diff --git a/_config/database.yml b/_config/database.yml new file mode 100644 index 000000000..d4a257d45 --- /dev/null +++ b/_config/database.yml @@ -0,0 +1,16 @@ +--- +name: databaseconnectors +--- +Injector: + MySQLPDODatabase: + class: 'MySQLDatabase' + properties: + connector: %$PDOConnector + schemaManager: %$MySQLSchemaManager + queryBuilder: %$DBQueryBuilder + MySQLDatabase: + class: 'MySQLDatabase' + properties: + connector: %$MySQLiConnector + schemaManager: %$MySQLSchemaManager + queryBuilder: %$DBQueryBuilder diff --git a/_register_database.php b/_register_database.php index 963b6f349..4e99c277b 100644 --- a/_register_database.php +++ b/_register_database.php @@ -3,61 +3,29 @@ // Register the SilverStripe provided databases $frameworkPath = defined('FRAMEWORK_PATH') ? FRAMEWORK_PATH : FRAMEWORK_NAME; +// Use MySQLi as default DatabaseAdapterRegistry::register( array( 'class' => 'MySQLDatabase', - 'title' => 'MySQL 5.0+', + 'title' => 'MySQL 5.0+ (using MySQLi)', 'helperPath' => $frameworkPath . '/dev/install/MySQLDatabaseConfigurationHelper.php', 'supported' => class_exists('MySQLi'), + 'missingExtensionText' => + 'The MySQLi + PHP extension is not available. Please install or enable it and refresh this page.' ) ); +// Setup MySQL PDO as alternate option DatabaseAdapterRegistry::register( array( - 'class' => 'MSSQLDatabase', - 'title' => 'SQL Server 2008', - 'helperPath' => 'mssql/code/MSSQLDatabaseConfigurationHelper.php', - 'supported' => (function_exists('mssql_connect') || function_exists('sqlsrv_connect')), - 'missingExtensionText' => 'Neither the mssql or' - . ' sqlsrv PHP extensions are' - . ' available. Please install or enable one of them and refresh this page.' + 'class' => 'MySQLPDODatabase', + 'title' => 'MySQL 5.0+ (using PDO)', + 'helperPath' => $frameworkPath . '/dev/install/MySQLDatabaseConfigurationHelper.php', + 'supported' => (class_exists('PDO') && in_array('mysql', PDO::getAvailableDrivers())), + 'missingExtensionText' => + 'Either the PDO Extension or + the MySQL PDO Driver + are unavailable. Please install or enable these and refresh this page.' ) -); - -DatabaseAdapterRegistry::register( - array( - 'class' => 'PostgreSQLDatabase', - 'title' => 'PostgreSQL 8.3+', - 'helperPath' => 'postgresql/code/PostgreSQLDatabaseConfigurationHelper.php', - 'supported' => function_exists('pg_query'), - 'missingExtensionText' => 'The pgsql PHP extension is not available. Please' - . ' install or enable it and refresh this page.' - ) -); - -DatabaseAdapterRegistry::register( - array( - 'class' => 'SQLiteDatabase', - 'title' => 'SQLite 3.3+', - 'helperPath' => 'sqlite3/code/SQLiteDatabaseConfigurationHelper.php', - 'supported' => (class_exists('SQLite3') || class_exists('PDO')), - 'missingExtensionText' => 'The SQLite3 and' - . ' PDO classes are not available. Please install or' - . ' enable one of them and refresh this page.', - 'fields' => array( - 'path' => array( - 'title' => 'Database path
Absolute path, writeable by the webserver user.
' - . 'Recommended to be outside of your webroot
', - 'default' => realpath(dirname($_SERVER['SCRIPT_FILENAME'])) . DIRECTORY_SEPARATOR . 'assets' - . DIRECTORY_SEPARATOR . '.db' - ), - 'database' => array( - 'title' => 'Database name', - 'default' => 'SS_mysite', - 'attributes' => array( - "onchange" => "this.value = this.value.replace(/[\/\\:*?"<>|. \t]+/g,'');" - ) - ) - ) - ) -); +); \ No newline at end of file diff --git a/admin/code/CMSBatchAction.php b/admin/code/CMSBatchAction.php index 4e28371e9..0842544b9 100644 --- a/admin/code/CMSBatchAction.php +++ b/admin/code/CMSBatchAction.php @@ -117,16 +117,10 @@ abstract class CMSBatchAction extends Object { $applicableIDs = array(); - $SQL_ids = implode(', ', array_filter($ids, 'is_numeric')); - $draftPages = DataObject::get( - $this->managedClass, - sprintf( - "\"%s\".\"ID\" IN (%s)", - ClassInfo::baseDataClass($this->managedClass), - $SQL_ids - ) - ); + $managedClass = $this->managedClass; + $draftPages = DataObject::get($managedClass)->byIDs($ids); + // Filter out the live-only ids $onlyOnLive = array_fill_keys($ids, true); if($checkStagePages) { foreach($draftPages as $obj) { @@ -134,24 +128,13 @@ abstract class CMSBatchAction extends Object { if($obj->$methodName()) $applicableIDs[] = $obj->ID; } } + $onlyOnLive = array_keys($onlyOnLive); - $managed_class = $this->managedClass; - if($managed_class::has_extension('Versioned')) { + if($checkLivePages && $onlyOnLive && $managedClass::has_extension('Versioned')) { // Get the pages that only exist on live (deleted from stage) - if($checkLivePages && $onlyOnLive) { - $SQL_ids = implode(', ', array_keys($onlyOnLive)); - $livePages = Versioned::get_by_stage( - $this->managedClass, "Live", - sprintf( - "\"%s\".\"ID\" IN (%s)", - ClassInfo::baseDataClass($this->managedClass), - $SQL_ids - ) - ); - - if($livePages) foreach($livePages as $obj) { - if($obj->$methodName()) $applicableIDs[] = $obj->ID; - } + $livePages = Versioned::get_by_stage($managedClass, "Live")->byIDs($onlyOnLive); + foreach($livePages as $obj) { + if($obj->$methodName()) $applicableIDs[] = $obj->ID; } } diff --git a/admin/code/CMSBatchActionHandler.php b/admin/code/CMSBatchActionHandler.php index a8854e662..c8dc2c4f8 100644 --- a/admin/code/CMSBatchActionHandler.php +++ b/admin/code/CMSBatchActionHandler.php @@ -95,14 +95,8 @@ class CMSBatchActionHandler extends RequestHandler { Translatable::disable_locale_filter(); } - $pages = DataObject::get( - $this->recordClass, - sprintf( - '"%s"."ID" IN (%s)', - ClassInfo::baseDataClass($this->recordClass), - implode(", ", $ids) - ) - ); + $recordClass = $this->recordClass; + $pages = DataObject::get($recordClass)->byIDs($ids); if(class_exists('Translatable') && SiteTree::has_extension('Translatable')) { Translatable::enable_locale_filter(); @@ -112,16 +106,11 @@ class CMSBatchActionHandler extends RequestHandler { if($record_class::has_extension('Versioned')) { // If we didn't query all the pages, then find the rest on the live site if(!$pages || $pages->Count() < sizeof($ids)) { + $idsFromLive = array(); foreach($ids as $id) $idsFromLive[$id] = true; if($pages) foreach($pages as $page) unset($idsFromLive[$page->ID]); $idsFromLive = array_keys($idsFromLive); - - $sql = sprintf( - '"%s"."ID" IN (%s)', - $this->recordClass, - implode(", ", $idsFromLive) - ); - $livePages = Versioned::get_by_stage($this->recordClass, 'Live', $sql); + $livePages = Versioned::get_by_stage($this->recordClass, 'Live')->byIDs($idsFromLive); if($pages) { // Can't merge into a DataList, need to condense into an actual list first // (which will retrieve all records as objects, so its an expensive operation) diff --git a/admin/code/LeftAndMain.php b/admin/code/LeftAndMain.php index 00f9ec718..ea06b5d26 100644 --- a/admin/code/LeftAndMain.php +++ b/admin/code/LeftAndMain.php @@ -547,7 +547,7 @@ class LeftAndMain extends Controller implements PermissionProvider { * Return styling for the menu icon, if a custom icon is set for this class * * Example: static $menu-icon = '/path/to/image/'; - * @param type $class + * @param string $class * @return string */ public static function menu_icon_for_class($class) { @@ -975,14 +975,14 @@ class LeftAndMain extends Controller implements PermissionProvider { $className = $this->stat('tree_class'); // Existing or new record? - $SQL_id = Convert::raw2sql($data['ID']); - if(substr($SQL_id,0,3) != 'new') { - $record = DataObject::get_by_id($className, $SQL_id); + $id = $data['ID']; + if(substr($id,0,3) != 'new') { + $record = DataObject::get_by_id($className, $id); if($record && !$record->canEdit()) return Security::permissionFailure($this); - if(!$record || !$record->ID) $this->httpError(404, "Bad record ID #" . (int)$data['ID']); + if(!$record || !$record->ID) $this->httpError(404, "Bad record ID #" . (int)$id); } else { if(!singleton($this->stat('tree_class'))->canCreate()) return Security::permissionFailure($this); - $record = $this->getNewItem($SQL_id, false); + $record = $this->getNewItem($id, false); } // save form data into record @@ -998,9 +998,10 @@ class LeftAndMain extends Controller implements PermissionProvider { public function delete($data, $form) { $className = $this->stat('tree_class'); - $record = DataObject::get_by_id($className, Convert::raw2sql($data['ID'])); + $id = $data['ID']; + $record = DataObject::get_by_id($className, $id); if($record && !$record->canDelete()) return Security::permissionFailure(); - if(!$record || !$record->ID) $this->httpError(404, "Bad record ID #" . (int)$data['ID']); + if(!$record || !$record->ID) $this->httpError(404, "Bad record ID #" . (int)$id); $record->delete(); @@ -1078,12 +1079,11 @@ class LeftAndMain extends Controller implements PermissionProvider { // Update all dependent pages if(class_exists('VirtualPage')) { - if($virtualPages = DataObject::get("VirtualPage", "\"CopyContentFromID\" = $node->ID")) { - foreach($virtualPages as $virtualPage) { - $statusUpdates['modified'][$virtualPage->ID] = array( - 'TreeTitle' => $virtualPage->TreeTitle() - ); - } + $virtualPages = VirtualPage::get()->filter("CopyContentFromID", $node->ID); + foreach($virtualPages as $virtualPage) { + $statusUpdates['modified'][$virtualPage->ID] = array( + 'TreeTitle' => $virtualPage->TreeTitle() + ); } } @@ -1105,8 +1105,10 @@ class LeftAndMain extends Controller implements PermissionProvider { // Nodes that weren't "actually moved" shouldn't be registered as // having been edited; do a direct SQL update instead ++$counter; - DB::query(sprintf("UPDATE \"%s\" SET \"Sort\" = %d WHERE \"ID\" = '%d'", - $className, $counter, $id)); + DB::prepared_query( + "UPDATE \"$className\" SET \"Sort\" = ? WHERE \"ID\" = ?", + array($counter, $id) + ); } } @@ -1777,8 +1779,11 @@ class LeftAndMainMarkingFilter { // We need to recurse up the tree, // finding ParentIDs for each ID until we run out of parents while (!empty($parents)) { - $res = DB::query('SELECT "ParentID", "ID" FROM "SiteTree"' - . ' WHERE "ID" in ('.implode(',',array_keys($parents)).')'); + $parentsClause = DB::placeholders($parents); + $res = DB::prepared_query( + "SELECT \"ParentID\", \"ID\" FROM \"SiteTree\" WHERE \"ID\" in ($parentsClause)", + array_keys($parents) + ); $parents = array(); foreach($res as $row) { @@ -1792,19 +1797,16 @@ class LeftAndMainMarkingFilter { protected function getQuery($params) { $where = array(); - $SQL_params = Convert::raw2sql($params); - if(isset($SQL_params['ID'])) unset($SQL_params['ID']); - foreach($SQL_params as $name => $val) { - switch($name) { - default: - // Partial string match against a variety of fields - if(!empty($val) && singleton("SiteTree")->hasDatabaseField($name)) { - $where[] = "\"$name\" LIKE '%$val%'"; - } + if(isset($params['ID'])) unset($params['ID']); + if($treeClass = static::config()->tree_class) foreach($params as $name => $val) { + // Partial string match against a variety of fields + if(!empty($val) && singleton($treeClass)->hasDatabaseField($name)) { + $predicate = sprintf('"%s" LIKE ?', $name); + $where[$predicate] = "%$val%"; } } - return new SQLQuery( + return new SQLSelect( array("ParentID", "ID"), 'SiteTree', $where diff --git a/admin/tests/LeftAndMainTest.php b/admin/tests/LeftAndMainTest.php index 9f727eb37..96701886c 100644 --- a/admin/tests/LeftAndMainTest.php +++ b/admin/tests/LeftAndMainTest.php @@ -63,7 +63,9 @@ class LeftAndMainTest extends FunctionalTest { $this->loginWithPermission('ADMIN'); // forcing sorting for non-MySQL - $rootPages = DataObject::get('LeftAndMainTest_Object', '"ParentID" = 0', '"ID"'); + $rootPages = LeftAndMainTest_Object::get() + ->filter("ParentID", 0) + ->sort('"ID"'); $siblingIDs = $rootPages->column('ID'); $page1 = $rootPages->offsetGet(0); $page2 = $rootPages->offsetGet(1); @@ -167,9 +169,11 @@ class LeftAndMainTest extends FunctionalTest { // restricted cms user $this->session()->inst_set('loggedInAs', $securityonlyuser->ID); $menuItems = singleton('LeftAndMain')->MainMenu(false); + $menuItems = array_map($allValsFn, $menuItems->column('Code')); + sort($menuItems); $this->assertEquals( - array_map($allValsFn, $menuItems->column('Code')), - array('SecurityAdmin','Help'), + $menuItems, + array('Help', 'SecurityAdmin'), 'Groups with limited access can only access the interfaces they have permissions for' ); diff --git a/conf/ConfigureFromEnv.php b/conf/ConfigureFromEnv.php index 90b5ce97a..534701d74 100644 --- a/conf/ConfigureFromEnv.php +++ b/conf/ConfigureFromEnv.php @@ -141,3 +141,6 @@ if(defined('SS_USE_BASIC_AUTH') && SS_USE_BASIC_AUTH) { if(defined('SS_ERROR_LOG')) { SS_Log::add_writer(new SS_LogFileWriter(BASE_PATH . '/' . SS_ERROR_LOG), SS_Log::WARN, '<='); } + +// Allow database adapters to handle their own configuration +DatabaseAdapterRegistry::autoconfigure(); diff --git a/control/Session.php b/control/Session.php index 5940e92aa..a384de0e1 100644 --- a/control/Session.php +++ b/control/Session.php @@ -504,11 +504,11 @@ class Session { * Sets the appropriate form message in session, with type. This will be shown once, * for the form specified. * - * @param formname the form name you wish to use ( usually $form->FormName() ) - * @param messsage the message you wish to add to it - * @param type the type of message + * @param string $formname the form name you wish to use ( usually $form->FormName() ) + * @param string $message the message you wish to add to it + * @param string $type the type of message */ - public static function setFormMessage($formname,$message,$type){ + public static function setFormMessage($formname, $message, $type){ Session::set("FormInfo.$formname.formError.message", $message); Session::set("FormInfo.$formname.formError.type", $type); } diff --git a/control/injector/Injector.php b/control/injector/Injector.php index 2a7470bf7..a42dd5850 100644 --- a/control/injector/Injector.php +++ b/control/injector/Injector.php @@ -315,14 +315,10 @@ class Injector { * of a particular type is injected, and what items should be injected * for those properties / methods. * - * @param type $class - * The class to set a mapping for - * @param type $property - * The property to set the mapping for - * @param type $injectType - * The registered type that will be injected - * @param string $injectVia - * Whether to inject by setting a property or calling a setter + * @param string $class The class to set a mapping for + * @param string $property The property to set the mapping for + * @param string $toInject The registered type that will be injected + * @param string $injectVia Whether to inject by setting a property or calling a setter */ public function setInjectMapping($class, $property, $toInject, $injectVia = 'property') { $mapping = isset($this->injectMap[$class]) ? $this->injectMap[$class] : array(); @@ -757,8 +753,7 @@ class Injector { * Removes a named object from the cached list of objects managed * by the inject * - * @param type $name - * The name to unregister + * @param string $name The name to unregister */ public function unregisterNamedObject($name) { unset($this->serviceCache[$name]); @@ -793,7 +788,7 @@ class Injector { * Optional set of arguments to pass as constructor arguments * if this object is to be created from scratch * (ie asSingleton = false) - * + * @return mixed the instance of the specified object */ public function get($name, $asSingleton = true, $constructorArgs = null) { // reassign the name as it might actually be a compound name @@ -866,7 +861,8 @@ class Injector { * * Additional parameters are passed through as * - * @param type $name + * @param string $name + * @return mixed A new instance of the specified object */ public function create($name) { $constructorArgs = func_get_args(); diff --git a/core/ClassInfo.php b/core/ClassInfo.php index 5a25a4a5d..126d24425 100644 --- a/core/ClassInfo.php +++ b/core/ClassInfo.php @@ -25,7 +25,7 @@ class ClassInfo { /** * Cache for {@link hasTable()} */ - private static $_cache_all_tables = null; + private static $_cache_all_tables = array(); /** * @var Array Cache for {@link ancestry()}. @@ -36,17 +36,11 @@ class ClassInfo { * @todo Move this to SS_Database or DB */ public static function hasTable($class) { - if(DB::isActive()) { - // Cache the list of all table names to reduce on DB traffic - if(empty(self::$_cache_all_tables)) { - self::$_cache_all_tables = array(); - $tables = DB::query(DB::getConn()->allTablesSQL())->column(); - foreach($tables as $table) self::$_cache_all_tables[strtolower($table)] = true; - } - return isset(self::$_cache_all_tables[strtolower($class)]); - } else { - return false; + // Cache the list of all table names to reduce on DB traffic + if(empty(self::$_cache_all_tables) && DB::is_active()) { + self::$_cache_all_tables = DB::get_schema()->tableList(); } + return !empty(self::$_cache_all_tables[strtolower($class)]); } public static function reset_db_cache() { @@ -56,17 +50,21 @@ class ClassInfo { /** * Returns the manifest of all classes which are present in the database. + * * @param string $class Class name to check enum values for ClassName field + * @param boolean $includeUnbacked Flag indicating whether or not to include + * types that don't exist as implemented classes. By default these are excluded. + * @return array List of subclasses */ public static function getValidSubClasses($class = 'SiteTree', $includeUnbacked = false) { - $classes = DB::getConn()->enumValuesForField($class, 'ClassName'); + $classes = DB::get_schema()->enumValuesForField($class, 'ClassName'); if (!$includeUnbacked) $classes = array_filter($classes, array('ClassInfo', 'exists')); return $classes; } /** * Returns an array of the current class and all its ancestors and children - * which have a DB table. + * which require a DB table. * * @param string|object $class * @todo Move this into data object @@ -81,10 +79,11 @@ class ClassInfo { $classes = array_merge( self::ancestry($class), - self::subclassesFor($class)); + self::subclassesFor($class) + ); foreach ($classes as $class) { - if (self::hasTable($class)) $result[$class] = $class; + if (DataObject::has_own_table($class)) $result[$class] = $class; } return $result; diff --git a/core/Config.php b/core/Config.php index f048d0fa1..7a7c77ef8 100644 --- a/core/Config.php +++ b/core/Config.php @@ -636,12 +636,12 @@ class Config { * every other source is filtered on request, so no amount of changes to parent's configuration etc can override a * remove call. * - * @param $class string - The class to remove a configuration value from - * @param $name string - The configuration name - * @param $key any - An optional key to filter against. + * @param string $class The class to remove a configuration value from + * @param string $name The configuration name + * @param mixed $key An optional key to filter against. * If referenced config value is an array, only members of that array that match this key will be removed * Must also match value if provided to be removed - * @param $value any - And optional value to filter against. + * @param mixed $value And optional value to filter against. * If referenced config value is an array, only members of that array that match this value will be removed * If referenced config value is not an array, value will be removed only if it matches this argument * Must also match key if provided and referenced config value is an array to be removed diff --git a/core/Convert.php b/core/Convert.php index f5fce0fcf..7c628d1e1 100644 --- a/core/Convert.php +++ b/core/Convert.php @@ -141,12 +141,48 @@ class Convert { return self::raw2json($val); } - public static function raw2sql($val) { + /** + * Safely encodes a value (or list of values) using the current database's + * safe string encoding method + * + * @param mixed|array $val Input value, or list of values as an array + * @param boolean $quoted Flag indicating whether the value should be safely + * quoted, instead of only being escaped. By default this function will + * only escape the string (false). + * @return string|array Safely encoded value in the same format as the input + */ + public static function raw2sql($val, $quoted = false) { if(is_array($val)) { - foreach($val as $k => $v) $val[$k] = self::raw2sql($v); + foreach($val as $k => $v) { + $val[$k] = self::raw2sql($v, $quoted); + } return $val; } else { - return DB::getConn()->addslashes($val); + if($quoted) { + return DB::get_conn()->quoteString($val); + } else { + return DB::get_conn()->escapeString($val); + } + } + } + + /** + * Safely encodes a SQL symbolic identifier (or list of identifiers), such as a database, + * table, or column name. Supports encoding of multi identfiers separated by + * a delimiter (e.g. ".") + * + * @param string|array $identifier The identifier to escape. E.g. 'SiteTree.Title' + * @param string $separator The string that delimits subsequent identifiers + * @return string|array The escaped identifier. E.g. '"SiteTree"."Title"' + */ + public static function symbol2sql($identifier, $separator = '.') { + if(is_array($identifier)) { + foreach($identifier as $k => $v) { + $identifier[$k] = self::symbol2sql($v, $separator); + } + return $identifier; + } else { + return DB::get_conn()->escapeIdentifier($identifier, $separator); } } @@ -350,6 +386,7 @@ class Convert { /** * Normalises newline sequences to conform to (an) OS specific format. + * * @param string $data Text containing potentially mixed formats of newline * sequences including \r, \r\n, \n, or unicode newline characters * @param string $nl The newline sequence to normalise to. Defaults to that diff --git a/core/PaginatedList.php b/core/PaginatedList.php index 8a27ccb91..22bdcb1a1 100644 --- a/core/PaginatedList.php +++ b/core/PaginatedList.php @@ -144,9 +144,9 @@ class PaginatedList extends SS_ListDecorator { * Sets the page length, page start and total items from a query object's * limit, offset and unlimited count. The query MUST have a limit clause. * - * @param SQLQuery $query + * @param SQLSelect $query */ - public function setPaginationFromQuery(SQLQuery $query) { + public function setPaginationFromQuery(SQLSelect $query) { if ($limit = $query->getLimit()) { $this->setPageLength($limit['limit']); $this->setPageStart($limit['start']); diff --git a/dev/CsvBulkLoader.php b/dev/CsvBulkLoader.php index 4d04198a2..0a75e0bf9 100644 --- a/dev/CsvBulkLoader.php +++ b/dev/CsvBulkLoader.php @@ -208,19 +208,17 @@ class CsvBulkLoader extends BulkLoader { foreach($this->duplicateChecks as $fieldName => $duplicateCheck) { if(is_string($duplicateCheck)) { - $SQL_fieldName = Convert::raw2sql($duplicateCheck); - if(!isset($record[$SQL_fieldName]) || empty($record[$SQL_fieldName])) { - //skip current duplicate check if field value is empty - continue; - } + // Skip current duplicate check if field value is empty + if(empty($record[$duplicateCheck])) continue; - $SQL_fieldValue = Convert::raw2sql($record[$SQL_fieldName]); - $existingRecord = DataObject::get_one($this->objectClass, "\"$SQL_fieldName\" = '{$SQL_fieldValue}'"); + // Check existing record with this value + $dbFieldValue = $record[$duplicateCheck]; + $existingRecord = DataObject::get($this->objectClass) + ->filter($duplicateCheck, $dbFieldValue) + ->first(); - if($existingRecord) { - return $existingRecord; - } + if($existingRecord) return $existingRecord; } elseif(is_array($duplicateCheck) && isset($duplicateCheck['callback'])) { if($this->hasMethod($duplicateCheck['callback'])) { $existingRecord = $this->{$duplicateCheck['callback']}($record[$fieldName], $record); diff --git a/dev/Debug.php b/dev/Debug.php index c87774803..f54f1b129 100644 --- a/dev/Debug.php +++ b/dev/Debug.php @@ -326,11 +326,10 @@ class Debug { } if(!headers_sent()) { - $currController = Controller::has_curr() ? Controller::curr() : null; // Ensure the error message complies with the HTTP 1.1 spec $msg = strip_tags(str_replace(array("\n", "\r"), '', $friendlyErrorMessage)); - if($currController) { - $response = $currController->getResponse(); + if(Controller::has_curr()) { + $response = Controller::curr()->getResponse(); $response->setStatusCode($statusCode, $msg); } else { header($_SERVER['SERVER_PROTOCOL'] . " $statusCode $msg"); @@ -466,24 +465,22 @@ class Debug { // being called again. // This basically calls Permission::checkMember($_SESSION['loggedInAs'], 'ADMIN'); + // @TODO - Rewrite safely using DataList::filter $memberID = $_SESSION['loggedInAs']; - - $groups = DB::query("SELECT \"GroupID\" from \"Group_Members\" WHERE \"MemberID\" = " . $memberID); - $groupCSV = implode($groups->column(), ','); - - $permission = DB::query(" - SELECT \"ID\" - FROM \"Permission\" - WHERE ( - \"Code\" = 'ADMIN' - AND \"Type\" = " . Permission::GRANT_PERMISSION . " - AND \"GroupID\" IN ($groupCSV) + $permission = DB::prepared_query(' + SELECT "ID" FROM "Permission" + INNER JOIN "Group_Members" ON "Permission"."GroupID" = "Group_Members"."GroupID" + WHERE "Permission"."Code" = ? + AND "Permission"."Type" = ? + AND "Group_Members"."MemberID" = ?', + array( + 'ADMIN', // Code + Permission::GRANT_PERMISSION, // Type + $memberID // MemberID ) - ")->value(); + )->value(); - if($permission) { - return; - } + if($permission) return; } // This basically does the same as diff --git a/dev/FixtureBlueprint.php b/dev/FixtureBlueprint.php index 0e287f608..e6e9776e1 100644 --- a/dev/FixtureBlueprint.php +++ b/dev/FixtureBlueprint.php @@ -86,7 +86,7 @@ class FixtureBlueprint { $obj->ID = $data['ID']; // The database needs to allow inserting values into the foreign key column (ID in our case) - $conn = DB::getConn(); + $conn = DB::get_conn(); if(method_exists($conn, 'allowPrimaryKeyEditing')) { $conn->allowPrimaryKeyEditing(ClassInfo::baseDataClass($class), true); } @@ -187,12 +187,12 @@ class FixtureBlueprint { // If LastEdited was set in the fixture, set it here if($data && array_key_exists('LastEdited', $data)) { $edited = $this->parseValue($data['LastEdited'], $fixtures); - DB::manipulate(array( - $class => array( - "command" => "update", "id" => $obj->id, - "fields" => array("LastEdited" => "'".$edited."'") - ) - )); + $update = new SQLUpdate( + $class, + array('"LastEdited"' => $edited), + array('"ID"' => $obj->id) + ); + $update->execute(); } } catch(Exception $e) { Config::inst()->update('DataObject', 'validation_enabled', $validationenabled); diff --git a/dev/FixtureFactory.php b/dev/FixtureFactory.php index 7f206e68c..1b01535b2 100644 --- a/dev/FixtureFactory.php +++ b/dev/FixtureFactory.php @@ -89,12 +89,13 @@ class FixtureFactory { * @return Int Database identifier */ public function createRaw($table, $identifier, $data) { - $manipulation = array($table => array("fields" => array(), "command" => "insert")); - foreach($data as $fieldName => $fieldVal) { - $manipulation[$table]["fields"][$fieldName] = "'" . $this->parseValue($fieldVal) . "'"; + $fields = array(); + foreach($data as $fieldName => $fieldVal) { + $fields["\"$fieldName\""] = $this->parseValue($fieldVal); } - DB::manipulate($manipulation); - $id = DB::getGeneratedID($table); + $insert = new SQLInsert($table, $fields); + $insert->execute(); + $id = DB::get_generated_id($table); $this->fixtures[$table][$identifier] = $id; return $id; @@ -171,10 +172,10 @@ class FixtureFactory { $class::get()->byId($dbId)->delete(); } else { $table = $class; - DB::manipulate(array( - $table => array("fields" => array('ID' => $dbId), - "command" => "delete") + $delete = new SQLDelete("\"$table\"", array( + "\"$table\".\"ID\"" => $dbId )); + $delete->execute(); } unset($this->fixtures[$class][$id]); diff --git a/dev/LogErrorEmailFormatter.php b/dev/LogErrorEmailFormatter.php index b9213005b..7dbc347eb 100644 --- a/dev/LogErrorEmailFormatter.php +++ b/dev/LogErrorEmailFormatter.php @@ -41,8 +41,8 @@ class SS_LogErrorEmailFormatter implements Zend_Log_Formatter_Interface { $data = ''; $data .= ''; $data .= "
\n"; - $data .= "

" - . "[$errorType] $errstr
$errfile:$errline\n
\n
\n

\n"; + $data .= "

[$errorType] "; + $data .= nl2br(htmlspecialchars($errstr))."
$errfile:$errline\n
\n
\n

\n"; // Render the provided backtrace $data .= SS_Backtrace::get_rendered_backtrace($errcontext); diff --git a/dev/SapphireTest.php b/dev/SapphireTest.php index 81d67f499..9a212842b 100644 --- a/dev/SapphireTest.php +++ b/dev/SapphireTest.php @@ -224,7 +224,7 @@ class SapphireTest extends PHPUnit_Framework_TestCase { // Set up fixture if($fixtureFile || $this->usesDatabase || !self::using_temp_db()) { - if(substr(DB::getConn()->currentDatabase(), 0, strlen($prefix) + 5) + if(substr(DB::get_conn()->getSelectedDatabase(), 0, strlen($prefix) + 5) != strtolower(sprintf('%stmpdb', $prefix))) { //echo "Re-creating temp database... "; @@ -700,6 +700,74 @@ class SapphireTest extends PHPUnit_Framework_TestCase { ); } } + + /** + * Removes sequences of repeated whitespace characters from SQL queries + * making them suitable for string comparison + * + * @param string $sql + * @return string The cleaned and normalised SQL string + */ + protected function normaliseSQL($sql) { + return trim(preg_replace('/\s+/m', ' ', $sql)); + } + + /** + * Asserts that two SQL queries are equivalent + * + * @param string $expectedSQL + * @param string $actualSQL + * @param string $message + * @param float $delta + * @param integer $maxDepth + * @param boolean $canonicalize + * @param boolean $ignoreCase + */ + public function assertSQLEquals($expectedSQL, $actualSQL, $message = '', $delta = 0, $maxDepth = 10, + $canonicalize = false, $ignoreCase = false + ) { + // Normalise SQL queries to remove patterns of repeating whitespace + $expectedSQL = $this->normaliseSQL($expectedSQL); + $actualSQL = $this->normaliseSQL($actualSQL); + + $this->assertEquals($expectedSQL, $actualSQL, $message, $delta, $maxDepth, $canonicalize, $ignoreCase); + } + + /** + * Asserts that a SQL query contains a SQL fragment + * + * @param string $needleSQL + * @param string $haystackSQL + * @param string $message + * @param boolean $ignoreCase + * @param boolean $checkForObjectIdentity + */ + public function assertSQLContains($needleSQL, $haystackSQL, $message = '', $ignoreCase = false, + $checkForObjectIdentity = true + ) { + $needleSQL = $this->normaliseSQL($needleSQL); + $haystackSQL = $this->normaliseSQL($haystackSQL); + + $this->assertContains($needleSQL, $haystackSQL, $message, $ignoreCase, $checkForObjectIdentity); + } + + /** + * Asserts that a SQL query contains a SQL fragment + * + * @param string $needleSQL + * @param string $haystackSQL + * @param string $message + * @param boolean $ignoreCase + * @param boolean $checkForObjectIdentity + */ + public function assertSQLNotContains($needleSQL, $haystackSQL, $message = '', $ignoreCase = false, + $checkForObjectIdentity = true + ) { + $needleSQL = $this->normaliseSQL($needleSQL); + $haystackSQL = $this->normaliseSQL($haystackSQL); + + $this->assertNotContains($needleSQL, $haystackSQL, $message, $ignoreCase, $checkForObjectIdentity); + } /** * Helper function for the DOS matchers @@ -724,18 +792,18 @@ class SapphireTest extends PHPUnit_Framework_TestCase { * Returns true if we are currently using a temporary database */ public static function using_temp_db() { - $dbConn = DB::getConn(); + $dbConn = DB::get_conn(); $prefix = defined('SS_DATABASE_PREFIX') ? SS_DATABASE_PREFIX : 'ss_'; - return $dbConn && (substr($dbConn->currentDatabase(), 0, strlen($prefix) + 5) + return $dbConn && (substr($dbConn->getSelectedDatabase(), 0, strlen($prefix) + 5) == strtolower(sprintf('%stmpdb', $prefix))); } public static function kill_temp_db() { // Delete our temporary database if(self::using_temp_db()) { - $dbConn = DB::getConn(); - $dbName = $dbConn->currentDatabase(); - if($dbName && DB::getConn()->databaseExists($dbName)) { + $dbConn = DB::get_conn(); + $dbName = $dbConn->getSelectedDatabase(); + if($dbName && DB::get_conn()->databaseExists($dbName)) { // Some DataExtensions keep a static cache of information that needs to // be reset whenever the database is killed foreach(ClassInfo::subclassesFor('DataExtension') as $class) { @@ -744,7 +812,7 @@ class SapphireTest extends PHPUnit_Framework_TestCase { } // echo "Deleted temp database " . $dbConn->currentDatabase() . "\n"; - $dbConn->dropDatabase(); + $dbConn->dropSelectedDatabase(); } } } @@ -754,8 +822,7 @@ class SapphireTest extends PHPUnit_Framework_TestCase { */ public static function empty_temp_db() { if(self::using_temp_db()) { - $dbadmin = new DatabaseAdmin(); - $dbadmin->clearAllData(); + DB::get_conn()->clearAllData(); // Some DataExtensions keep a static cache of information that needs to // be reset whenever the database is cleaned out @@ -775,15 +842,14 @@ class SapphireTest extends PHPUnit_Framework_TestCase { global $databaseConfig; $databaseConfig['timezone'] = '+0:00'; DB::connect($databaseConfig); - $dbConn = DB::getConn(); + $dbConn = DB::get_conn(); $prefix = defined('SS_DATABASE_PREFIX') ? SS_DATABASE_PREFIX : 'ss_'; $dbname = strtolower(sprintf('%stmpdb', $prefix)) . rand(1000000,9999999); while(!$dbname || $dbConn->databaseExists($dbname)) { $dbname = strtolower(sprintf('%stmpdb', $prefix)) . rand(1000000,9999999); } - $dbConn->selectDatabase($dbname); - $dbConn->createDatabase(); + $dbConn->selectDatabase($dbname, true); $st = Injector::inst()->create('SapphireTest'); $st->resetDBSchema(); @@ -796,9 +862,9 @@ class SapphireTest extends PHPUnit_Framework_TestCase { public static function delete_all_temp_dbs() { $prefix = defined('SS_DATABASE_PREFIX') ? SS_DATABASE_PREFIX : 'ss_'; - foreach(DB::getConn()->allDatabaseNames() as $dbName) { + foreach(DB::get_schema()->databaseList() as $dbName) { if(preg_match(sprintf('/^%stmpdb[0-9]+$/', $prefix), $dbName)) { - DB::getConn()->dropDatabaseByName($dbName); + DB::get_schema()->dropDatabase($dbName); if(Director::is_cli()) { echo "Dropped database \"$dbName\"" . PHP_EOL; } else { @@ -823,27 +889,26 @@ class SapphireTest extends PHPUnit_Framework_TestCase { $dataClasses = ClassInfo::subclassesFor('DataObject'); array_shift($dataClasses); - $conn = DB::getConn(); - $conn->beginSchemaUpdate(); DB::quiet(); - - foreach($dataClasses as $dataClass) { - // Check if class exists before trying to instantiate - this sidesteps any manifest weirdness - if(class_exists($dataClass)) { - $SNG = singleton($dataClass); - if(!($SNG instanceof TestOnly)) $SNG->requireTable(); + $schema = DB::get_schema(); + $extraDataObjects = $includeExtraDataObjects ? $this->extraDataObjects : null; + $schema->schemaUpdate(function() use($dataClasses, $extraDataObjects){ + foreach($dataClasses as $dataClass) { + // Check if class exists before trying to instantiate - this sidesteps any manifest weirdness + if(class_exists($dataClass)) { + $SNG = singleton($dataClass); + if(!($SNG instanceof TestOnly)) $SNG->requireTable(); + } } - } - - // If we have additional dataobjects which need schema, do so here: - if($includeExtraDataObjects && $this->extraDataObjects) { - foreach($this->extraDataObjects as $dataClass) { - $SNG = singleton($dataClass); - if(singleton($dataClass) instanceof DataObject) $SNG->requireTable(); + + // If we have additional dataobjects which need schema, do so here: + if($extraDataObjects) { + foreach($extraDataObjects as $dataClass) { + $SNG = singleton($dataClass); + if(singleton($dataClass) instanceof DataObject) $SNG->requireTable(); + } } - } - - $conn->endSchemaUpdate(); + }); ClassInfo::reset_db_cache(); singleton('DataObject')->flushCache(); @@ -865,7 +930,9 @@ class SapphireTest extends PHPUnit_Framework_TestCase { $permission->write(); $group->Permissions()->add($permission); - $member = DataObject::get_one('Member', sprintf('"Email" = \'%s\'', "$permCode@example.org")); + $member = DataObject::get_one('Member', array( + '"Member"."Email"' => "$permCode@example.org" + )); if(!$member) $member = Injector::inst()->create('Member'); $member->FirstName = $permCode; diff --git a/dev/TestRunner.php b/dev/TestRunner.php index 707737f69..b3918c3e5 100755 --- a/dev/TestRunner.php +++ b/dev/TestRunner.php @@ -99,8 +99,7 @@ class TestRunner extends Controller { // Invalidate classname spec since the test manifest will now pull out new subclasses for each internal class // (e.g. Member will now have various subclasses of DataObjects that implement TestOnly) - DataObject::clear_classname_spec_cache(); - PolymorphicForeignKey::clear_classname_spec_cache(); + DataObject::reset(); } public function init() { diff --git a/dev/install/DatabaseAdapterRegistry.php b/dev/install/DatabaseAdapterRegistry.php index 1d1c29368..29ffd7501 100644 --- a/dev/install/DatabaseAdapterRegistry.php +++ b/dev/install/DatabaseAdapterRegistry.php @@ -9,39 +9,47 @@ * @author Tom Rix */ class DatabaseAdapterRegistry { - + + /** + * Default database connector registration fields + * + * @var array + */ private static $default_fields = array( 'server' => array( - 'title' => 'Database server', - 'envVar' => 'SS_DATABASE_SERVER', + 'title' => 'Database server', + 'envVar' => 'SS_DATABASE_SERVER', 'default' => 'localhost' ), 'username' => array( - 'title' => 'Database username', - 'envVar' => 'SS_DATABASE_USERNAME', + 'title' => 'Database username', + 'envVar' => 'SS_DATABASE_USERNAME', 'default' => 'root' ), 'password' => array( - 'title' => 'Database password', - 'envVar' => 'SS_DATABASE_PASSWORD', + 'title' => 'Database password', + 'envVar' => 'SS_DATABASE_PASSWORD', 'default' => 'password' ), 'database' => array( - 'title' => 'Database name', + 'title' => 'Database name', 'default' => 'SS_mysite', 'attributes' => array( "onchange" => "this.value = this.value.replace(/[\/\\:*?"<>|. \t]+/g,'');" ) ), ); - + /** * Internal array of registered database adapters + * + * @var array */ private static $adapters = array(); - + /** * Add new adapter to the registry + * * @param array $config Associative array of configuration details */ public static function register($config) { @@ -55,20 +63,28 @@ class DatabaseAdapterRegistry { ? $config['missingModuleText'] : 'The SilverStripe module, '.$moduleName.', is missing or incomplete.' . ' Please download it.'; - + $config['missingModuleText'] = $missingModuleText; $config['missingExtensionText'] = $missingExtensionText; - + // set default fields if none are defined already if(!isset($config['fields'])) $config['fields'] = self::$default_fields; - + self::$adapters[$config['class']] = $config; } - + + /** + * Unregisters a database connector by classname + * + * @param string $class + */ public static function unregister($class) { - if(isset($adapters[$class])) unset($adapters[$class]); + unset(self::$adapters[$class]); } - + + /** + * Detects all _register_database.php files and invokes them + */ public static function autodiscover() { foreach(glob(dirname(__FILE__) . '/../../../*', GLOB_ONLYDIR) as $directory) { if(file_exists($directory . '/_register_database.php')) { @@ -76,9 +92,45 @@ class DatabaseAdapterRegistry { } } } - + + /** + * Detects all _configure_database.php files and invokes them + * Called by ConfigureFromEnv.php + */ + public static function autoconfigure() { + foreach(glob(dirname(__FILE__) . '/../../../*', GLOB_ONLYDIR) as $directory) { + if(file_exists($directory . '/_configure_database.php')) { + include_once($directory . '/_configure_database.php'); + } + } + } + + /** + * Return all registered adapters + * + * @return array + */ public static function get_adapters() { return self::$adapters; } + /** + * Returns registry data for a class + * + * @param string $class + * @return array List of adapter properties + */ + public static function get_adapter($class) { + if(isset(self::$adapters[$class])) return self::$adapters[$class]; + } + + /** + * Retrieves default field configuration + * + * @return array + */ + public function get_default_fields() { + return self::$default_fields; + } + } diff --git a/dev/install/DatabaseConfigurationHelper.php b/dev/install/DatabaseConfigurationHelper.php index fd086b26e..7104dc417 100644 --- a/dev/install/DatabaseConfigurationHelper.php +++ b/dev/install/DatabaseConfigurationHelper.php @@ -1,6 +1,8 @@ true, 'error' => 'details of error') */ @@ -29,6 +32,22 @@ interface DatabaseConfigurationHelper { * @return array Result - e.g. array('okay' => true, 'connection' => mysql link, 'error' => 'details of error') */ public function requireDatabaseConnection($databaseConfig); + + /** + * Determines the version of the database server + * + * @param array $databaseConfig Associative array of db configuration, e.g. "server", "username" etc + * @return string Version of database server or false on failure + */ + public function getDatabaseVersion($databaseConfig); + + /** + * Check database version is greater than the minimum supported + * + * @param array $databaseConfig Associative array of db configuration, e.g. "server", "username" etc + * @return array Result - e.g. array('success' => true, 'error' => 'details of error') + */ + public function requireDatabaseVersion($databaseConfig); /** * Ensure that the database connection is able to use an existing database, diff --git a/dev/install/MySQLDatabaseConfigurationHelper.php b/dev/install/MySQLDatabaseConfigurationHelper.php index 8d53048b5..91bc19141 100644 --- a/dev/install/MySQLDatabaseConfigurationHelper.php +++ b/dev/install/MySQLDatabaseConfigurationHelper.php @@ -1,4 +1,5 @@ connect_errno)) { + $conn->query("SET sql_mode = 'ANSI'"); + return $conn; + } else { + $error = ($conn->connect_errno) + ? $conn->connect_error + : 'Unknown connection error'; + return null; + } + case 'MySQLPDODatabase': + // May throw a PDOException if fails + $conn = @new PDO('mysql:host='.$databaseConfig['server'], $databaseConfig['username'], + $databaseConfig['password']); + if($conn) { + $conn->query("SET sql_mode = 'ANSI'"); + return $conn; + } else { + $error = 'Unknown connection error'; + return null; + } + default: + $error = 'Invalid connection type'; + return null; + } + } catch(Exception $ex) { + $error = $ex->getMessage(); + return null; + } } /** - * Ensure that the database server exists. - * @param array $databaseConfig Associative array of db configuration, e.g. "server", "username" etc - * @return array Result - e.g. array('success' => true, 'error' => 'details of error') + * Helper function to quickly extract a column from a mysqi_result + * + * @param mixed $results mysqli_result or enumerable list of rows + * @return array Resulting data */ - public function requireDatabaseServer($databaseConfig) { - $success = false; - $error = ''; - $conn = @new MySQLi($databaseConfig['server'], $databaseConfig['username'], $databaseConfig['password']); - if($conn && $conn->connect_errno) { - $success = false; - $error = $conn->connect_error; + protected function column($results) { + $array = array(); + if($results instanceof mysqli_result) { + while($row = $results->fetch_array()) { + $array[] = $row[0]; + } } else { - $success = true; + foreach($results as $row) { + $array[] = $row[0]; + } } + return $array; + } + + public function requireDatabaseFunctions($databaseConfig) { + $data = DatabaseAdapterRegistry::get_adapter($databaseConfig['type']); + return !empty($data['supported']); + } + + public function requireDatabaseServer($databaseConfig) { + $connection = $this->createConnection($databaseConfig, $error); + $success = !empty($connection); return array( 'success' => $success, @@ -43,28 +90,21 @@ class MySQLDatabaseConfigurationHelper implements DatabaseConfigurationHelper { ); } - /** - * Get the database version for the MySQL connection, given the - * database parameters. - * @return mixed string Version number as string | boolean FALSE on failure - */ public function getDatabaseVersion($databaseConfig) { - $conn = new MySQLi($databaseConfig['server'], $databaseConfig['username'], $databaseConfig['password']); - if(!$conn) return false; - $version = $conn->server_info; - if(!$version) { - // fallback to trying a query - $result = $conn->query('SELECT VERSION()'); - $row = $result->fetch_array(); - if($row && isset($row[0])) { - $version = trim($row[0]); - } + $conn = $this->createConnection($databaseConfig, $error); + if(!$conn) { + return false; + } elseif($conn instanceof MySQLi) { + return $conn->server_info; + } elseif($conn instanceof PDO) { + return $conn->getAttribute(PDO::ATTR_SERVER_VERSION); } - return $version; + return false; } /** * Ensure that the MySQL server version is at least 5.0. + * * @param array $databaseConfig Associative array of db configuration, e.g. "server", "username" etc * @return array Result - e.g. array('success' => true, 'error' => 'details of error') */ @@ -86,21 +126,16 @@ class MySQLDatabaseConfigurationHelper implements DatabaseConfigurationHelper { ); } - /** - * Ensure a database connection is possible using credentials provided. - * @param array $databaseConfig Associative array of db configuration, e.g. "server", "username" etc - * @return array Result - e.g. array('success' => true, 'error' => 'details of error') - */ public function requireDatabaseConnection($databaseConfig) { - $success = false; - $error = ''; - $conn = new MySQLi($databaseConfig['server'], $databaseConfig['username'], $databaseConfig['password']); - if($conn) { - $success = true; - } else { + $conn = $this->createConnection($databaseConfig, $error); + $success = !empty($conn); + + // Check database name only uses valid characters + if($success && !$this->checkValidDatabaseName($databaseConfig['database'])) { $success = false; - $error = ($conn) ? $conn->connect_error : ''; + $error = 'Invalid characters in database name.'; } + return array( 'success' => $success, 'error' => $error @@ -108,68 +143,89 @@ class MySQLDatabaseConfigurationHelper implements DatabaseConfigurationHelper { } /** - * Ensure that the database connection is able to use an existing database, - * or be able to create one if it doesn't exist. + * Determines if a given database name is a valid Silverstripe name. * - * @param array $databaseConfig Associative array of db configuration, e.g. "server", "username" etc - * @return array Result - e.g. array('success' => true, 'alreadyExists' => 'true') + * @param string $database Candidate database name + * @return boolean */ + public function checkValidDatabaseName($database) { + + // Reject filename unsafe characters (cross platform) + if(preg_match('/[\\\\\/\?%\*\:\|"\<\>\.]+/', $database)) return false; + + // Restricted to characters in the ASCII and Extended ASCII range + // @see http://dev.mysql.com/doc/refman/5.0/en/identifiers.html + return preg_match('/^[\x{0001}-\x{FFFF}]+$/u', $database); + } + + /** + * Checks if a specified grant proves that the current user has the specified + * permission on the specified database + * + * @param string $database Database name + * @param string $permission Permission to check for + * @param string $grant MySQL syntax grant to check within + * @return boolean + */ + public function checkDatabasePermissionGrant($database, $permission, $grant) { + // Filter out invalid database names + if(!$this->checkValidDatabaseName($database)) return false; + + // Escape all valid database patterns (permission must exist on all tables) + $dbPattern = sprintf( + '((%s)|(%s)|(%s))', + preg_quote("\"$database\".*"), + preg_quote('"%".*'), + preg_quote('*.*') + ); + $expression = '/GRANT[ ,\w]+((ALL PRIVILEGES)|('.$permission.'(?! ((VIEW)|(ROUTINE)))))[ ,\w]+ON '. + $dbPattern.'/i'; + return preg_match($expression, $grant); + } + + /** + * Checks if the current user has the specified permission on the specified database + * + * @param mixed $conn Connection object + * @param string $database Database name + * @param string $permission Permission to check + * @return boolean + */ + public function checkDatabasePermission($conn, $database, $permission) { + $grants = $this->column($conn->query("SHOW GRANTS FOR CURRENT_USER")); + foreach($grants as $grant) { + if($this->checkDatabasePermissionGrant($database, $permission, $grant)) { + return true; + } + } + return false; + } + public function requireDatabaseOrCreatePermissions($databaseConfig) { $success = false; $alreadyExists = false; - $conn = new MySQLi($databaseConfig['server'], $databaseConfig['username'], $databaseConfig['password']); - if($conn && $conn->select_db($databaseConfig['database'])) { - $success = true; - $alreadyExists = true; - } else { - if($conn && $conn->query('CREATE DATABASE testing123')) { - $conn->query('DROP DATABASE testing123'); + $conn = $this->createConnection($databaseConfig, $error); + if($conn) { + $list = $this->column($conn->query("SHOW DATABASES")); + if(in_array($databaseConfig['database'], $list)) { $success = true; + $alreadyExists = true; + } else{ + // If no database exists then check DDL permissions $alreadyExists = false; + $success = $this->checkDatabasePermission($conn, $databaseConfig['database'], 'CREATE'); } } + return array( 'success' => $success, 'alreadyExists' => $alreadyExists ); } - /** - * Ensure we have permissions to alter tables. - * - * @param array $databaseConfig Associative array of db configuration, e.g. "server", "username" etc - * @return array Result - e.g. array('okay' => true, 'applies' => true), where applies is whether - * the test is relevant for the database - */ public function requireDatabaseAlterPermissions($databaseConfig) { - $success = false; - $conn = new MySQLi($databaseConfig['server'], $databaseConfig['username'], $databaseConfig['password']); - if($conn) { - if ($res = $conn->query('SHOW GRANTS')) { - // Annoyingly, MySQL 'escapes' the database, so we need to do it too. - $db = str_replace(array('%', '_', '`'), array('\%', '\_', '``'), $databaseConfig['database']); - while ($row = $res->fetch_array()) { - if (preg_match('/^GRANT (.+) ON (.+) TO/', $row[0], $matches)) { - // Need to change to an array of permissions, because ALTER is contained in ALTER ROUTINES. - $permission = array_map('trim', explode(',', $matches[1])); - $on_database = $matches[2]; - // The use of both ` and " is because of ANSI mode. - if (in_array('ALL PRIVILEGES', $permission) and ( - ($on_database == '*.*') or ($on_database == '`' . $db . '`.*') - or ($on_database == '"' . $db . '".*'))) { - $success = true; - break; - } - if (in_array('ALTER', $permission) and ( - ($on_database == '*.*') or ($on_database == '`' . $db . '`.*') - or ($on_database == '"' . $db . '".*'))) { - $success = true; - break; - } - } - } - } - } + $conn = $this->createConnection($databaseConfig, $error); + $success = $this->checkDatabasePermission($conn, $databaseConfig['database'], 'ALTER'); return array( 'success' => $success, 'applies' => true diff --git a/dev/install/config-form.html b/dev/install/config-form.html index dd36e1543..a98a35a95 100644 --- a/dev/install/config-form.html +++ b/dev/install/config-form.html @@ -133,13 +133,12 @@ if(isset($details['fields'])) foreach($details['fields'] as $fieldName => $fieldSpec) { $fieldTitle = $fieldSpec['title']; $fieldType = ($fieldName == 'password') ? 'password' : 'text'; - // values $defaultValue = (isset($fieldSpec['default'])) ? $fieldSpec['default'] : null; if($usingEnv && isset($fieldSpec['envVar']) && defined($fieldSpec['envVar'])) { $value = constant($fieldSpec['envVar']); } else { - $value = (isset($databaseConfig[$fieldName])) ? $databaseConfig[$fieldName] : $defaultValue; + $value = (isset($databaseConfig[$fieldName]) && $databaseConfig['type'] == $class) ? $databaseConfig[$fieldName] : $defaultValue; } // attributes diff --git a/dev/install/install.php5 b/dev/install/install.php5 index 87eaddc84..f827be986 100755 --- a/dev/install/install.php5 +++ b/dev/install/install.php5 @@ -165,8 +165,11 @@ foreach(DatabaseAdapterRegistry::get_adapters() as $class => $details) { // Load database config if(isset($_REQUEST['db'])) { - if(isset($_REQUEST['db']['type'])) $type = $_REQUEST['db']['type']; - else $type = $_REQUEST['db']['type'] = defined('SS_DATABASE_CLASS') ? SS_DATABASE_CLASS : 'MySQLDatabase'; + if(isset($_REQUEST['db']['type'])) { + $type = $_REQUEST['db']['type']; + } else { + $type = $_REQUEST['db']['type'] = defined('SS_DATABASE_CLASS') ? SS_DATABASE_CLASS : 'MySQLDatabase'; + } // Disabled inputs don't submit anything - we need to use the environment (except the database name) if($usingEnv) { @@ -301,9 +304,13 @@ class InstallRequirements { * Check the database configuration. These are done one after another * starting with checking the database function exists in PHP, and * continuing onto more difficult checks like database permissions. + * + * @param array $databaseConfig The list of database parameters + * @return boolean Validity of database configuration details */ - function checkDatabase($databaseConfig) { - if($this->requireDatabaseFunctions( + public function checkDatabase($databaseConfig) { + // Check if support is available + if(!$this->requireDatabaseFunctions( $databaseConfig, array( "Database Configuration", @@ -311,57 +318,66 @@ class InstallRequirements { "Database support in PHP", $this->getDatabaseTypeNice($databaseConfig['type']) ) - )) { - if($this->requireDatabaseServer( - $databaseConfig, - array( - "Database Configuration", - "Database server", - $databaseConfig['type'] == 'SQLiteDatabase' ? "I couldn't write to path '$databaseConfig[path]'" : "I couldn't find a database server on '$databaseConfig[server]'", - $databaseConfig['type'] == 'SQLiteDatabase' ? $databaseConfig['path'] : $databaseConfig['server'] - ) - )) { - if($this->requireDatabaseConnection( - $databaseConfig, - array( - "Database Configuration", - "Database access credentials", - "That username/password doesn't work" - ) - )) { - if($this->requireDatabaseVersion( - $databaseConfig, - array( - "Database Configuration", - "Database server version requirement", - '', - 'Version ' . $this->getDatabaseConfigurationHelper($databaseConfig['type'])->getDatabaseVersion($databaseConfig) - ) - )) { - if($this->requireDatabaseOrCreatePermissions( - $databaseConfig, - array( - "Database Configuration", - "Can I access/create the database", - "I can't create new databases and the database '$databaseConfig[database]' doesn't exist" - ) - )) { - $this->requireDatabaseAlterPermissions( - $databaseConfig, - array( - "Database Configuration", - "Can I ALTER tables", - "I don't have permission to ALTER tables" - ) - ); - } - } - } - } - } + )) return false; + + // Check if the server is available + $usePath = !empty($databaseConfig['path']) && empty($databaseConfig['server']); + if(!$this->requireDatabaseServer( + $databaseConfig, + array( + "Database Configuration", + "Database server", + $usePath ? "I couldn't write to path '$databaseConfig[path]'" : "I couldn't find a database server on '$databaseConfig[server]'", + $usePath ? $databaseConfig['path'] : $databaseConfig['server'] + ) + )) return false; + + // Check if the connection credentials allow access to the server / database + if(!$this->requireDatabaseConnection( + $databaseConfig, + array( + "Database Configuration", + "Database access credentials", + "That username/password doesn't work" + ) + )) return false; + + // Check the necessary server version is available + if(!$this->requireDatabaseVersion( + $databaseConfig, + array( + "Database Configuration", + "Database server version requirement", + '', + 'Version ' . $this->getDatabaseConfigurationHelper($databaseConfig['type'])->getDatabaseVersion($databaseConfig) + ) + )) return false; + + // Check that database creation permissions are available + if(!$this->requireDatabaseOrCreatePermissions( + $databaseConfig, + array( + "Database Configuration", + "Can I access/create the database", + "I can't create new databases and the database '$databaseConfig[database]' doesn't exist" + ) + )) return false; + + // Check alter permission (necessary to create tables etc) + if(!$this->requireDatabaseAlterPermissions( + $databaseConfig, + array( + "Database Configuration", + "Can I ALTER tables", + "I don't have permission to ALTER tables" + ) + )) return false; + + // Success! + return true; } - function checkAdminConfig($adminConfig) { + public function checkAdminConfig($adminConfig) { if(!$adminConfig['username']) { $this->error(array('', 'Please enter a username!')); } @@ -374,14 +390,14 @@ class InstallRequirements { * Check if the web server is IIS and version greater than the given version. * @return boolean */ - function isIIS($fromVersion = 7) { + public function isIIS($fromVersion = 7) { if(strpos($this->findWebserver(), 'IIS/') === false) { return false; } return substr(strstr($this->findWebserver(), '/'), -3, 1) >= $fromVersion; } - function isApache() { + public function isApache() { if(strpos($this->findWebserver(), 'Apache') !== false) { return true; } else { @@ -393,7 +409,7 @@ class InstallRequirements { * Find the webserver software running on the PHP host. * @return string|boolean Server software or boolean FALSE */ - function findWebserver() { + public function findWebserver() { // Try finding from SERVER_SIGNATURE or SERVER_SOFTWARE if(!empty($_SERVER['SERVER_SIGNATURE'])) { $webserver = $_SERVER['SERVER_SIGNATURE']; @@ -409,7 +425,7 @@ class InstallRequirements { /** * Check everything except the database */ - function check() { + public function check() { $this->errors = null; $isApache = $this->isApache(); $isIIS = $this->isIIS(); @@ -657,7 +673,7 @@ class InstallRequirements { return $this->errors; } - function suggestPHPSetting($settingName, $settingValues, $testDetails) { + public function suggestPHPSetting($settingName, $settingValues, $testDetails) { $this->testing($testDetails); // special case for display_errors, check the original value before @@ -675,7 +691,7 @@ class InstallRequirements { } } - function requirePHPSetting($settingName, $settingValues, $testDetails) { + public function requirePHPSetting($settingName, $settingValues, $testDetails) { $this->testing($testDetails); $val = ini_get($settingName); @@ -685,7 +701,7 @@ class InstallRequirements { } } - function suggestClass($class, $testDetails) { + public function suggestClass($class, $testDetails) { $this->testing($testDetails); if(!class_exists($class)) { @@ -693,7 +709,7 @@ class InstallRequirements { } } - function suggestFunction($class, $testDetails) { + public function suggestFunction($class, $testDetails) { $this->testing($testDetails); if(!function_exists($class)) { @@ -701,7 +717,7 @@ class InstallRequirements { } } - function requireDateTimezone($testDetails) { + public function requireDateTimezone($testDetails) { $this->testing($testDetails); $result = ini_get('date.timezone') && in_array(ini_get('date.timezone'), timezone_identifiers_list()); @@ -710,7 +726,7 @@ class InstallRequirements { } } - function requireMemory($min, $recommended, $testDetails) { + public function requireMemory($min, $recommended, $testDetails) { $_SESSION['forcemem'] = false; $mem = $this->getPHPMemory(); @@ -735,7 +751,7 @@ class InstallRequirements { } } - function getPHPMemory() { + public function getPHPMemory() { $memString = ini_get("memory_limit"); switch(strtolower(substr($memString, -1))) { @@ -753,7 +769,7 @@ class InstallRequirements { } } - function listErrors() { + public function listErrors() { if($this->errors) { echo "

The following problems are preventing me from installing SilverStripe CMS:

\n\n"; foreach($this->errors as $error) { @@ -762,7 +778,7 @@ class InstallRequirements { } } - function showTable($section = null) { + public function showTable($section = null) { if($section) { $tests = $this->tests[$section]; $id = strtolower(str_replace(' ', '_', $section)); @@ -816,7 +832,7 @@ class InstallRequirements { } } - function requireFunction($funcName, $testDetails) { + public function requireFunction($funcName, $testDetails) { $this->testing($testDetails); if(!function_exists($funcName)) { @@ -826,7 +842,7 @@ class InstallRequirements { } } - function requireClass($className, $testDetails) { + public function requireClass($className, $testDetails) { $this->testing($testDetails); if(!class_exists($className)) { $this->error($testDetails); @@ -838,7 +854,7 @@ class InstallRequirements { /** * Require that the given class doesn't exist */ - function requireNoClasses($classNames, $testDetails) { + public function requireNoClasses($classNames, $testDetails) { $this->testing($testDetails); $badClasses = array(); foreach($classNames as $className) { @@ -852,7 +868,7 @@ class InstallRequirements { } } - function checkApacheVersion($testDetails) { + public function checkApacheVersion($testDetails) { $this->testing($testDetails); $is1pointx = preg_match('#Apache[/ ]1\.#', $testDetails[3]); @@ -863,7 +879,7 @@ class InstallRequirements { return true; } - function requirePHPVersion($recommendedVersion, $requiredVersion, $testDetails) { + public function requirePHPVersion($recommendedVersion, $requiredVersion, $testDetails) { $this->testing($testDetails); $installedVersion = phpversion(); @@ -891,7 +907,7 @@ class InstallRequirements { /** * Check that a module exists */ - function checkModuleExists($dirname) { + public function checkModuleExists($dirname) { $path = $this->getBaseDir() . $dirname; return file_exists($path) && ($dirname == 'mysite' || file_exists($path . '/_config.php')); } @@ -900,7 +916,7 @@ class InstallRequirements { * The same as {@link requireFile()} but does additional checks * to ensure the module directory is intact. */ - function requireModule($dirname, $testDetails) { + public function requireModule($dirname, $testDetails) { $this->testing($testDetails); $path = $this->getBaseDir() . $dirname; if(!file_exists($path)) { @@ -913,7 +929,7 @@ class InstallRequirements { } } - function requireFile($filename, $testDetails) { + public function requireFile($filename, $testDetails) { $this->testing($testDetails); $filename = $this->getBaseDir() . $filename; if(!file_exists($filename)) { @@ -922,7 +938,7 @@ class InstallRequirements { } } - function requireWriteable($filename, $testDetails, $absolute = false) { + public function requireWriteable($filename, $testDetails, $absolute = false) { $this->testing($testDetails); if($absolute) { @@ -974,7 +990,7 @@ class InstallRequirements { } } - function requireTempFolder($testDetails) { + public function requireTempFolder($testDetails) { $this->testing($testDetails); try { @@ -991,7 +1007,7 @@ class InstallRequirements { } } - function requireApacheModule($moduleName, $testDetails) { + public function requireApacheModule($moduleName, $testDetails) { $this->testing($testDetails); if(!in_array($moduleName, apache_get_modules())) { $this->error($testDetails); @@ -1001,7 +1017,7 @@ class InstallRequirements { } } - function testApacheRewriteExists($moduleName = 'mod_rewrite') { + public function testApacheRewriteExists($moduleName = 'mod_rewrite') { if(function_exists('apache_get_modules') && in_array($moduleName, apache_get_modules())) { return true; } elseif(isset($_SERVER['HTTP_MOD_REWRITE']) && $_SERVER['HTTP_MOD_REWRITE'] == 'On') { @@ -1011,7 +1027,7 @@ class InstallRequirements { } } - function testIISRewriteModuleExists($moduleName = 'IIS_UrlRewriteModule') { + public function testIISRewriteModuleExists($moduleName = 'IIS_UrlRewriteModule') { if(isset($_SERVER[$moduleName]) && $_SERVER[$moduleName]) { return true; } else { @@ -1019,7 +1035,7 @@ class InstallRequirements { } } - function requireApacheRewriteModule($moduleName, $testDetails) { + public function requireApacheRewriteModule($moduleName, $testDetails) { $this->testing($testDetails); if($this->testApacheRewriteExists()) { return true; @@ -1033,11 +1049,11 @@ class InstallRequirements { * Determines if the web server has any rewriting capability. * @return boolean */ - function hasRewritingCapability() { + public function hasRewritingCapability() { return ($this->testApacheRewriteExists() || $this->testIISRewriteModuleExists()); } - function requireIISRewriteModule($moduleName, $testDetails) { + public function requireIISRewriteModule($moduleName, $testDetails) { $this->testing($testDetails); if($this->testIISRewriteModuleExists()) { return true; @@ -1047,7 +1063,7 @@ class InstallRequirements { } } - function getDatabaseTypeNice($databaseClass) { + public function getDatabaseTypeNice($databaseClass) { return substr($databaseClass, 0, -8); } @@ -1055,7 +1071,7 @@ class InstallRequirements { * Get an instance of a helper class for the specific database. * @param string $databaseClass e.g. MySQLDatabase or MSSQLDatabase */ - function getDatabaseConfigurationHelper($databaseClass) { + public function getDatabaseConfigurationHelper($databaseClass) { $adapters = DatabaseAdapterRegistry::get_adapters(); if(isset($adapters[$databaseClass])) { $helperPath = $adapters[$databaseClass]['helperPath']; @@ -1064,7 +1080,7 @@ class InstallRequirements { return (class_exists($class)) ? new $class() : false; } - function requireDatabaseFunctions($databaseConfig, $testDetails) { + public function requireDatabaseFunctions($databaseConfig, $testDetails) { $this->testing($testDetails); $helper = $this->getDatabaseConfigurationHelper($databaseConfig['type']); if (!$helper) { @@ -1080,7 +1096,7 @@ class InstallRequirements { } } - function requireDatabaseConnection($databaseConfig, $testDetails) { + public function requireDatabaseConnection($databaseConfig, $testDetails) { $this->testing($testDetails); $helper = $this->getDatabaseConfigurationHelper($databaseConfig['type']); $result = $helper->requireDatabaseConnection($databaseConfig); @@ -1093,7 +1109,7 @@ class InstallRequirements { } } - function requireDatabaseVersion($databaseConfig, $testDetails) { + public function requireDatabaseVersion($databaseConfig, $testDetails) { $this->testing($testDetails); $helper = $this->getDatabaseConfigurationHelper($databaseConfig['type']); if(method_exists($helper, 'requireDatabaseVersion')) { @@ -1110,7 +1126,7 @@ class InstallRequirements { return true; } - function requireDatabaseServer($databaseConfig, $testDetails) { + public function requireDatabaseServer($databaseConfig, $testDetails) { $this->testing($testDetails); $helper = $this->getDatabaseConfigurationHelper($databaseConfig['type']); $result = $helper->requireDatabaseServer($databaseConfig); @@ -1123,7 +1139,7 @@ class InstallRequirements { } } - function requireDatabaseOrCreatePermissions($databaseConfig, $testDetails) { + public function requireDatabaseOrCreatePermissions($databaseConfig, $testDetails) { $this->testing($testDetails); $helper = $this->getDatabaseConfigurationHelper($databaseConfig['type']); $result = $helper->requireDatabaseOrCreatePermissions($databaseConfig); @@ -1144,7 +1160,7 @@ class InstallRequirements { } } - function requireDatabaseAlterPermissions($databaseConfig, $testDetails) { + public function requireDatabaseAlterPermissions($databaseConfig, $testDetails) { $this->testing($testDetails); $helper = $this->getDatabaseConfigurationHelper($databaseConfig['type']); $result = $helper->requireDatabaseAlterPermissions($databaseConfig); @@ -1158,7 +1174,7 @@ class InstallRequirements { } } - function requireServerVariables($varNames, $testDetails) { + public function requireServerVariables($varNames, $testDetails) { $this->testing($testDetails); $missing = array(); @@ -1177,7 +1193,7 @@ class InstallRequirements { } - function requirePostSupport($testDetails) { + public function requirePostSupport($testDetails) { $this->testing($testDetails); if(!isset($_POST)) { @@ -1189,7 +1205,7 @@ class InstallRequirements { return true; } - function isRunningWebServer($testDetails) { + public function isRunningWebServer($testDetails) { $this->testing($testDetails); if($testDetails[3]) { return true; @@ -1202,14 +1218,14 @@ class InstallRequirements { // Must be PHP4 compatible var $baseDir; - function getBaseDir() { + public function getBaseDir() { // Cache the value so that when the installer mucks with SCRIPT_FILENAME half way through, this method // still returns the correct value. if(!$this->baseDir) $this->baseDir = realpath(dirname($_SERVER['SCRIPT_FILENAME'])) . DIRECTORY_SEPARATOR; return $this->baseDir; } - function testing($testDetails) { + public function testing($testDetails) { if(!$testDetails) return; $section = $testDetails[0]; @@ -1221,7 +1237,7 @@ class InstallRequirements { $this->tests[$section][$test] = array("good", $message); } - function error($testDetails) { + public function error($testDetails) { $section = $testDetails[0]; $test = $testDetails[1]; @@ -1229,7 +1245,7 @@ class InstallRequirements { $this->errors[] = $testDetails; } - function warning($testDetails) { + public function warning($testDetails) { $section = $testDetails[0]; $test = $testDetails[1]; @@ -1237,23 +1253,23 @@ class InstallRequirements { $this->warnings[] = $testDetails; } - function hasErrors() { + public function hasErrors() { return sizeof($this->errors); } - function hasWarnings() { + public function hasWarnings() { return sizeof($this->warnings); } } class Installer extends InstallRequirements { - function __construct() { + public function __construct() { // Cache the baseDir value $this->getBaseDir(); } - function install($config) { + public function install($config) { ?> @@ -1307,7 +1323,9 @@ class Installer extends InstallRequirements { $databaseVersion = $config['db']['type']; $helper = $this->getDatabaseConfigurationHelper($dbType); if($helper && method_exists($helper, 'getDatabaseVersion')) { - $databaseVersion = urlencode($dbType . ': ' . $helper->getDatabaseVersion($config['db'][$dbType])); + $versionConfig = $config['db'][$dbType]; + $versionConfig['type'] = $dbType; + $databaseVersion = urlencode($dbType . ': ' . $helper->getDatabaseVersion($versionConfig)); } $url = "http://ss2stat.silverstripe.com/Installation/add?SilverStripe=$silverstripe_version&PHP=$phpVersion&Database=$databaseVersion&WebServer=$encWebserver"; @@ -1343,7 +1361,6 @@ class Installer extends InstallRequirements { // Write the config file global $usingEnv; if($usingEnv) { - $this->statusMessage("Setting up 'mysite/_config.php' for use with _ss_environment.php..."); $this->writeToFile("mysite/_config.php", <<statusMessage("Setting up 'mysite/_config.php'..."); - $escapedPassword = addslashes($dbConfig['password']); + // Create databaseConfig + $lines = array( + $lines[] = "\t'type' => '$type'" + ); + foreach($dbConfig as $key => $value) { + $lines[] = "\t'{$key}' => '$value'"; + } + $databaseConfigContent = implode(",\n", $lines); $this->writeToFile("mysite/_config.php", << '{$type}', - "server" => '{$dbConfig['server']}', - "username" => '{$dbConfig['username']}', - "password" => '{$escapedPassword}', - "database" => '{$dbConfig['database']}', - "path" => '{$dbConfig['path']}', +{$databaseConfigContent} ); // Set the site locale i18n::set_locale('$locale'); + PHP ); } + $this->statusMessage("Setting up 'mysite/_config/config.yml'"); + $this->writeToFile("mysite/_config/config.yml", <<checkModuleExists('cms')) { $this->writeToFile("mysite/code/RootURLController.php", <<Your site is now set up. Start adding controllers to mysite to get started."; } @@ -1442,7 +1479,7 @@ PHP $adminMember = Security::findAnAdministrator(); $adminMember->Email = $config['admin']['username']; $adminMember->Password = $config['admin']['password']; - $adminMember->PasswordEncryption = Security::get_password_encryption_algorithm(); + $adminMember->PasswordEncryption = Security::config()->encryption_algorithm; try { $this->statusMessage('Creating default CMS admin account...'); @@ -1496,7 +1533,7 @@ HTML; return $this->errors; } - function writeToFile($filename, $content) { + public function writeToFile($filename, $content) { $base = $this->getBaseDir(); $this->statusMessage("Setting up $base$filename"); @@ -1507,7 +1544,7 @@ HTML; } } - function createHtaccess() { + public function createHtaccess() { $start = "### SILVERSTRIPE START ###\n"; $end = "\n### SILVERSTRIPE END ###"; @@ -1548,7 +1585,7 @@ ErrorDocument 500 /assets/error-500.html RewriteRule ^vendor(/|$) - [F,L,NC] RewriteRule silverstripe-cache(/|$) - [F,L,NC] RewriteRule composer\.(json|lock) - [F,L,NC] - + RewriteCond %{REQUEST_URI} ^(.*)$ RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_URI} !\.php$ @@ -1576,7 +1613,7 @@ TEXT; * Writes basic configuration to the web.config for IIS * so that rewriting capability can be use. */ - function createWebConfig() { + public function createWebConfig() { $modulePath = FRAMEWORK_NAME; $content = << @@ -1614,7 +1651,7 @@ TEXT; $this->writeToFile('web.config', $content); } - function checkRewrite() { + public function checkRewrite() { require_once 'core/startup/ParameterConfirmationToken.php'; $token = new ParameterConfirmationToken('flush'); $params = http_build_query($token->params()); @@ -1659,7 +1696,7 @@ TEXT; HTML; } - function var_export_array_nokeys($array) { + public function var_export_array_nokeys($array) { $retval = "array(\n"; foreach($array as $item) { $retval .= "\t'"; @@ -1674,7 +1711,7 @@ HTML; * Show an installation status message. * The output differs depending on whether this is CLI or web based */ - function statusMessage($msg) { + public function statusMessage($msg) { echo "
  • $msg
  • \n"; flush(); } diff --git a/docs/en/changelogs/3.2.0.md b/docs/en/changelogs/3.2.0.md index 5c4819fa9..ed2d53839 100644 --- a/docs/en/changelogs/3.2.0.md +++ b/docs/en/changelogs/3.2.0.md @@ -16,6 +16,8 @@ ## Changelog +### CMS + ### DataObject::validate() method visibility changed to public The visibility of `DataObject::validate()` has been changed from `protected` to `public`. @@ -83,3 +85,424 @@ including "MMM" or "MMMM", consider deleting those formats to fall back to the global (and more stable) default. ### Bugfixes + * Migration of code to use new parameterised framework + +### Framework + + * Implementation of a parameterised query framework eliminating the need to manually escape variables for + use in SQL queries. This has been integrated into nearly every level of the database ORM. + * Refactor of database connectivity classes into separate components linked together through dependency injection + * Refactor of `SQLQuery` into separate objects for each query type: `SQLSelect`, `SQLDelete`, `SQLUpdate` and `SQLInsert` + * Rename of API methods to conform to coding conventions + * PDO is now a standard connector, and is available for all database interfaces + * Additional database and query generation tools + +## Bugfixes + + * Reduced database regeneration chances on subsequent rebuilds after the initial dev/build + * Elimination of various SQL injection vulnerability points + * `DataObject::writeComponents()` now called correctly during `DataObject::write()` + * Fixed missing theme declaration in installer + * Fixed incorrect use of non-existing exception classes (e.g. `HTTPResponse_exception`) + * `GridState` fixed to distinguish between check for missing values, and creation of + nested state values, in order to prevent non-empty values being returned for + missing keys. This was breaking `DataObject::get_by_id` by passing in an object + for the ID. + * Fixed order of `File` fulltext searchable fields to use same order as actual fields. + This is required to prevent unnecessary rebuild of MS SQL databases when fulltext + searching is enabled. + +## Upgrading + +### Update code that uses SQLQuery + +SQLQuery is still implemented, but now extends the new SQLSelect class and has some methods +deprecated. Previously this class was used for both selecting and deleting, but these +have been superceded by the specialised SQLSelect and SQLDelete classes. Additionally, +3.2 now provides SQLUpdate and SQLInsert to generate parameterised query friendly +data updates. + +SQLSelect, SQLDelete and SQLUpdate all inherit from SQLConditionalExpression, which +implements toSelect, toDelete, and toUpdate to generate basic transformations +between query types. + +In the past SQLQuery->setDelete(true) would be used to turn a select into a delete, +although now a new SQLDelete object should be created from a separate SQLSelect. + +Before: + + :::php + setFrom('"SiteTree"'); + $query->setWhere('"SiteTree"."ShowInMenus" = 0'); + $query->setDelete(true); + $query->execute(); + +After: + + :::php + setFrom('"SiteTree"') + ->setWhere(array('"SiteTree"."ShowInMenus"' => 0)); + $query->execute(); + +Alternatively: + + :::php + setFrom('"SiteTree"') + ->setWhere(array('"SiteTree"."ShowInMenus"' => 0)) + ->toDelete(); + $query->execute(); + +Also, take care for any code or functions which expect an object of type `SQLQuery`, as +these references should be replaced with `SQLSelect`. Legacy code which generates +`SQLQuery` can still communicate with new code that expects `SQLSelect` as it is a +subclass of `SQLSelect`, but the inverse is not true. + +### Update code that interacts with SQL strings to use parameters + +The Silverstripe ORM (object relation model) has moved from using escaped SQL strings +to query the database, to a combination of parameterised SQL expressions alongside +a related list of parameter values. As a result of this, it is necessary to assume +that any `SQLSelect` object (previously `SQLQuery`) may, and will usually, have +un-injected parameters. + +All database queries performed through `DataList`, `DataQuery` and `SQLQuery` will continue +to work, as will those through `DataObject::get()` (which returns a filterable `DataList`). +However, any conditional expression that includes values escaped with `Convert::raw2sql()` +should use the new standard syntax. This new querying standard method enforces a much +higher level of security than was previously available, and all code using manual +escaping should be upgraded. + +See [the security topic](/topics/security#parameterised-queries) for details on why this is necessary, or +[the databamodel topic](/topics/datamodel#raw-sql-options-for-advanced-users) for more information. + +As a result of this upgrade there are now very few cases where `Convert::raw2sql` needs to be used. + +Examples of areas where queries should be upgraded are below: + +1. #### Querying the database directly through DB, including non-SELECT queries + + Before: + + :::php + addWhere(array( + '"ParentID" = \''.intval($parentID).'\'', + '"ID" IN (SELECT "PageID" FROM "MyObject")' + )); + + $query->addWhere("\"Title\" LIKE '%".Convert::raw2sql($myText)."' OR \"Title\" LIKE '".Convert::raw2sql($myText)."%'"); + + After, substituting `SQLSelect` for the deprecated `SQLQuery`: + + Note: The inclusion of properly ANSI quoted symbols with the table name included, + as per best coding practices. + + :::php + $testURL)); + + $query->addWhere(array( + '"SiteTree"."ParentID"' => // Note that the " = ?" is optional for simple comparison + array( // Syntax for parameter casting for supporting databases + 'value' => $parentID, + 'type' => 'integer' + ), + '"SiteTree"."ID" IN (SELECT "MyObject"."PageID" FROM "MyObject")' // Raw SQL condition with no parameters + )); + + // Multiple parameters may be assigned for a single query (this should not be associative) + $query->addWhere(array( + '"SiteTree"."Title" LIKE %? OR "SiteTree"."Title" LIKE %?' => array($myText, $myText) + )); + +3. #### Querying the database through `DataList`, `DataQuery`, and `DataObject` + + Before: + + :::php + where('"Name" = \''.Convert::raw2sql($name).'\''); + $list = DataList::create('Banner')->where(array( + '"ParentID" IS NOT NULL', + '"Title" = \'' . Convert::raw2sql($title) . '\'' + ); + + After: + + :::php + $details)); + $things = MyObject::get()->where(array('"MyObject"."Name" = ?' => $name)); + $list = DataList::create('Banner')->where(array( + '"ParentID" IS NOT NULL', + '"Title" = ?', $title + ); + +4. #### Interaction with the `DataList::sql()`, `DataQuery::sql()` or `SQLSelect::sql()` methods + + The place where legacy code would almost certainly fail is any code that calls + `SQLQuery::sql`, `DataList::sql` or `DataQuery::sql`, as the api requires that user + code passes in an argument here to retrieve SQL parameters by value. + + User code that assumes parameterless queries will likely fail, and need to be + updated to handle this case properly. + + Before: + + :::php + setFrom('"SiteTree"') + ->setWhere(array("\"SiteTree\".\"Title\" LIKE '" . Convert::raw2sql($argument) . "'")); + + // Inspect elements of the query + $sql = $query->sql(); + $sql = preg_replace('/LIKE \'(.+)\'/', 'LIKE \'%${1}%\'', $sql); // Adds %% around the argument + + // Pass new query to database connector + DB::query($sql); + + After: + + :::php + setFrom('"SiteTree"') + ->setWhere(array('"SiteTree"."Title" LIKE ?' => $argument)); + + // Inspect elements of the query + $sql = $query->sql($parameters); + foreach($parameters as $key => $value) { + // Adds %% around arguments + $parameters[$key] = "%{$value}%"; + } + + // Pass new query to database connector + // Note that DB::query($sql) would fail, as it would contain ? with missing parameters + DB::prepared_query($sql, $parameters); + + Also note that the parameters may not be a single level array, as certain values + may be forced to be cast as a certain type (where supported by the current API). + + E.g. + + :::php + 0, 'type' => 'boolean') // May also contain other database API specific options + ) + DB::prepared_query('DELETE FROM "MyObject" WHERE ParentID = ? OR IsValid = ?', $parameters); + +5. #### Update implementations of augmentSQL + +Since this method now takes a `SQLSelect` as a first parameter, existing code referencing the deprecated `SQLQuery` +type will raise a PHP error. + +Furthermore, it's important to note that even though the signature of `SQLSelect::getWhere` is similar to the old +`SQLQuery::getWhere` the result will actually be an associative array of SQL fragments mapped to arrays of +parameters, and any transformation of these values will require parameters to be maintained. + +If your code doesn't modify the parameters then `SQLSelect::getWhereParameterised` can be used in order to return +these SQL statements as a simple array of strings. The resulting parameters are still maintained, but are +instead be returned by referenced through the first parameter to this method. + +E.g. + +Before: + + :::php + function augmentSQL(SQLQuery $query, DataQuery $dataQuery = null) { + $locale = Translatable::get_current_locale(); + if(!preg_match('/("|\'|`)Locale("|\'|`)/', implode(' ', $query->getWhere()))) { + $qry = sprintf('"Locale" = \'%s\'', Convert::raw2sql($locale)); + $query->addWhere($qry); + } + } + +After: + + :::php + function augmentSQL(SQLSelect $query, DataQuery $dataQuery = null) { + $locale = Translatable::get_current_locale(); + if(!preg_match('/("|\'|`)Locale("|\'|`)/', implode(' ', $query->getWhereParameterised($parameters)))) { + $query->addWhere(array( + '"Locale"' => $locale + )); + } + } + + +### Update code that interacts with the DB schema + +Updating database schema is now done by `updateSchema` with a callback, rather than relying +on user code to call `beginSchemaUpdate` and `endSchemaUpdate` around the call. + +Since the schema management object is separate from the database controller you +interact with it via `DB::get_schema` instead of `DB::get_conn` (previously named +`DB::getConn`) + +Before: + + :::php + beginSchemaUpdate(); + foreach($dataClasses as $dataClass) { + singleton($dataClass)->requireTable(); + } + $conn->endSchemaUpdate(); + +After: + + :::php + schemaUpdate(function() use($dataClasses){ + foreach($dataClasses as $dataClass) { + singleton($dataClass)->requireTable(); + } + }); + +Also should be noted is that many functions have been renamed to conform better with +coding conventions. E.g. `DB::requireTable` is now `DB::require_table` + +### Other + + * Helper function `DB::placeholders` can be used to generate a comma separated list of placeholders + useful for creating "WHERE ... IN (?,...)" SQL fragments + * Implemented Convert::symbol2sql to safely encode database and table names and identifiers. + E.g. `Convert::symbol2sql('table.column') => '"table"."column"';` + * `Convert::raw2sql` may now quote the escaped value, as well as safely escape it, according to the current + database adaptor's preference. + * `DB` class has been updated and many static methods have been renamed to conform to coding convention. + * Renamed API: + - `affectedRows` -> `affected_rows` + - `checkAndRepairTable` -> `check_and_repair_table` + - `createDatabase` -> `create_database` + - `createField` -> `createField` + - `createTable` -> `createTable` + - `dontRequireField` -> `dont_require_field` + - `dontRequireTable` -> `dont_require_table` + - `fieldList` -> `field_list` + - `getConn` -> `get_conn` + - `getGeneratedID` -> `get_generated_id` + - `isActive` -> `is_active` + - `requireField` -> `require_field` + - `requireIndex` -> `require_index` + - `requireTable` -> `require_table` + - `setConn` -> `set_conn` + - `tableList` -> `table_list` + * Deprecated API: + - `getConnect` (Was placeholder for PDO connection string building code, but is made + redundant after the PDOConnector being fully abstracted) + * New API: + - `build_sql` - Hook into new SQL generation code + - `get_connector` (Nothing to do with getConnect) + - `get_schema` + - `placeholders` + - `prepared_query` + * `SS_Database` class has been updated and many functions have been deprecated, or refactored into + the various other database classes. Most of the database management classes remain in the database + controller, due to individual databases (changing, creating of, etc) varying quite a lot from + API to API, but schema updates within a database itself is managed by an attached DBSchemaManager + * Refactored into DBSchemaManager: + - `createTable` + - `alterTable` + - `renameTable` + - `createField` + - `renameField` + - `fieldList` + - `tableList` + - `hasTable` + - `enumValuesForField` + - `beginSchemaUpdate` and `endSchemaUpdate` -> Use `schemaUpdate` with a callback + - `cancelSchemaUpdate` + - `isSchemaUpdating` + - `doesSchemaNeedUpdating` + - `transCreateTable` + - `transAlterTable` + - `transCreateField` + - `transCreateField` + - `transCreateIndex` + - `transAlterField` + - `transAlterIndex` + - `requireTable` + - `dontRequireTable` + - `requireIndex` + - `hasField` + - `requireField` + - `dontRequireField` + * Refactored into DBQueryBuilder + - `sqlQueryToString` + * Deprecated: + - `getConnect` - Was intended for use with PDO, but was never implemented, and is now + redundant, now that there is a stand-alone `PDOConnector` + - `prepStringForDB` - Use `quoteString` instead + - `dropDatabase` - Use `dropSelectedDatabase` + - `createDatabase` - Use `selectDatabase` with the second parameter set to true instead + - `allDatabaseNames` - Use `databaseList` instead + - `currentDatabase` - Use `getSelectedDatabase` instead + - `addslashes` - Use `escapeString` instead + * LogErrorEmailFormatter now better displays SQL queries in errors by respecting line breaks + * Installer has been majorly upgraded to handle the new database configuration options + and additional PDO functionality. + * Created SS_DatabaseException to emit database errors. Query information such as SQL + and any relevant parameters may be used by error handling user code that catches + this exception. + * The SQLConditionGroup interface has been created to represent dynamically + evaluated SQL conditions. This may be used to wrap a class that generates + a custom SQL clause(s) to be evaluated at the time of execution. + * DataObject constants CHANGE_NONE, CHANGE_STRICT, and CHANGE_VALUE have been created + to provide more verbosity to field modification detection. This replaces the use of + various magic numbers with the same meaning. + * create_table_options now uses constants as API specific filters rather than strings. + This is in order to promote better referencing of elements across the codebase. + See `FulltextSearchable->enable` for example. diff --git a/docs/en/changelogs/index.md b/docs/en/changelogs/index.md index de704568e..2cc8f7adc 100644 --- a/docs/en/changelogs/index.md +++ b/docs/en/changelogs/index.md @@ -9,6 +9,8 @@ For information on how to upgrade to newer versions consult the [upgrading](/ins ## Stable Releases + * [3.2.0](3.2.0) - Unreleased + * [3.1.5](3.1.5) - 13 May 2014 * [3.1.4](3.1.4) - 8 April 2014 * [3.1.0](3.1.0) - 1 October 2013 diff --git a/docs/en/misc/coding-conventions.md b/docs/en/misc/coding-conventions.md index 6e88827f2..adeb5d219 100644 --- a/docs/en/misc/coding-conventions.md +++ b/docs/en/misc/coding-conventions.md @@ -433,7 +433,15 @@ Put code into the classes in the following order (where applicable). ### SQL Format If you have to use raw SQL, make sure your code works across databases make sure you escape your queries like below, -with the column or table name escaped with double quotes and values with single quotes. +with the column or table name escaped with double quotes as below. + + :::php + MyClass::get()->where(array("\"Score\" > ?" => 50)); + +It is preferable to use parameterised queries whenever necessary to provide conditions +to a SQL query, where values placeholders are each replaced with a single unquoted question mark. +If it's absolutely necessary to use literal values in a query make sure that values +are single quoted. :::php MyClass::get()->where("\"Title\" = 'my title'"); diff --git a/docs/en/reference/dataextension.md b/docs/en/reference/dataextension.md index c47833ac1..e68fd4d8a 100644 --- a/docs/en/reference/dataextension.md +++ b/docs/en/reference/dataextension.md @@ -225,9 +225,8 @@ The other queries that you will want to customise are the selection queries, called by get & get_one. For example, the Versioned object has code to redirect every request to ClassName_live, if you are browsing the live site. -To do this, define the **augmentSQL(SQLQuery &$query)** method. Again, the -`$query` object is passed by reference and can be modified as needed by your -method. Instead of a manipulation array, we have a `[api:SQLQuery]` object. +To do this, define the **augmentSQL(SQLSelect $query)** method. Again, the $query object is passed by reference and can +be modified as needed by your method. Instead of a manipulation array, we have a `[api:SQLSelect]` object. ### Additional methods diff --git a/docs/en/reference/index.md b/docs/en/reference/index.md index 002241d7d..8eacdb6b4 100644 --- a/docs/en/reference/index.md +++ b/docs/en/reference/index.md @@ -25,7 +25,7 @@ Reference articles complement our auto-generated [API docs](http://api.silverstr * [Site Reports](site-reports): Tabular reports in a specialized CMS interface * [SiteConfig](siteconfig): Global configuration stored in the database * [SiteTree](sitetree): Base class for a "page" in the CMS -* [SQLQuery](sqlquery): Wrapper around a SQL query allowing modification before execution +* [SQLSelect](sqlquery): Wrapper around a SQL query allowing modification before execution * [StaticPublisher](staticpublisher): Export a page tree as static HTML for better performance and portability * [TableField](tablefield): Add and edit records with inline edits in this form field * [TableListField](tablelistfield): View and delete records in the CMS diff --git a/docs/en/reference/searchcontext.md b/docs/en/reference/searchcontext.md index 5d66224bc..6990c2b04 100644 --- a/docs/en/reference/searchcontext.md +++ b/docs/en/reference/searchcontext.md @@ -6,7 +6,7 @@ Manages searching of properties on one or more `[api:DataObject]` types, based o `[api:SearchContext]` is intentionally decoupled from any controller-logic, it just receives a set of search parameters and an object class it acts on. -The default output of a `[api:SearchContext]` is either a `[api:SQLQuery]` object for further refinement, or a +The default output of a `[api:SearchContext]` is either a `[api:SQLSelect]` object for further refinement, or a `[api:DataObject]` instance. In case you need multiple contexts, consider namespacing your request parameters by using `FieldList->namespace()` on @@ -86,7 +86,7 @@ method, we're building our own `getCustomSearchContext()` variant. ### Pagination For pagination records on multiple pages, you need to wrap the results in a -`PaginatedList` object. This object is also passed the generated `SQLQuery` +`PaginatedList` object. This object is also passed the generated `SQLSelect` in order to read page limit information. It is also passed the current `SS_HTTPRequest` object so it can read the current page from a GET var. diff --git a/docs/en/reference/sqlquery.md b/docs/en/reference/sqlquery.md index 423f77b81..9d00c4fcb 100644 --- a/docs/en/reference/sqlquery.md +++ b/docs/en/reference/sqlquery.md @@ -1,8 +1,8 @@ -# SQL Query +# SQL Select ## Introduction -An object representing a SQL query, which can be serialized into a SQL statement. +An object representing a SQL select query, which can be serialized into a SQL statement. It is easier to deal with object-wrappers than string-parsing a raw SQL-query. This object is used by the SilverStripe ORM internally. @@ -18,8 +18,8 @@ the following three statements are functionally equivalent: :::php // Through raw SQL $count = DB::query('SELECT COUNT(*) FROM "Member"')->value(); - // Through SQLQuery abstraction layer - $query = new SQLQuery(); + // Through SQLSelect abstraction layer + $query = new SQLSelect(); $count = $query->setFrom('Member')->setSelect('COUNT(*)')->value(); // Through the ORM $count = Member::get()->count(); @@ -38,51 +38,179 @@ but still maintain a connection to the ORM where possible.
    Please read our ["security" topic](/topics/security) to find out -how to sanitize user input before using it in SQL queries. +how to properly prepare user input and variables for use in queries
    ## Usage ### SELECT +Selection can be done by creating an instance of `SQLSelect`, which allows +management of all elements of a SQL SELECT query, including columns, joined tables, +conditional filters, grouping, limiting, and sorting. + +E.g. + :::php - $sqlQuery = new SQLQuery(); - $sqlQuery->setFrom('Player'); - $sqlQuery->selectField('FieldName', 'Name'); - $sqlQuery->selectField('YEAR("Birthday")', 'Birthyear'); - $sqlQuery->addLeftJoin('Team','"Player"."TeamID" = "Team"."ID"'); - $sqlQuery->addWhere('YEAR("Birthday") = 1982'); - // $sqlQuery->setOrderBy(...); - // $sqlQuery->setGroupBy(...); - // $sqlQuery->setHaving(...); - // $sqlQuery->setLimit(...); - // $sqlQuery->setDistinct(true); + setFrom('Player'); + $sqlSelect->selectField('FieldName', 'Name'); + $sqlSelect->selectField('YEAR("Birthday")', 'Birthyear'); + $sqlSelect->addLeftJoin('Team','"Player"."TeamID" = "Team"."ID"'); + $sqlSelect->addWhere(array('YEAR("Birthday") = ?' => 1982)); + // $sqlSelect->setOrderBy(...); + // $sqlSelect->setGroupBy(...); + // $sqlSelect->setHaving(...); + // $sqlSelect->setLimit(...); + // $sqlSelect->setDistinct(true); - // Get the raw SQL (optional) - $rawSQL = $sqlQuery->sql(); + // Get the raw SQL (optional) and parameters + $rawSQL = $sqlSelect->sql($parameters); // Execute and return a Query object - $result = $sqlQuery->execute(); + $result = $sqlSelect->execute(); // Iterate over results foreach($result as $row) { echo $row['BirthYear']; } -The result is an array lightly wrapped in a database-specific subclass of `[api:Query]`. +The result of `SQLSelect::execute()` is an array lightly wrapped in a database-specific subclass of `[api:SS_Query]`. This class implements the *Iterator*-interface, and provides convenience-methods for accessing the data. ### DELETE +Deletion can be done either by calling `DB::query`/`DB::prepared_query` directly, +by creating a `SQLDelete` object, or by transforming a `SQLSelect` into a `SQLDelete` +object instead. + +For example, creating a `SQLDelete` object + :::php - $sqlQuery->setDelete(true); + setFrom('"SiteTree"') + ->setWhere(array('"SiteTree"."ShowInMenus"' => 0)); + $query->execute(); + +Alternatively, turning an existing `SQLSelect` into a delete + + :::php + setFrom('"SiteTree"') + ->setWhere(array('"SiteTree"."ShowInMenus"' => 0)) + ->toDelete(); + $query->execute(); + +Directly querying the database + + :::php + value pairs, + but also supports SQL expressions as values if necessary. + * `setAssignments` - Replaces all existing assignments with the specified list + * `getAssignments` - Returns all currently given assignments, as an associative array + in the format `array('Column' => array('SQL' => array('parameters)))` + * `assign` - Singular form of addAssignments, but only assigns a single column value. + * `assignSQL` - Assigns a column the value of a specified SQL expression without parameters + `assignSQL('Column', 'SQL)` is shorthand for `assign('Column', array('SQL' => array()))` + +SQLUpdate also includes the following api methods: + + * `clear` - Clears all assignments + * `getTable` - Gets the table to update + * `setTable` - Sets the table to update. This should be ANSI quoted. + E.g. `$query->setTable('"SiteTree"');` + +SQLInsert also includes the following api methods: + * `clear` - Clears all rows + * `clearRow` - Clears all assignments on the current row + * `addRow` - Adds another row of assignments, and sets the current row to the new row + * `addRows` - Adds a number of arrays, each representing a list of assignment rows, + and sets the current row to the last one. + * `getColumns` - Gets the names of all distinct columns assigned + * `getInto` - Gets the table to insert into + * `setInto` - Sets the table to insert into. This should be ANSI quoted. + E.g. `$query->setInto('"SiteTree"');` + +E.g. :::php - DB::query('UPDATE "Player" SET "Status"=\'Active\''); + where(array('ID' => 3)); + + // assigning a list of items + $update->addAssignments(array( + '"Title"' => 'Our Products', + '"MenuTitle"' => 'Products' + )); + + // Assigning a single value + $update->assign('"MenuTitle"', 'Products'); + + // Assigning a value using parameterised expression + $title = 'Products'; + $update->assign('"MenuTitle"', array( + 'CASE WHEN LENGTH("MenuTitle") > LENGTH(?) THEN "MenuTitle" ELSE ? END' => + array($title, $title) + )); + + // Assigning a value using a pure SQL expression + $update->assignSQL('"Date"', 'NOW()'); + + // Perform the update + $update->execute(); + +In addition to assigning values, the SQLInsert object also supports multi-row +inserts. For database connectors and API that don't have multi-row insert support +these are translated internally as multiple single row inserts. + +For example, + + :::php + addRows(array( + array('"Title"' => 'Home', '"Content"' => '

    This is our home page

    '), + array('"Title"' => 'About Us', '"ClassName"' => 'AboutPage') + )); + + // Adjust an assignment on the last row + $insert->assign('"Content"', '

    This is about us

    '); + + // Add another row + $insert->addRow(array('"Title"' => 'Contact Us')); + + $columns = $insert->getColumns(); + // $columns will be array('"Title"', '"Content"', '"ClassName"'); + + $insert->execute(); ### Value Checks @@ -92,12 +220,12 @@ e.g. when you want a single column rather than a full-blown object representatio Example: Get the count from a relationship. :::php - $sqlQuery = new SQLQuery(); - $sqlQuery->setFrom('Player'); - $sqlQuery->addSelect('COUNT("Player"."ID")'); - $sqlQuery->addWhere('"Team"."ID" = 99'); - $sqlQuery->addLeftJoin('Team', '"Team"."ID" = "Player"."TeamID"'); - $count = $sqlQuery->execute()->value(); + $sqlSelect = new SQLSelect(); + $sqlSelect->setFrom('Player'); + $sqlSelect->addSelect('COUNT("Player"."ID")'); + $sqlSelect->addWhere(array('"Team"."ID"' => 99)); + $sqlSelect->addLeftJoin('Team', '"Team"."ID" = "Player"."TeamID"'); + $count = $sqlSelect->execute()->value(); Note that in the ORM, this call would be executed in an efficient manner as well: @@ -112,14 +240,14 @@ This can be useful for creating dropdowns. Example: Show player names with their birth year, but set their birth dates as values. :::php - $sqlQuery = new SQLQuery(); - $sqlQuery->setFrom('Player'); - $sqlQuery->setSelect('Birthdate'); - $sqlQuery->selectField('CONCAT("Name", ' - ', YEAR("Birthdate")', 'NameWithBirthyear'); - $map = $sqlQuery->execute()->map(); + $sqlSelect = new SQLSelect(); + $sqlSelect->setFrom('Player'); + $sqlSelect->setSelect('Birthdate'); + $sqlSelect->selectField('CONCAT("Name", ' - ', YEAR("Birthdate")', 'NameWithBirthyear'); + $map = $sqlSelect->execute()->map(); $field = new DropdownField('Birthdates', 'Birthdates', $map); -Note that going through SQLQuery is just necessary here +Note that going through SQLSelect is just necessary here because of the custom SQL value transformation (`YEAR()`). An alternative approach would be a custom getter in the object definition. diff --git a/docs/en/topics/datamodel.md b/docs/en/topics/datamodel.md index 081e1b114..0cea1a619 100755 --- a/docs/en/topics/datamodel.md +++ b/docs/en/topics/datamodel.md @@ -348,13 +348,134 @@ necessary. If the ORM doesn't do quite what you need it to, you may also consider extending the ORM with new data types or filter modifiers (that documentation still needs to be written) -#### Where clauses +
    +See [the security topic](/topics/security#parameterised-queries) for details on safe database querying and why parameterised queries +are so necessary here. +
    -You can specify a WHERE clause fragment (that will be combined with other -filters using AND) with the `where()` method: +#### SQL WHERE Predicates with Parameters + +If using `DataObject::get()` (which returns a `DataList` instance) you can specify a WHERE clause fragment +(that will be combined with other filters using AND) with the `where()` method, or `whereAny()` to add a list +of clauses combined with OR. + +Placeholders within a predicate are denoted by the question mark symbol, and should not be quoted. + +For example: :::php - $members = Member::get()->where("\"FirstName\" = 'Sam'") + $members = Member::get()->where(array('"FirstName" = ?' => 'Sam')); + +If using `SQLSelect` you should use `addWhere`, `setWhere`, `addWhereAny`, or `setWhereAny` to modify the query. + +Using the parameterised query syntax you can either provide a single variable as a parameter, an array of parameters +if the SQL has multiple value placeholders, or simply pass an indexed array of strings for literal SQL. + +Although parameters can be escaped and directly inserted into the SQL condition (See `Convert::raw2sql()'), +the parameterised syntax is the preferred method of declaring conditions on a query. + +Column names must still be double quoted, and for consistency and compatibility with other code, should also +be prefixed with the table name. + +E.g. + + :::php + where(array( + '"Table"."Column" = ?' => $column, + '"Table"."Name" = ?' => $value + )); + + // Shorthand for simple column comparison (as above), omitting the '?' + // These will each be expanded internally to '"Table"."Column" = ?' + $query = $query->where(array( + '"Table"."Column"' => $column, + '"Table"."Name"' => $value + )); + + // Multiple predicates, some with multiple parameters. + // The parameters should ideally not be an associative array. + $query = $query->where(array( + '"Table"."ColumnOne" = ? OR "Table"."ColumnTwo" != ?' => array(1, 4), + '"Table"."ID" != ?' => $value + )); + + // Multiple predicates, each with explicitly typed parameters. + // + // The purpose of this syntax is to provide not only parameter values, but + // to also instruct the database connector on how to treat this value + // internally (subject to the database API supporting this feature). + // + // SQLQuery distinguishes these from predicates with multiple parameters + // by checking for the 'value' key in any array parameter given + $query = $query->whereAny(array( + '"Table"."Column"' => array( + 'value' => $value, + 'type' => 'string' // or any php type + ), + '"Table"."HasValue"' => array( + 'value' => 0, + 'type' => 'boolean' + ) + )); + +#### Run-Time Evaluated Conditions with SQLConditionGroup + +Conditional expressions and groups may be encapsulated within a class (implementing +the SQLConditionGroup interface) and evaluated at the time of execution. + +This is useful for conditions which may be placed into a query before the details +of that condition are fully specified. + +E.g. + + :::php + field} < RAND()"; + } + } + + $query = SQLSelect::create() + ->setFrom('"MyObject"') + ->setWhere($condition = new RandomCondition()); + $condition->field = '"Score"'; + $items = $query->execute(); + +#### Direct SQL Predicate + +Conditions can be a literal piece of SQL which doesn't involve any parameters or values +at all, or can using safely SQL-encoded values, as it was originally. + +
    +In nearly every instance it's preferrable to use the parameterised syntax, especially dealing +with variable parameters, even if those values were not submitted by the user. +See [the security topic](/topics/security#parameterised-queries) for details. +
    + +For instance, the following are all valid ways of adding SQL conditions directly to a query + + :::php + addWhere("\"Column\" = 'Value'"); + + // multiple predicates as an array + $query->addWhere(array("\"Column\" = 'Value'", "\"Column\" != 'Value'")); + + // Shorthand for the above using argument expansion + $query->addWhere("\"Column\" = 'Value'", "\"Column\" != 'Value'"); + + // Literal SQL condition + $query->addWhere('"Created" > NOW()"'); #### Joining diff --git a/docs/en/topics/security.md b/docs/en/topics/security.md index 0ebbef7bd..833d6bcf3 100644 --- a/docs/en/topics/security.md +++ b/docs/en/topics/security.md @@ -8,14 +8,57 @@ See our "[Release Process](/misc/release-process#security-releases) on how to re ## SQL Injection The [coding-conventions](/misc/coding-conventions) help guard against SQL injection attacks but still require developer -diligence: ensure that any variable you insert into a filter / sort / join clause has been escaped. +diligence: ensure that any variable you insert into a filter / sort / join clause is either parameterised, or has been +escaped. See [http://shiflett.org/articles/sql-injection](http://shiflett.org/articles/sql-injection). +### Parameterised queries + +Parameterised queries, or prepared statements, allow the logic around the query and its structure to be separated from +the parameters passed in to be executed. Many DB adaptors support these as standard including [PDO](http://php.net/manual/en/pdo.prepare.php), +[MySQL](http://php.net/manual/en/mysqli.prepare.php), [SQL Server](http://php.net/manual/en/function.sqlsrv-prepare.php), +[SQLite](http://php.net/manual/en/sqlite3.prepare.php), and [PostgreSQL](http://php.net/manual/en/function.pg-prepare.php). + +The use of parameterised queries whenever possible will safeguard your code in most cases, but care +must still be taken when working with literal values or table/column identifiers that may +come from user input. + +Example: + + :::php + $records = DB::preparedQuery('SELECT * FROM "MyClass" WHERE "ID" = ?', array(3)); + $records = MyClass::get()->where(array('"ID" = ?' => 3)); + $records = MyClass::get()->where(array('"ID"' => 3)); + $records = DataObject::get_by_id('MyClass', 3); + $records = DataObject::get_one('MyClass', array('"ID" = ?' => 3)); + $records = MyClass::get()->byID(3); + $records = SQLSelect::create()->addWhere(array('"ID"' => 3))->execute(); + +Parameterised updates and inserts are also supported, but the syntax is a little different + + :::php + SQLInsert::create('"MyClass"') + ->assign('"Name"', 'Daniel') + ->addAssignments(array( + '"Position"' => 'Accountant', + '"Age"' => array( + 'GREATEST(0,?,?)' => array(24, 28) + ) + )) + ->assignSQL('"Created"', 'NOW()') + ->execute(); + DB::preparedQuery( + 'INSERT INTO "MyClass" ("Name", "Position", "Age", "Created") VALUES(?, ?, GREATEST(0,?,?), NOW())' + array('Daniel', 'Accountant', 24, 28) + ); + + ### Automatic escaping -SilverStripe automatically escapes data in SQL statements wherever possible, -through database-specific methods (see `[api:Database->addslashes()]`). +SilverStripe internally will use parameterised queries in SQL statements wherever possible. + +If necessary Silverstripe performs any required escaping through database-specific methods (see `[api:Database->addslashes()]`). For `[api:MySQLDatabase]`, this will be `[mysql_real_escape_string()](http://de3.php.net/mysql_real_escape_string)`. * Most `[api:DataList]` accessors (see escaping note in method documentation) @@ -29,7 +72,8 @@ For `[api:MySQLDatabase]`, this will be `[mysql_real_escape_string()](http://de3 * FormField->saveInto() * DBField->saveInto() -Data is escaped when saving back to the database, not when writing to object-properties. +Data is not escaped when writing to object-properties, as inserts and updates are normally +handled via prepared statements. Example: @@ -38,23 +82,25 @@ Example: $members = Member::get()->filter('Name', $_GET['name']); // automatically escaped/quoted $members = Member::get()->filter(array('Name' => $_GET['name'])); - // needs to be escaped/quoted manually - $members = Member::get()->where(sprintf('"Name" = \'%s\'', Convert::raw2sql($_GET['name']))); + // parameterised condition + $members = Member::get()->where(array('"Name" = ?' => $_GET['name'])); + // needs to be escaped and quoted manually (note raw2sql called with the $quote parameter set to true) + $members = Member::get()->where(sprintf('"Name" = %s', Convert::raw2sql($_GET['name'], true)));
    -It is NOT good practice to "be sure" and convert the data passed to the functions below manually. This might +It is NOT good practice to "be sure" and convert the data passed to the functions above manually. This might result in *double escaping* and alters the actually saved data (e.g. by adding slashes to your content).
    ### Manual escaping -As a rule of thumb, whenever you're creating raw queries (or just chunks of SQL), you need to take care of escaping -yourself. See [coding-conventions](/misc/coding-conventions) and [datamodel](/topics/datamodel) for ways to cast and convert -your data. +As a rule of thumb, whenever you're creating SQL queries (or just chunks of SQL) you should use parameterisation, +but there may be cases where you need to take care of escaping yourself. See [coding-conventions](/misc/coding-conventions) +and [datamodel](/topics/datamodel) for ways to parameterise, cast, and convert your data. -* `SQLQuery` -* `DataObject::buildSQL()` +* `SQLSelect` * `DB::query()` +* `DB::preparedQuery()` * `Director::urlParams()` * `Controller->requestParams`, `Controller->urlParams` * `SS_HTTPRequest` data @@ -64,11 +110,12 @@ Example: :::php class MyForm extends Form { - public function save($RAW_data, $form) { - $SQL_data = Convert::raw2sql($RAW_data); // works recursively on an array - $objs = Player::get()->where("Name = '{$SQL_data[name]}'"); - // ... - } + public function save($RAW_data, $form) { + // Pass true as the second parameter of raw2sql to quote the value safely + $SQL_data = Convert::raw2sql($RAW_data, true); // works recursively on an array + $objs = Player::get()->where("Name = " . $SQL_data['name']); + // ... + } } @@ -79,32 +126,34 @@ Example: :::php class MyController extends Controller { - private static $allowed_actions = array('myurlaction'); - public function myurlaction($RAW_urlParams) { - $SQL_urlParams = Convert::raw2sql($RAW_urlParams); // works recursively on an array - $objs = Player::get()->where("Name = '{$SQL_data[OtherID]}'"); - // ... - } + private static $allowed_actions = array('myurlaction'); + public function myurlaction($RAW_urlParams) { + // Pass true as the second parameter of raw2sql to quote the value safely + $SQL_urlParams = Convert::raw2sql($RAW_urlParams, true); // works recursively on an array + $objs = Player::get()->where("Name = " . $SQL_data['OtherID']); + // ... + } } -As a rule of thumb, you should escape your data **as close to querying as possible**. -This means if you've got a chain of functions passing data through, escaping should happen at the end of the chain. +As a rule of thumb, you should escape your data **as close to querying as possible** +(or preferably, use parameterised queries). This means if you've got a chain of functions +passing data through, escaping should happen at the end of the chain. :::php class MyController extends Controller { - /** - * @param array $RAW_data All names in an indexed array (not SQL-safe) - */ - public function saveAllNames($RAW_data) { - // $SQL_data = Convert::raw2sql($RAW_data); // premature escaping - foreach($RAW_data as $item) $this->saveName($item); - } - - public function saveName($RAW_name) { - $SQL_name = Convert::raw2sql($RAW_name); - DB::query("UPDATE Player SET Name = '{$SQL_name}'"); - } + /** + * @param array $RAW_data All names in an indexed array (not SQL-safe) + */ + public function saveAllNames($RAW_data) { + // $SQL_data = Convert::raw2sql($RAW_data); // premature escaping + foreach($RAW_data as $item) $this->saveName($item); + } + + public function saveName($RAW_name) { + $SQL_name = Convert::raw2sql($RAW_name, true); + DB::query("UPDATE Player SET Name = {$SQL_name}"); + } } This might not be applicable in all cases - especially if you are building an API thats likely to be customized. If @@ -171,10 +220,10 @@ PHP: :::php class MyObject extends DataObject { - private static $db = array( - 'MyEscapedValue' => 'Text', // Example value: not bold - 'MyUnescapedValue' => 'HTMLText' // Example value: bold - ); + private static $db = array( + 'MyEscapedValue' => 'Text', // Example value: not bold + 'MyUnescapedValue' => 'HTMLText' // Example value: bold + ); } @@ -182,8 +231,8 @@ Template: :::php
      -
    • $MyEscapedValue
    • // output: <b>not bold<b> -
    • $MyUnescapedValue
    • // output: bold +
    • $MyEscapedValue
    • // output: <b>not bold<b> +
    • $MyUnescapedValue
    • // output: bold
    @@ -199,11 +248,11 @@ Template (see above): :::php
      - // output: foo & "bar" -
    • $Title
    • -
    • $MyEscapedValue
    • // output: <b>not bold<b> -
    • $MyUnescapedValue
    • // output: bold -
    • $MyUnescapedValue.XML
    • // output: <b>bold<b> + // output: foo & "bar" +
    • $Title
    • +
    • $MyEscapedValue
    • // output: <b>not bold<b> +
    • $MyUnescapedValue
    • // output: bold +
    • $MyUnescapedValue.XML
    • // output: <b>bold<b>
    @@ -217,7 +266,7 @@ PHP: :::php class MyObject extends DataObject { public $Title = 'not bold'; // will be escaped due to Text casting - + $casting = array( "Title" => "Text", // forcing a casting 'TitleWithHTMLSuffix' => 'HTMLText' // optional, as HTMLText is the default casting @@ -234,9 +283,9 @@ Template: :::php
      -
    • $Title
    • // output: <b>not bold<b> -
    • $Title.RAW
    • // output: not bold -
    • $TitleWithHTMLSuffix
    • // output: not bold: (...) +
    • $Title
    • // output: <b>not bold<b> +
    • $Title.RAW
    • // output: not bold +
    • $TitleWithHTMLSuffix
    • // output: not bold: (...)
    @@ -349,17 +398,17 @@ Below is an example with different ways you would use this casting technique: :::php public function CaseStudies() { - // cast an ID from URL parameters e.g. (mysite.com/home/action/ID) - $anotherID = (int)Director::urlParam['ID']; - - // perform a calculation, the prerequisite being $anotherID must be an integer - $calc = $anotherID + (5 - 2) / 2; - - // cast the 'category' GET variable as an integer - $categoryID = (int)$_GET['category']; - - // perform a byID(), which ensures the ID is an integer before querying - return CaseStudy::get()->byID($categoryID); + // cast an ID from URL parameters e.g. (mysite.com/home/action/ID) + $anotherID = (int)Director::urlParam['ID']; + + // perform a calculation, the prerequisite being $anotherID must be an integer + $calc = $anotherID + (5 - 2) / 2; + + // cast the 'category' GET variable as an integer + $categoryID = (int)$_GET['category']; + + // perform a byID(), which ensures the ID is an integer before querying + return CaseStudy::get()->byID($categoryID); } @@ -390,11 +439,10 @@ disallow certain filetypes. Example configuration for Apache2: - ... - - php_flag engine off - Options -ExecCGI -Includes -Indexes - + + php_flag engine off + Options -ExecCGI -Includes -Indexes + @@ -490,7 +538,6 @@ controller's `init()` method: class MyController extends Controller { public function init() { parent::init(); - $this->response->addHeader('X-Frame-Options', 'SAMEORIGIN'); } } diff --git a/filesystem/File.php b/filesystem/File.php index 73688e85d..3d5ef24fc 100644 --- a/filesystem/File.php +++ b/filesystem/File.php @@ -195,7 +195,7 @@ class File extends DataObject { if (!$record) { if(class_exists('ErrorPage')) { - $record = DataObject::get_one('ErrorPage', '"ErrorCode" = \'404\''); + $record = ErrorPage::get()->filter("ErrorCode", 404)->first(); } if (!$record) return; // There were no suitable matches at all. @@ -237,8 +237,10 @@ class File extends DataObject { $item = null; foreach($parts as $part) { if($part == ASSETS_DIR && !$parentID) continue; - $SQL_part = Convert::raw2sql($part); - $item = DataObject::get_one("File", "\"Name\" = '$SQL_part' AND \"ParentID\" = $parentID"); + $item = File::get()->filter(array( + 'Name' => $part, + 'ParentID' => $parentID + ))->first(); if(!$item) break; $parentID = $item->ID; } @@ -472,7 +474,9 @@ class File extends DataObject { * Delete the database record (recursively for folders) without touching the filesystem */ public function deleteDatabaseOnly() { - if(is_numeric($this->ID)) DB::query("DELETE FROM \"File\" WHERE \"ID\" = $this->ID"); + if(is_numeric($this->ID)) { + DB::prepared_query('DELETE FROM "File" WHERE "ID" = ?', array($this->ID)); + } } /** diff --git a/filesystem/Filesystem.php b/filesystem/Filesystem.php index 5fd8f1db9..8060d2c4c 100644 --- a/filesystem/Filesystem.php +++ b/filesystem/Filesystem.php @@ -1,33 +1,33 @@ get('Filesystem', 'folder_create_mask')); } - + /** * Remove a directory and all subdirectories and files. - * + * * @param String $folder Absolute folder path * @param Boolean $contentsOnly If this is true then the contents of the folder will be removed but not the * folder itself @@ -77,13 +77,13 @@ class Filesystem extends Object { if(!$contentsOnly) rmdir($folder); } } - + /** * Cleanup function to reset all the Filename fields. Visit File/fixfiles to call. */ public function fixfiles() { if(!Permission::check('ADMIN')) return Security::permissionFailure($this); - + $files = DataObject::get("File"); foreach($files as $file) { $file->updateFilesystem(); @@ -92,10 +92,10 @@ class Filesystem extends Object { } echo "

    Done!"; } - + /** * Return the most recent modification time of anything in the folder. - * + * * @param $folder The folder, relative to the site root * @param $extensionList An option array of file extensions to limit the search to * @return String Same as filemtime() format. @@ -103,17 +103,17 @@ class Filesystem extends Object { public static function folderModTime($folder, $extensionList = null, $recursiveCall = false) { //$cacheID = $folder . ',' . implode(',', $extensionList); //if(!$recursiveCall && self::$cache_folderModTime[$cacheID]) return self::$cache_folderModTime[$cacheID]; - + $modTime = 0; if(!Filesystem::isAbsolute($folder)) $folder = Director::baseFolder() . '/' . $folder; - + $items = scandir($folder); foreach($items as $item) { if($item[0] != '.') { // Recurse into folders if(is_dir("$folder/$item")) { $modTime = max($modTime, self::folderModTime("$folder/$item", $extensionList, true)); - + // Check files } else { if($extensionList) $extension = strtolower(substr($item,strrpos($item,'.')+1)); @@ -127,11 +127,11 @@ class Filesystem extends Object { //if(!$recursiveCall) self::$cache_folderModTime[$cacheID] = $modTime; return $modTime; } - + /** * Returns true if the given filename is an absolute file reference. * Works on Linux and Windows. - * + * * @param String $filename Absolute or relative filename, with or without path. * @return Boolean */ @@ -142,16 +142,16 @@ class Filesystem extends Object { /** * This function ensures the file table is correct with the files in the assets folder. - * + * * If a Folder record ID is given, all of that folder's children will be synchronised. * If the given Folder ID isn't found, or not specified at all, then everything will * be synchronised from the root folder (singleton Folder). - * - * See {@link File->updateFilesystem()} to sync properties of a single database record + * + * See {@link File->updateFilesystem()} to sync properties of a single database record * back to the equivalent filesystem record. - * + * * @param int $folderID Folder ID to sync along with all it's children - * @param Boolean $syncLinkTracking Determines if the link tracking data should also + * @param Boolean $syncLinkTracking Determines if the link tracking data should also * be updated via {@link SiteTree->syncLinkTracking()}. Setting this to FALSE * means that broken links inside page content are not noticed, at least until the next * call to {@link SiteTree->write()} on this page. @@ -160,13 +160,13 @@ class Filesystem extends Object { public static function sync($folderID = null, $syncLinkTracking = true) { $folder = DataObject::get_by_id('Folder', (int) $folderID); if(!($folder && $folder->exists())) $folder = singleton('Folder'); - + $results = $folder->syncChildren(); $finished = false; while(!$finished) { - $orphans = DB::query("SELECT \"C\".\"ID\" FROM \"File\" AS \"C\" - LEFT JOIN \"File\" AS \"P\" ON \"C\".\"ParentID\" = \"P\".\"ID\" - WHERE \"P\".\"ID\" IS NULL AND \"C\".\"ParentID\" > 0"); + $orphans = DB::query('SELECT "ChildFile"."ID" FROM "File" AS "ChildFile" + LEFT JOIN "File" AS "ParentFile" ON "ChildFile"."ParentID" = "ParentFile"."ID" + WHERE "ParentFile"."ID" IS NULL AND "ChildFile"."ParentID" > 0'); $finished = true; if($orphans) foreach($orphans as $orphan) { $finished = false; @@ -208,13 +208,13 @@ class Filesystem extends Object { } } } - + return _t( 'Filesystem.SYNCRESULTS', - 'Sync complete: {createdcount} items created, {deletedcount} items deleted', + 'Sync complete: {createdcount} items created, {deletedcount} items deleted', array('createdcount' => (int)$results['added'], 'deletedcount' => (int)$results['deleted']) ); } - + } diff --git a/filesystem/Folder.php b/filesystem/Folder.php index 23f4f99c1..b6f749751 100644 --- a/filesystem/Folder.php +++ b/filesystem/Folder.php @@ -2,18 +2,18 @@ /** * Represents a folder in the assets/ directory. * The folder path is stored in the "Filename" property. - * + * * Updating the "Name" or "Filename" properties on * a folder object also updates all associated children * (both {@link File} and {@link Folder} records). - * + * * Deleting a folder will also remove the folder from the filesystem, * including any subfolders and contained files. Use {@link deleteDatabaseOnly()} * to avoid touching the filesystem. - * + * * See {@link File} documentation for more details about the * relationship between the database and filesystem in the SilverStripe file APIs. - * + * * @package framework * @subpackage filesystem */ @@ -24,13 +24,13 @@ class Folder extends File { private static $plural_name = "Folders"; private static $default_sort = "\"Name\""; - + /** - * + * */ public function populateDefaults() { parent::populateDefaults(); - + if(!$this->Name) $this->Name = _t('AssetAdmin.NEWFOLDER',"NewFolder"); } @@ -39,7 +39,7 @@ class Folder extends File { * and on the filesystem. If necessary, creates parent folders as well. If it's * unable to find or make the folder, it will return null (as /assets is unable * to be represented by a Folder DataObject) - * + * * @param $folderPath string Absolute or relative path to the file. * If path is relative, its interpreted relative to the "assets/" directory. * @return Folder|null @@ -58,7 +58,7 @@ class Folder extends File { $filter = FileNameFilter::create(); foreach($parts as $part) { if(!$part) continue; // happens for paths with a trailing slash - + // Ensure search includes folders with illegal characters removed, but // err in favour of matching existing folders if $folderPath // includes illegal characters itself. @@ -67,7 +67,7 @@ class Folder extends File { 'ParentID' => $parentID, 'Name' => array($partSafe, $part) ))->first(); - + if(!$item) { $item = new Folder(); $item->ParentID = $parentID; @@ -83,9 +83,9 @@ class Folder extends File { return $item; } - + /** - * Synchronize the file database with the actual content of the assets + * Synchronize the file database with the actual content of the assets * folder. */ public function syncChildren() { @@ -95,19 +95,23 @@ class Folder extends File { $skipped = 0; // First, merge any children that are duplicates - $duplicateChildrenNames = DB::query("SELECT \"Name\" FROM \"File\"" - . " WHERE \"ParentID\" = $parentID GROUP BY \"Name\" HAVING count(*) > 1")->column(); + $duplicateChildrenNames = DB::prepared_query( + 'SELECT "Name" FROM "File" WHERE "ParentID" = ? GROUP BY "Name" HAVING count(*) > 1', + array($parentID) + )->column(); if($duplicateChildrenNames) foreach($duplicateChildrenNames as $childName) { - $childName = Convert::raw2sql($childName); // Note, we do this in the database rather than object-model; otherwise we get all sorts of problems // about deleting files - $children = DB::query("SELECT \"ID\" FROM \"File\"" - . " WHERE \"Name\" = '$childName' AND \"ParentID\" = $parentID")->column(); + $children = DB::prepared_query( + 'SELECT "ID" FROM "File" WHERE "Name" = ? AND "ParentID" = ?', + array($childName, $parentID) + )->column(); if($children) { $keptChild = array_shift($children); foreach($children as $removedChild) { - DB::query("UPDATE \"File\" SET \"ParentID\" = $keptChild WHERE \"ParentID\" = $removedChild"); - DB::query("DELETE FROM \"File\" WHERE \"ID\" = $removedChild"); + DB::prepared_query('UPDATE "File" SET "ParentID" = ? WHERE "ParentID" = ?', + array($keptChild, $removedChild)); + DB::prepared_query('DELETE FROM "File" WHERE "ID" = ?', array($removedChild)); } } else { user_error("Inconsistent database issue: SELECT ID FROM \"File\" WHERE Name = '$childName'" @@ -115,10 +119,10 @@ class Folder extends File { } } - + // Get index of database content // We don't use DataObject so that things like subsites doesn't muck with this. - $dbChildren = DB::query("SELECT * FROM \"File\" WHERE \"ParentID\" = $parentID"); + $dbChildren = DB::prepared_query('SELECT * FROM "File" WHERE "ParentID" = ?', array($parentID)); $hasDbChild = array(); if($dbChildren) { @@ -150,7 +154,7 @@ class Folder extends File { foreach($actualChildren as $actualChild) { $skip = false; - + // Check ignore patterns if($ignoreRules) foreach($ignoreRules as $rule) { if(preg_match($rule, $actualChild)) { @@ -158,7 +162,7 @@ class Folder extends File { break; } } - + // Check allowed extensions, unless admin users are allowed to bypass these exclusions if($checkExtensions && ($extension = self::get_file_extension($actualChild)) @@ -175,13 +179,13 @@ class Folder extends File { // A record with a bad class type doesn't deserve to exist. It must be purged! if(isset($hasDbChild[$actualChild])) { $child = $hasDbChild[$actualChild]; - if(( !( $child instanceof Folder ) && is_dir($baseDir . $actualChild) ) + if(( !( $child instanceof Folder ) && is_dir($baseDir . $actualChild) ) || (( $child instanceof Folder ) && !is_dir($baseDir . $actualChild)) ) { - DB::query("DELETE FROM \"File\" WHERE \"ID\" = $child->ID"); - unset($hasDbChild[$actualChild]); + DB::prepared_query('DELETE FROM "File" WHERE "ID" = ?', array($child->ID)); + unset($hasDbChild[$actualChild]); } } - + if(isset($hasDbChild[$actualChild])) { $child = $hasDbChild[$actualChild]; unset($unwantedDbChildren[$actualChild]); @@ -190,30 +194,30 @@ class Folder extends File { $childID = $this->constructChild($actualChild); $child = DataObject::get_by_id("File", $childID); } - + if( $child && is_dir($baseDir . $actualChild)) { $childResult = $child->syncChildren(); $added += $childResult['added']; $deleted += $childResult['deleted']; $skipped += $childResult['skipped']; } - + // Clean up the child record from memory after use. Important! $child->destroy(); $child = null; } - + // Iterate through the unwanted children, removing them all if(isset($unwantedDbChildren)) foreach($unwantedDbChildren as $unwantedDbChild) { - DB::query("DELETE FROM \"File\" WHERE \"ID\" = $unwantedDbChild->ID"); + DB::prepared_query('DELETE FROM "File" WHERE "ID" = ?', array($unwantedDbChild->ID)); $deleted++; } } else { - DB::query("DELETE FROM \"File\" WHERE \"ID\" = $this->ID"); + DB::prepared_query('DELETE FROM "File" WHERE "ID" = ?', array($this->ID)); } - + return array( - 'added' => $added, + 'added' => $added, 'deleted' => $deleted, 'skipped' => $skipped ); @@ -223,6 +227,9 @@ class Folder extends File { * Construct a child of this Folder with the given name. * It does this without actually using the object model, as this starts messing * with all the data. Rather, it does a direct database insert. + * + * @param string $name Name of the file or folder + * @return integer the ID of the newly saved File record */ public function constructChild($name) { // Determine the class name - File, Folder or Image @@ -233,20 +240,19 @@ class Folder extends File { $className = File::get_class_for_file_extension(pathinfo($name, PATHINFO_EXTENSION)); } - if(Member::currentUser()) $ownerID = Member::currentUser()->ID; - else $ownerID = 0; - - $filename = Convert::raw2sql($this->Filename . $name); + $ownerID = Member::currentUserID(); + + $filename = $this->Filename . $name; if($className == 'Folder' ) $filename .= '/'; - $name = Convert::raw2sql($name); - - DB::query("INSERT INTO \"File\" + $nowExpression = DB::get_conn()->now(); + DB::prepared_query("INSERT INTO \"File\" (\"ClassName\", \"ParentID\", \"OwnerID\", \"Name\", \"Filename\", \"Created\", \"LastEdited\", \"Title\") - VALUES ('$className', $this->ID, $ownerID, '$name', '$filename', " - . DB::getConn()->now() . ',' . DB::getConn()->now() . ", '$name')"); - - return DB::getGeneratedID("File"); + VALUES (?, ?, ?, ?, ?, $nowExpression, $nowExpression, ?)", + array($className, $this->ID, $ownerID, $name, $filename, $name) + ); + + return DB::get_generated_id("File"); } /** @@ -262,7 +268,7 @@ class Folder extends File { if(!isset($tmpFile['size'])) { return; } - + $base = BASE_PATH; // $parentFolder = Folder::findOrMake("Uploads"); @@ -275,14 +281,14 @@ class Folder extends File { $file = $this->RelativePath . $file; Filesystem::makeFolder(dirname("$base/$file")); - + $doubleBarrelledExts = array('.gz', '.bz', '.bz2'); - + $ext = ""; if(preg_match('/^(.*)(\.[^.]+)$/', $file, $matches)) { $file = $matches[1]; $ext = $matches[2]; - // Special case for double-barrelled + // Special case for double-barrelled if(in_array($ext, $doubleBarrelledExts) && preg_match('/^(.*)(\.[^.]+)$/', $file, $matches)) { $file = $matches[1]; $ext = $matches[2] . $ext; @@ -294,7 +300,7 @@ class Folder extends File { while(file_exists("$base/$file$ext")) { $i++; $oldFile = $file; - + if(strpos($file, '.') !== false) { $file = preg_replace('/[0-9]*(\.[^.]+$)/', $i . '\\1', $file); } elseif(strpos($file, '_') !== false) { @@ -305,7 +311,7 @@ class Folder extends File { if($oldFile == $file && $i > 2) user_error("Couldn't fix $file$ext with $i", E_USER_ERROR); } - + if (move_uploaded_file($tmpFile['tmp_name'], "$base/$file$ext")) { // Update with the new image return $this->constructChild(basename($file . $ext)); @@ -319,18 +325,18 @@ class Folder extends File { return false; } } - + public function validate() { return new ValidationResult(true); } - + //------------------------------------------------------------------------------------------------- // Data Model Definition public function getRelativePath() { return parent::getRelativePath() . "/"; } - + public function onBeforeDelete() { if($this->ID && ($children = $this->AllChildren())) { foreach($children as $child) { @@ -345,11 +351,11 @@ class Folder extends File { // Do this after so a folder's contents are removed before we delete the folder. if($this->Filename && $this->Name && file_exists($this->getFullPath())) { $files = glob( $this->getFullPath() . '/*' ); - + if( !$files || ( count( $files ) == 1 && preg_match( '/\/_resampled$/', $files[0] ) ) ) Filesystem::removeFolder( $this->getFullPath() ); } - + parent::onBeforeDelete(); } @@ -376,13 +382,13 @@ class Folder extends File { /** * A folder doesn't have a (meaningful) file size. - * + * * @return Null */ public function getSize() { return null; } - + /** * Delete the database record (recursively for folders) without touching the filesystem */ @@ -393,40 +399,34 @@ class Folder extends File { parent::deleteDatabaseOnly(); } - - public function myChildren() { - // Ugly, but functional. - $ancestors = ClassInfo::ancestry($this->class); - foreach($ancestors as $i => $a) { - if(isset($baseClass) && $baseClass === -1) { - $baseClass = $a; - break; - } - if($a == "DataObject") $baseClass = -1; - } - - $g = DataObject::get($baseClass, "\"ParentID\" = " . $this->ID); - return $g; - } - + /** - * Returns true if this folder has children + * Returns all children of this folder + * + * @return DataList */ - public function hasChildren() { - return (bool)DB::query("SELECT COUNT(*) FROM \"File\" WHERE ParentID = " - . (int)$this->ID)->value(); + public function myChildren() { + return File::get()->filter("ParentID", $this->ID); } /** * Returns true if this folder has children + * + * @return bool + */ + public function hasChildren() { + return $this->myChildren()->exists(); + } + + /** + * Returns true if this folder has children + * + * @return bool */ public function hasChildFolders() { - $SQL_folderClasses = Convert::raw2sql(ClassInfo::subclassesFor('Folder')); - - return (bool)DB::query("SELECT COUNT(*) FROM \"File\" WHERE \"ParentID\" = " . (int)$this->ID - . " AND \"ClassName\" IN ('" . implode("','", $SQL_folderClasses) . "')")->value(); + return $this->ChildFolders()->exists(); } - + /** * Overloaded to call recursively on all contained {@link File} records. */ @@ -442,7 +442,7 @@ class Folder extends File { } } } - + /** * Return the FieldList used to edit this folder in the CMS. * You can modify this FieldList by subclassing folder, or by creating a {@link DataExtension} @@ -451,27 +451,29 @@ class Folder extends File { public function getCMSFields() { // Hide field on root level, which can't be renamed if(!$this->ID || $this->ID === "root") { - $titleField = new HiddenField("Name"); + $titleField = new HiddenField("Name"); } else { $titleField = new TextField("Name", $this->fieldLabel('Name')); } - + $fields = new FieldList( $titleField, new HiddenField('ParentID') ); $this->extend('updateCMSFields', $fields); - + return $fields; } /** * Get the children of this folder that are also folders. + * + * @return DataList */ public function ChildFolders() { return Folder::get()->filter('ParentID', $this->ID); } - + /** * @return String */ @@ -481,14 +483,14 @@ class Folder extends File { if(!$this->canDelete()) $classes .= " nodelete"; - if(!$this->canEdit()) + if(!$this->canEdit()) $classes .= " disabled"; - + $classes .= $this->markingClasses(); return $classes; } - + /** * @return string */ diff --git a/forms/Form.php b/forms/Form.php index a1bff3c5d..4c50e1b06 100644 --- a/forms/Form.php +++ b/forms/Form.php @@ -1110,8 +1110,8 @@ class Form extends RequestHandler { /** * Set a status message for the form. * - * @param message the text of the message - * @param type Should be set to good, bad, or warning. + * @param string $message the text of the message + * @param string $type Should be set to good, bad, or warning. */ public function setMessage($message, $type) { $this->message = $message; @@ -1122,15 +1122,15 @@ class Form extends RequestHandler { /** * Set a message to the session, for display next time this form is shown. * - * @param message the text of the message - * @param type Should be set to good, bad, or warning. + * @param string $message the text of the message + * @param string $type Should be set to good, bad, or warning. */ public function sessionMessage($message, $type) { Session::set("FormInfo.{$this->FormName()}.formError.message", $message); Session::set("FormInfo.{$this->FormName()}.formError.type", $type); } - public static function messageForForm( $formName, $message, $type ) { + public static function messageForForm($formName, $message, $type ) { Session::set("FormInfo.{$formName}.formError.message", $message); Session::set("FormInfo.{$formName}.formError.type", $type); } diff --git a/forms/HtmlEditorField.php b/forms/HtmlEditorField.php index f39587484..d0e47bda0 100644 --- a/forms/HtmlEditorField.php +++ b/forms/HtmlEditorField.php @@ -27,7 +27,7 @@ class HtmlEditorField extends TextareaField { private static $sanitise_server_side = false; protected $rows = 30; - + /** * Includes the JavaScript neccesary for this field to work using the {@link Requirements} system. */ @@ -50,20 +50,20 @@ class HtmlEditorField extends TextareaField { } else { Requirements::javascript(MCE_ROOT . 'tiny_mce_src.js'); - } + } Requirements::customScript($configObj->generateJS(), 'htmlEditorConfig'); } - + /** * @see TextareaField::__construct() */ public function __construct($name, $title = null, $value = '') { parent::__construct($name, $title, $value); - + self::include_js(); } - + /** * @return string */ @@ -73,7 +73,7 @@ class HtmlEditorField extends TextareaField { if($links = $value->getElementsByTagName('a')) foreach($links as $link) { $matches = array(); - + if(preg_match('/\[sitetree_link(?:\s*|%20|,)?id=([0-9]+)\]/i', $link->getAttribute('href'), $matches)) { if(!DataObject::get_by_id('SiteTree', $matches[1])) { $class = $link->getAttribute('class'); @@ -105,14 +105,14 @@ class HtmlEditorField extends TextareaField { ) ); } - + public function saveInto(DataObjectInterface $record) { if($record->hasField($this->name) && $record->escapeTypeForField($this->name) != 'xml') { throw new Exception ( 'HtmlEditorField->saveInto(): This field should save into a HTMLText or HTMLVarchar field.' ); } - + $htmlValue = Injector::inst()->create('HTMLValue', $this->value); // Sanitise if requested @@ -153,10 +153,10 @@ class HtmlEditorField extends TextareaField { public function performReadonlyTransformation() { $field = $this->castedCopy('HtmlEditorField_Readonly'); $field->dontEscape = true; - + return $field; } - + public function performDisabledTransformation() { return $this->performReadonlyTransformation(); } @@ -182,7 +182,7 @@ class HtmlEditorField_Readonly extends ReadonlyField { /** * Toolbar shared by all instances of {@link HTMLEditorField}, to avoid too much markup duplication. * Needs to be inserted manually into the template in order to function - see {@link LeftAndMain->EditorToolbar()}. - * + * * @package forms * @subpackage fields-formattedinput */ @@ -200,7 +200,7 @@ class HtmlEditorField_Toolbar extends RequestHandler { protected $templateViewFile = 'HtmlEditorField_viewfile'; protected $controller, $name; - + public function __construct($controller, $name) { parent::__construct(); @@ -211,7 +211,7 @@ class HtmlEditorField_Toolbar extends RequestHandler { Requirements::javascript(FRAMEWORK_DIR ."/javascript/HtmlEditorField.js"); Requirements::css(THIRDPARTY_DIR . '/jquery-ui-themes/smoothness/jquery-ui.css'); - + $this->controller = $controller; $this->name = $name; } @@ -226,17 +226,20 @@ class HtmlEditorField_Toolbar extends RequestHandler { /** * Searches the SiteTree for display in the dropdown - * + * * @return callback - */ + */ public function siteTreeSearchCallback($sourceObject, $labelField, $search) { - return DataObject::get($sourceObject, "\"MenuTitle\" LIKE '%$search%' OR \"Title\" LIKE '%$search%'"); + return DataObject::get($sourceObject)->filterAny(array( + 'MenuTitle:PartialMatch' => $search, + 'Title:PartialMatch' => $search + )); } - + /** * Return a {@link Form} instance allowing a user to * add links in the TinyMCE content editor. - * + * * @return Form */ public function LinkForm() { @@ -244,16 +247,16 @@ class HtmlEditorField_Toolbar extends RequestHandler { 'SiteTree', 'ID', 'MenuTitle', true); // mimic the SiteTree::getMenuTitle(), which is bypassed when the search is performed $siteTree->setSearchFunction(array($this, 'siteTreeSearchCallback')); - + $numericLabelTmpl = '%d' . '%s'; $form = new Form( $this->controller, - "{$this->name}/LinkForm", + "{$this->name}/LinkForm", new FieldList( $headerWrap = new CompositeField( new LiteralField( - 'Heading', + 'Heading', sprintf('

    %s

    ', _t('HtmlEditorField.LINK', 'Insert Link')) ) @@ -298,22 +301,22 @@ class HtmlEditorField_Toolbar extends RequestHandler { ) ); - $headerWrap->addExtraClass('CompositeField composite cms-content-header nolabel '); + $headerWrap->addExtraClass('CompositeField composite cms-content-header nolabel '); $contentComposite->addExtraClass('ss-insert-link content'); - + $form->unsetValidator(); $form->loadDataFrom($this); $form->addExtraClass('htmleditorfield-form htmleditorfield-linkform cms-dialog-content'); - + $this->extend('updateLinkForm', $form); - + return $form; } /** * Return a {@link Form} instance allowing a user to * add images and flash objects to the TinyMCE content editor. - * + * * @return Form */ public function MediaForm() { @@ -340,17 +343,17 @@ class HtmlEditorField_Toolbar extends RequestHandler { 'CMSThumbnail' => false, 'Name' => _t('File.Name'), )); - + $numericLabelTmpl = '%d' . '%s'; $fromCMS = new CompositeField( - new LiteralField('headerSelect', + new LiteralField('headerSelect', '

    '.sprintf($numericLabelTmpl, '1', _t('HtmlEditorField.FindInFolder', 'Find in Folder')).'

    '), - $select = TreeDropdownField::create('ParentID', "", 'Folder')->addExtraClass('noborder'), + $select = TreeDropdownField::create('ParentID', "", 'Folder')->addExtraClass('noborder'), $fileField ); - + $fromCMS->addExtraClass('content ss-uploadfield'); $select->addExtraClass('content-select'); @@ -424,7 +427,7 @@ class HtmlEditorField_Toolbar extends RequestHandler { $headings, $allFields ); - + $actions = new FieldList( FormAction::create('insertmedia', _t('HtmlEditorField.BUTTONINSERT', 'Insert')) ->addExtraClass('ss-ui-action-constructive media-insert') @@ -442,7 +445,7 @@ class HtmlEditorField_Toolbar extends RequestHandler { $fields, $actions ); - + $form->unsetValidator(); $form->disableSecurityToken(); @@ -451,9 +454,9 @@ class HtmlEditorField_Toolbar extends RequestHandler { // TODO Re-enable once we remove $.metadata dependency which currently breaks the JS due to $.ui.widget // $form->setAttribute('data-urlViewfile', $this->controller->Link($this->name)); - // Allow other people to extend the fields being added to the imageform + // Allow other people to extend the fields being added to the imageform $this->extend('updateMediaForm', $form); - + return $form; } @@ -470,15 +473,15 @@ class HtmlEditorField_Toolbar extends RequestHandler { $file = new File(array( 'Title' => basename($url), 'Filename' => $url - )); + )); } else { $url = Director::makeRelative($request->getVar('FileURL')); $url = preg_replace('/_resampled\/[^-]+-/', '', $url); - $file = File::get()->filter('Filename', $url)->first(); + $file = File::get()->filter('Filename', $url)->first(); if(!$file) $file = new File(array( 'Title' => basename($url), 'Filename' => $url - )); + )); } } elseif($id = $request->getVar('ID')) { $file = DataObject::get_by_id('File', $id); @@ -510,7 +513,7 @@ class HtmlEditorField_Toolbar extends RequestHandler { * Similar to {@link File->getCMSFields()}, but only returns fields * for manipulating the instance of the file as inserted into the HTML content, * not the "master record" in the database - hence there's no form or saving logic. - * + * * @param String Relative or absolute URL to file * @return FieldList */ @@ -605,7 +608,7 @@ class HtmlEditorField_Toolbar extends RequestHandler { if($file->Type == 'photo') { $fields->insertBefore('CaptionText', new TextField( 'AltText', - _t('HtmlEditorField.IMAGEALTTEXT', 'Alternative text (alt) - shown if image can\'t be displayed'), + _t('HtmlEditorField.IMAGEALTTEXT', 'Alternative text (alt) - shown if image can\'t be displayed'), $file->Title, 80 )); @@ -745,13 +748,10 @@ class HtmlEditorField_Toolbar extends RequestHandler { * @return DataList */ protected function getFiles($parentID = null) { - // TODO Use array('Filename:EndsWith' => $exts) once that's supported $exts = $this->getAllowedExtensions(); - $wheres = array(); - foreach($exts as $ext) $wheres[] = '"Filename" LIKE \'%.' . $ext . '\''; + $dotExts = array_map(function($ext) { return ".{$ext}"; }, $exts); + $files = File::get()->filter('Filename:EndsWith', $dotExts); - $files = File::get()->where(implode(' OR ', $wheres)); - // Limit by folder (if required) if($parentID) { $files = $files->filter('ParentID', $parentID); @@ -795,7 +795,7 @@ class HtmlEditorField_File extends ViewableData { /** * @param String - * @param File + * @param File */ public function __construct($url, $file = null) { $this->url = $url; @@ -942,7 +942,7 @@ class HtmlEditorField_Embed extends HtmlEditorField_File { public function appCategory() { return 'embed'; } - + public function getInfo() { return $this->oembed->info; } @@ -981,7 +981,7 @@ class HtmlEditorField_Image extends HtmlEditorField_File { /** * Provide an initial width for inserted image, restricted based on $embed_width - * + * * @return int */ public function getInsertWidth() { @@ -992,7 +992,7 @@ class HtmlEditorField_Image extends HtmlEditorField_File { /** * Provide an initial height for inserted image, scaled proportionally to the initial width - * + * * @return int */ public function getInsertHeight() { diff --git a/forms/TreeDropdownField.php b/forms/TreeDropdownField.php index 34bf57f22..1dc146005 100644 --- a/forms/TreeDropdownField.php +++ b/forms/TreeDropdownField.php @@ -245,7 +245,7 @@ class TreeDropdownField extends FormField { // Regular source specification $isSubTree = false; - $this->search = Convert::Raw2SQL($request->requestVar('search')); + $this->search = $request->requestVar('search'); $ID = (is_numeric($request->latestparam('ID'))) ? (int)$request->latestparam('ID') : (int)$request->requestVar('ID'); @@ -432,19 +432,19 @@ class TreeDropdownField extends FormField { $res = call_user_func($this->searchCallback, $this->sourceObject, $this->labelField, $this->search); } else { $sourceObject = $this->sourceObject; - $wheres = array(); + $filters = array(); if(singleton($sourceObject)->hasDatabaseField($this->labelField)) { - $wheres[] = "\"$this->labelField\" LIKE '%$this->search%'"; + $filters["{$this->labelField}:PartialMatch"] = $this->search; } else { if(singleton($sourceObject)->hasDatabaseField('Title')) { - $wheres[] = "\"Title\" LIKE '%$this->search%'"; + $filters["Title:PartialMatch"] = $this->search; } if(singleton($sourceObject)->hasDatabaseField('Name')) { - $wheres[] = "\"Name\" LIKE '%$this->search%'"; + $filters["Name:PartialMatch"] = $this->search; } } - if(!$wheres) { + if(empty($filters)) { throw new InvalidArgumentException(sprintf( 'Cannot query by %s.%s, not a valid database column', $sourceObject, @@ -452,7 +452,7 @@ class TreeDropdownField extends FormField { )); } - $res = DataObject::get($this->sourceObject, implode(' OR ', $wheres)); + $res = DataObject::get($this->sourceObject)->filterAny($filters); } if( $res ) { @@ -462,8 +462,11 @@ class TreeDropdownField extends FormField { $this->searchIds[$row->ID] = true; } while (!empty($parents)) { - $res = DB::query('SELECT "ParentID", "ID" FROM "' . $this->sourceObject - . '" WHERE "ID" in ('.implode(',',array_keys($parents)).')'); + $idsClause = DB::placeholders($parents); + $res = DB::prepared_query( + "SELECT \"ParentID\", \"ID\" FROM \"{$this->sourceObject}\" WHERE \"ID\" in ($idsClause)", + array_keys($parents) + ); $parents = array(); foreach($res as $row) { @@ -482,11 +485,9 @@ class TreeDropdownField extends FormField { * @return DataObject */ protected function objectForKey($key) { - if($this->keyField == 'ID') { - return DataObject::get_by_id($this->sourceObject, $key); - } else { - return DataObject::get_one($this->sourceObject, "\"{$this->keyField}\" = '".Convert::raw2sql($key)."'"); - } + return DataObject::get($this->sourceObject) + ->filter($this->keyField, $key) + ->first(); } /** diff --git a/forms/gridfield/GridField.php b/forms/gridfield/GridField.php index 32583acd0..2c9eb0e4d 100644 --- a/forms/gridfield/GridField.php +++ b/forms/gridfield/GridField.php @@ -662,7 +662,7 @@ class GridField extends FormField { * @param string $actionName * @param mixed $args * @param arrray $data - send data from a form - * @return type + * @return mixed * @throws InvalidArgumentException */ public function handleAlterAction($actionName, $args, $data) { @@ -798,10 +798,10 @@ class GridField_FormAction extends FormAction { /** * @param GridField $gridField - * @param type $name - * @param type $label - * @param type $actionName - * @param type $args + * @param string $name + * @param string $label + * @param string $actionName + * @param array $args */ public function __construct(GridField $gridField, $name, $title, $actionName, $args) { $this->gridField = $gridField; diff --git a/forms/gridfield/GridFieldComponent.php b/forms/gridfield/GridFieldComponent.php index d3cc73025..53db8f957 100644 --- a/forms/gridfield/GridFieldComponent.php +++ b/forms/gridfield/GridFieldComponent.php @@ -55,6 +55,7 @@ interface GridField_ColumnProvider extends GridFieldComponent { * @see {@link GridFieldDataColumns}. * * @param GridField $gridField + * @param arary $columns List of columns * @param array - List reference of all column names. */ public function augmentColumns($gridField, &$columns); diff --git a/forms/gridfield/GridFieldDeleteAction.php b/forms/gridfield/GridFieldDeleteAction.php index cbe33dbee..ffb4d68c2 100644 --- a/forms/gridfield/GridFieldDeleteAction.php +++ b/forms/gridfield/GridFieldDeleteAction.php @@ -42,7 +42,7 @@ class GridFieldDeleteAction implements GridField_ColumnProvider, GridField_Actio /** * Add a column 'Delete' * - * @param type $gridField + * @param GridField $gridField * @param array $columns */ public function augmentColumns($gridField, &$columns) { @@ -79,8 +79,8 @@ class GridFieldDeleteAction implements GridField_ColumnProvider, GridField_Actio /** * Which columns are handled by this component * - * @param type $gridField - * @return type + * @param GridField $gridField + * @return array */ public function getColumnsHandled($gridField) { return array('Actions'); diff --git a/forms/gridfield/GridFieldDetailForm.php b/forms/gridfield/GridFieldDetailForm.php index a19151fc4..4ebc19c0f 100644 --- a/forms/gridfield/GridFieldDetailForm.php +++ b/forms/gridfield/GridFieldDetailForm.php @@ -72,8 +72,8 @@ class GridFieldDetailForm implements GridField_URLHandler { /** * - * @param type $gridField - * @param type $request + * @param GridField $gridField + * @param SS_HTTPRequest $request * @return GridFieldDetailForm_ItemRequest */ public function handleItem($gridField, $request) { diff --git a/forms/gridfield/GridFieldEditButton.php b/forms/gridfield/GridFieldEditButton.php index 18801bc47..48415e3a9 100644 --- a/forms/gridfield/GridFieldEditButton.php +++ b/forms/gridfield/GridFieldEditButton.php @@ -18,7 +18,7 @@ class GridFieldEditButton implements GridField_ColumnProvider { /** * Add a column 'Delete' * - * @param type $gridField + * @param GridField $gridField * @param array $columns */ public function augmentColumns($gridField, &$columns) { @@ -54,8 +54,8 @@ class GridFieldEditButton implements GridField_ColumnProvider { /** * Which columns are handled by this component * - * @param type $gridField - * @return type + * @param GridField $gridField + * @return array */ public function getColumnsHandled($gridField) { return array('Actions'); diff --git a/forms/gridfield/GridState.php b/forms/gridfield/GridState.php index 9dd9a3ddd..28e75e853 100644 --- a/forms/gridfield/GridState.php +++ b/forms/gridfield/GridState.php @@ -100,7 +100,7 @@ class GridState extends HiddenField { /** * - * @return type + * @return string */ public function attrValue() { return Convert::raw2att($this->Value()); @@ -108,7 +108,7 @@ class GridState extends HiddenField { /** * - * @return type + * @return string */ public function __toString() { return $this->Value(); diff --git a/model/Aggregate.php b/model/Aggregate.php index 84cc105e2..28a6541ab 100644 --- a/model/Aggregate.php +++ b/model/Aggregate.php @@ -82,10 +82,10 @@ class Aggregate extends ViewableData { } /** - * Build the SQLQuery to calculate the aggregate + * Build the SQLSelect to calculate the aggregate * This is a seperate function so that subtypes of Aggregate can change just this bit * @param string $attr - the SQL field statement for selection (i.e. "MAX(LastUpdated)") - * @return SQLQuery + * @return SQLSelect */ protected function query($attr) { $query = DataList::create($this->type)->where($this->filter); @@ -108,7 +108,7 @@ class Aggregate extends ViewableData { $table = null; foreach (ClassInfo::ancestry($this->type, true) as $class) { - $fields = DataObject::database_fields($class); + $fields = DataObject::database_fields($class, false); if (array_key_exists($attribute, $fields)) { $table = $class; break; } } @@ -117,7 +117,8 @@ class Aggregate extends ViewableData { $query = $this->query("$func(\"$table\".\"$attribute\")"); // Cache results of this specific SQL query until flushCache() is triggered. - $cachekey = sha1($query->sql()); + $sql = $query->sql($parameters); + $cachekey = sha1($sql.'-'.var_export($parameters, true)); $cache = self::cache(); if (!($result = $cache->load($cachekey))) { diff --git a/model/ArrayList.php b/model/ArrayList.php index 89bba7143..2dca8e62e 100644 --- a/model/ArrayList.php +++ b/model/ArrayList.php @@ -275,8 +275,8 @@ class ArrayList extends ViewableData implements SS_List, SS_Filterable, SS_Sorta /** * Returns a map of this list * - * @param type $keyfield - the 'key' field of the result array - * @param type $titlefield - the value field of the result array + * @param string $keyfield - the 'key' field of the result array + * @param string $titlefield - the value field of the result array * @return array */ public function map($keyfield = 'ID', $titlefield = 'Title') { @@ -295,9 +295,9 @@ class ArrayList extends ViewableData implements SS_List, SS_Filterable, SS_Sorta /** * Find the first item of this list where the given key = value * - * @param type $key - * @param type $value - * @return type + * @param string $key + * @param string $value + * @return mixed */ public function find($key, $value) { foreach ($this->items as $item) { diff --git a/model/DB.php b/model/DB.php index 558fb72f5..985a42016 100644 --- a/model/DB.php +++ b/model/DB.php @@ -2,11 +2,12 @@ /** * Global database interface, complete with static methods. * Use this class for interacting with the database. - * + * * @package framework * @subpackage model */ class DB { + /** * This constant was added in SilverStripe 2.4 to indicate that SQL-queries * should now use ANSI-compatible syntax. The most notable affect of this @@ -14,7 +15,7 @@ class DB { * and not backticks */ const USE_ANSI_SQL = true; - + /** * The global database connection. @@ -37,38 +38,99 @@ class DB { * Set the global database connection. * Pass an object that's a subclass of SS_Database. This object will be used when {@link DB::query()} * is called. + * * @param $connection The connecton object to set as the connection. * @param $name The name to give to this connection. If you omit this argument, the connection * will be the default one used by the ORM. However, you can store other named connections to - * be accessed through DB::getConn($name). This is useful when you have an application that + * be accessed through DB::get_conn($name). This is useful when you have an application that * needs to connect to more than one database. */ - public static function setConn(SS_Database $connection, $name = 'default') { + public static function set_conn(SS_Database $connection, $name = 'default') { self::$connections[$name] = $connection; } + /** + * @deprecated since version 3.3 Use DB::set_conn instead + */ + public static function setConn(SS_Database $connection, $name = 'default') { + Deprecation::notice('3.3', 'Use DB::set_conn instead'); + self::set_conn($connection, $name); + } + /** * Get the global database connection. - * @param $name An optional name given to a connection in the DB::setConn() call. If omitted, + * + * @param string $name An optional name given to a connection in the DB::setConn() call. If omitted, * the default connection is returned. * @return SS_Database */ - public static function getConn($name = 'default') { + public static function get_conn($name = 'default') { if(isset(self::$connections[$name])) { return self::$connections[$name]; } } - + /** - * Set an alternative database in a browser cookie, + * @deprecated since version 3.3 Use DB::get_conn instead + */ + public static function getConn($name = 'default') { + Deprecation::notice('3.3', 'Use DB::get_conn instead'); + return self::get_conn($name); + } + + /** + * Retrieves the schema manager for the current database + * + * @param string $name An optional name given to a connection in the DB::setConn() call. If omitted, + * the default connection is returned. + * @return DBSchemaManager + */ + public static function get_schema($name = 'default') { + $connection = self::get_conn($name); + if($connection) return $connection->getSchemaManager(); + } + + /** + * Builds a sql query with the specified connection + * + * @param SQLExpression $expression The expression object to build from + * @param array $parameters Out parameter for the resulting query parameters + * @param string $name An optional name given to a connection in the DB::setConn() call. If omitted, + * the default connection is returned. + * @return string The resulting SQL as a string + */ + public static function build_sql(SQLExpression $expression, &$parameters, $name = 'default') { + $connection = self::get_conn($name); + if($connection) { + return $connection->getQueryBuilder()->buildSQL($expression, $parameters); + } else { + $parameters = array(); + return null; + } + } + + /** + * Retrieves the connector object for the current database + * + * @param string $name An optional name given to a connection in the DB::setConn() call. If omitted, + * the default connection is returned. + * @return DBConnector + */ + public static function get_connector($name = 'default') { + $connection = self::get_conn($name); + if($connection) return $connection->getConnector(); + } + + /** + * Set an alternative database in a browser cookie, * with the cookie lifetime set to the browser session. * This is useful for integration testing on temporary databases. * - * There is a strict naming convention for temporary databases to avoid abuse: + * There is a strict naming convention for temporary databases to avoid abuse: * (default: 'ss_') + tmpdb + <7 digits> * As an additional security measure, temporary databases will * be ignored in "live" mode. - * + * * Note that the database will be set on the next request. * Set it to null to revert to the main database. */ @@ -104,7 +166,7 @@ class DB { Cookie::set("alternativeDatabaseNameIv", null, 0, null, null, false, true); } } - + /** * Get the name of the database in use */ @@ -133,7 +195,7 @@ class DB { /** * Determines if the name is valid, as a security * measure against setting arbitrary databases. - * + * * @param String $name * @return Boolean */ @@ -148,7 +210,7 @@ class DB { /** * Connect to a database. * - * Given the database configuration, this method will create the correct + * Given the database configuration, this method will create the correct * subclass of {@link SS_Database}. * * @param array $database A map of options. The 'type' is the name of the subclass of SS_Database to use. For the @@ -158,7 +220,7 @@ class DB { * @return SS_Database */ public static function connect($databaseConfig, $label = 'default') { - + // This is used by the "testsession" module to test up a test session using an alternative name if($name = self::get_alternative_database_name()) { $databaseConfig['database'] = $name; @@ -171,13 +233,17 @@ class DB { self::$connection_attempted = true; $dbClass = $databaseConfig['type']; - $conn = new $dbClass($databaseConfig); - self::setConn($conn, $label); + // Using Injector->get allows us to use registered configurations + // which may or may not map to explicit objects + $conn = Injector::inst()->get($dbClass); + $conn->connect($databaseConfig); + + self::set_conn($conn, $label); return $conn; } - + /** * Returns true if a database connection has been attempted. * In particular, it lets the caller know if we're still so early in the execution pipeline that @@ -188,12 +254,10 @@ class DB { } /** - * Build the connection string from input. - * @param array $parameters The connection details. - * @return string $connect The connection string. - **/ + * @deprecated since version 3.2 DB::getConnect was never implemented and is obsolete + */ public static function getConnect($parameters) { - return self::getConn()->getConnect($parameters); + Deprecation::notice('3.2', 'DB::getConnect was never implemented and is obsolete'); } /** @@ -204,8 +268,43 @@ class DB { */ public static function query($sql, $errorLevel = E_USER_ERROR) { self::$lastQuery = $sql; - - return self::getConn()->query($sql, $errorLevel); + + return self::get_conn()->query($sql, $errorLevel); + } + + /** + * Helper function for generating a list of parameter placeholders for the + * given argument(s) + * + * @param array|integer $input An array of items needing placeholders, or a + * number to specify the number of placeholders + * @param string The string to join each placeholder together with + * @return string|null Either a list of placeholders, or null + */ + public static function placeholders($input, $join = ', ') { + if(is_array($input)) { + $number = count($input); + } elseif(is_numeric($input)) { + $number = intval($input); + } else { + return null; + } + if($number === 0) return null; + return implode($join, array_fill(0, $number, '?')); + } + + /** + * Execute the given SQL parameterised query with the specified arguments + * + * @param string $sql The SQL query to execute. The ? character will denote parameters. + * @param array $parameters An ordered list of arguments. + * @param int $errorLevel The level of error reporting to enable for the query + * @return SS_Query + */ + public static function prepared_query($sql, $parameters, $errorLevel = E_USER_ERROR) { + self::$lastQuery = $sql; + + return self::get_conn()->preparedQuery($sql, $parameters, $errorLevel); } /** @@ -214,7 +313,7 @@ class DB { * and the values are map containing 'command' and 'fields'. Command should be 'insert' or 'update', * and fields should be a map of field names to field values, including quotes. The field value can * also be a SQL function or similar. - * + * * Example: * * array( @@ -228,7 +327,7 @@ class DB { * ), * "id" => 234 // an alternative to providing ID in the fields list * ), - * + * * // Command: update * "other table" => array( * "command" => "update", @@ -241,61 +340,97 @@ class DB { * ), * ) * - * - * You'll note that only one command on a given table can be called. - * That's a limitation of the system that's due to it being written for {@link DataObject::write()}, + * + * You'll note that only one command on a given table can be called. + * That's a limitation of the system that's due to it being written for {@link DataObject::write()}, * which needs to do a single write on a number of different tables. - * + * + * @todo Update this to support paramaterised queries + * * @param array $manipulation */ public static function manipulate($manipulation) { self::$lastQuery = $manipulation; - return self::getConn()->manipulate($manipulation); + return self::get_conn()->manipulate($manipulation); } /** * Get the autogenerated ID from the previous INSERT query. * @return int */ + public static function get_generated_id($table) { + return self::get_conn()->getGeneratedID($table); + } + + /** + * @deprecated since version 3.3 Use DB::get_generated_id instead + */ public static function getGeneratedID($table) { - return self::getConn()->getGeneratedID($table); + Deprecation::notice('3.3', 'Use DB::get_generated_id instead'); + return self::get_generated_id($table); } /** * Check if the connection to the database is active. + * * @return boolean */ + public static function is_active() { + return ($conn = self::get_conn()) && $conn->isActive(); + } + + /** + * @deprecated since version 3.3 Use DB::is_active instead + */ public static function isActive() { - if($conn = self::getConn()) return $conn->isActive(); - else return false; + Deprecation::notice('3.3', 'Use DB::is_active instead'); + return self::is_active(); } /** * Create the database and connect to it. This can be called if the * initial database connection is not successful because the database * does not exist. - * @param string $connect Connection string - * @param string $username SS_Database username - * @param string $password SS_Database Password - * @param string $database SS_Database to which to create + * + * @param string $database Name of database to create * @return boolean Returns true if successful */ + public static function create_database($database) { + return self::get_conn()->selectDatabase($database, true); + } + + /** + * @deprecated since version 3.3 Use DB::create_database instead + */ public static function createDatabase($connect, $username, $password, $database) { - return self::getConn()->createDatabase($connect, $username, $password, $database); + Deprecation::notice('3.3', 'Use DB::create_database instead'); + return self::create_database($database); } /** * Create a new table. - * @param $tableName The name of the table - * @param $fields A map of field names to field types - * @param $indexes A map of indexes - * @param $options An map of additional options. The available keys are as follows: - * - 'MSSQLDatabase'/'MySQLDatabase'/'PostgreSQLDatabase' - database-specific options such as "engine" for MySQL. + * @param string $tableName The name of the table + * @param array$fields A map of field names to field types + * @param array $indexes A map of indexes + * @param array $options An map of additional options. The available keys are as follows: + * - 'MSSQLDatabase'/'MySQLDatabase'/'PostgreSQLDatabase' - database-specific options such as "engine" + * for MySQL. * - 'temporary' - If true, then a temporary table will be created - * @return The table name generated. This may be different from the table name, for example with temporary tables. + * @return string The table name generated. This may be different from the table name, for example with + * temporary tables. + */ + public static function create_table($table, $fields = null, $indexes = null, $options = null, + $advancedOptions = null + ) { + return self::get_schema()->createTable($table, $fields, $indexes, $options, $advancedOptions); + } + + /** + * @deprecated since version 3.3 Use DB::create_table instead */ public static function createTable($table, $fields = null, $indexes = null, $options = null) { - return self::getConn()->createTable($table, $fields, $indexes, $options); + Deprecation::notice('3.3', 'Use DB::create_table instead'); + return self::create_table($table, $fields, $indexes, $options); } /** @@ -304,115 +439,210 @@ class DB { * @param string $field Name of the field to add. * @param string $spec The field specification, eg 'INTEGER NOT NULL' */ + public static function create_field($table, $field, $spec) { + return self::get_schema()->createField($table, $field, $spec); + } + + /** + * @deprecated since version 3.3 Use DB::create_field instead + */ public static function createField($table, $field, $spec) { - return self::getConn()->createField($table, $field, $spec); + Deprecation::notice('3.3', 'Use DB::create_field instead'); + return self::create_field($table, $field, $spec); } /** * Generate the following table in the database, modifying whatever already exists * as necessary. + * * @param string $table The name of the table * @param string $fieldSchema A list of the fields to create, in the same form as DataObject::$db * @param string $indexSchema A list of indexes to create. The keys of the array are the names of the index. - * @param boolean $hasAutoIncPK A flag indicating that the primary key on this table is an autoincrement type * The values of the array can be one of: * - true: Create a single column index on the field named the same as the index. * - array('fields' => array('A','B','C'), 'type' => 'index/unique/fulltext'): This gives you full * control over the index. + * @param boolean $hasAutoIncPK A flag indicating that the primary key on this table is an autoincrement type * @param string $options SQL statement to append to the CREATE TABLE call. + * @param array $extensions List of extensions */ - public static function requireTable($table, $fieldSchema = null, $indexSchema = null, $hasAutoIncPK=true, - $options = null, $extensions=null) { - - return self::getConn()->requireTable($table, $fieldSchema, $indexSchema, $hasAutoIncPK, $options, $extensions); + public static function require_table($table, $fieldSchema = null, $indexSchema = null, $hasAutoIncPK = true, + $options = null, $extensions = null + ) { + return self::get_schema()->requireTable($table, $fieldSchema, $indexSchema, $hasAutoIncPK, $options, + $extensions); + } + + /** + * @deprecated since version 3.3 Use DB::require_table instead + */ + public static function requireTable($table, $fieldSchema = null, $indexSchema = null, $hasAutoIncPK = true, + $options = null, $extensions = null + ) { + Deprecation::notice('3.3', 'Use DB::require_table instead'); + return self::require_table($table, $fieldSchema, $indexSchema, $hasAutoIncPK, $options, $extensions); } /** * Generate the given field on the table, modifying whatever already exists as necessary. + * * @param string $table The table name. * @param string $field The field name. * @param string $spec The field specification. */ + public static function require_field($table, $field, $spec) { + return self::get_schema()->requireField($table, $field, $spec); + } + + /** + * @deprecated since version 3.3 Use DB::require_field instead + */ public static function requireField($table, $field, $spec) { - return self::getConn()->requireField($table, $field, $spec); + Deprecation::notice('3.3', 'Use DB::require_field instead'); + return self::require_field($table, $field, $spec); } /** * Generate the given index in the database, modifying whatever already exists as necessary. + * * @param string $table The table name. * @param string $index The index name. * @param string|boolean $spec The specification of the index. See requireTable() for more information. */ + public static function require_index($table, $index, $spec) { + self::get_schema()->requireIndex($table, $index, $spec); + } + + /** + * @deprecated since version 3.3 Use DB::require_index instead + */ public static function requireIndex($table, $index, $spec) { - return self::getConn()->requireIndex($table, $index, $spec); + Deprecation::notice('3.3', 'Use DB::require_index instead'); + self::require_index($table, $index, $spec); } /** * If the given table exists, move it out of the way by renaming it to _obsolete_(tablename). + * * @param string $table The table name. */ + public static function dont_require_table($table) { + self::get_schema()->dontRequireTable($table); + } + + /** + * @deprecated since version 3.3 Use DB::dont_require_table instead + */ public static function dontRequireTable($table) { - return self::getConn()->dontRequireTable($table); + Deprecation::notice('3.3', 'Use DB::dont_require_table instead'); + self::dont_require_table($table); } - + /** * See {@link SS_Database->dontRequireField()}. - * + * * @param string $table The table name. - * @param string $fieldName + * @param string $fieldName The field name not to require + */ + public static function dont_require_field($table, $fieldName) { + self::get_schema()->dontRequireField($table, $fieldName); + } + + /** + * @deprecated since version 3.3 Use DB::dont_require_field instead */ public static function dontRequireField($table, $fieldName) { - return self::getConn()->dontRequireField($table, $fieldName); + Deprecation::notice('3.3', 'Use DB::dont_require_field instead'); + self::dont_require_field($table, $fieldName); } /** * Checks a table's integrity and repairs it if necessary. - * @var string $tableName The name of the table. + * + * @param string $tableName The name of the table. * @return boolean Return true if the table has integrity after the method is complete. */ + public static function check_and_repair_table($table) { + return self::get_schema()->checkAndRepairTable($table); + } + + /** + * @deprecated since version 3.3 Use DB::check_and_repair_table instead + */ public static function checkAndRepairTable($table) { - return self::getConn()->checkAndRepairTable($table); + Deprecation::notice('3.3', 'Use DB::check_and_repair_table instead'); + self::check_and_repair_table($table); } /** * Return the number of rows affected by the previous operation. - * @return int + * + * @return integer The number of affected rows + */ + public static function affected_rows() { + return self::get_conn()->affectedRows(); + } + + /** + * @deprecated since version 3.3 Use DB::affected_rows instead */ public static function affectedRows() { - return self::getConn()->affectedRows(); + Deprecation::notice('3.3', 'Use DB::affected_rows instead'); + return self::affected_rows(); } /** * Returns a list of all tables in the database. * The table names will be in lower case. - * @return array + * + * @return array The list of tables + */ + public static function table_list() { + return self::get_schema()->tableList(); + } + + /** + * @deprecated since version 3.3 Use DB::table_list instead */ public static function tableList() { - return self::getConn()->tableList(); + Deprecation::notice('3.3', 'Use DB::table_list instead'); + return self::table_list(); } - + /** * Get a list of all the fields for the given table. * Returns a map of field name => field spec. + * * @param string $table The table name. - * @return array + * @return array The list of fields + */ + public static function field_list($table) { + return self::get_schema()->fieldList($table); + } + + /** + * @deprecated since version 3.3 Use DB::field_list instead */ public static function fieldList($table) { - return self::getConn()->fieldList($table); + Deprecation::notice('3.3', 'Use DB::field_list instead'); + return self::field_list($table); } /** * Enable supression of database messages. */ public static function quiet() { - return self::getConn()->quiet(); + self::get_schema()->quiet(); } - + /** - * Show a message about database alteration. + * Show a message about database alteration + * + * @param string $message to display + * @param string $type one of [created|changed|repaired|obsolete|deleted|error] */ - public static function alteration_message($message,$type="") { - return self::getConn()->alterationMessage($message, $type); + public static function alteration_message($message, $type = "") { + self::get_schema()->alterationMessage($message, $type); } - + } diff --git a/model/DataExtension.php b/model/DataExtension.php index 041630200..faafbb4d5 100644 --- a/model/DataExtension.php +++ b/model/DataExtension.php @@ -40,9 +40,9 @@ abstract class DataExtension extends Extension { /** * Edit the given query object to support queries for this extension * - * @param SQLQuery $query Query to augment. + * @param SQLSelect $query Query to augment. */ - public function augmentSQL(SQLQuery &$query) { + public function augmentSQL(SQLSelect $query) { } /** @@ -57,7 +57,7 @@ abstract class DataExtension extends Extension { /** * Augment a write-record request. * - * @param SQLQuery $manipulation Query to augment. + * @param array $manipulation Array of operations to augment. */ public function augmentWrite(&$manipulation) { } @@ -180,4 +180,3 @@ abstract class DataExtension extends Extension { } } - diff --git a/model/DataList.php b/model/DataList.php index 638dd3b3a..c23f8fc26 100644 --- a/model/DataList.php +++ b/model/DataList.php @@ -153,6 +153,13 @@ class DataList extends ViewableData implements SS_List, SS_Filterable, SS_Sortab return $clone; } + /** + * Returns a new DataList instance with the specified query parameter assigned + * + * @param string|array $keyOrArray Either the single key to set, or an array of key value pairs to set + * @param mixed $val If $keyOrArray is not an array, this is the value to set + * @return DataList + */ public function setDataQueryParam($keyOrArray, $val = null) { $clone = clone $this; @@ -171,16 +178,28 @@ class DataList extends ViewableData implements SS_List, SS_Filterable, SS_Sortab /** * Returns the SQL query that will be used to get this DataList's records. Good for debugging. :-) * - * @return SQLQuery + * @param array $parameters Out variable for parameters required for this query + * @param string The resulting SQL query (may be paramaterised) */ - public function sql() { - return $this->dataQuery->query()->sql(); + public function sql(&$parameters = array()) { + if(func_num_args() == 0) { + Deprecation::notice( + '3.2', + 'DataList::sql() now may produce parameters which are necessary to execute this query' + ); + } + return $this->dataQuery->query()->sql($parameters); } /** * Return a new DataList instance with a WHERE clause added to this list's query. + * + * Supports parameterised queries. + * See SQLSelect::addWhere() for syntax examples, although DataList + * won't expand multiple method arguments as SQLSelect does. * - * @param string $filter Escaped SQL statement + * @param string|array|SQLConditionGroup $filter Predicate(s) to set, as escaped SQL statements or + * paramaterised queries * @return DataList */ public function where($filter) { @@ -188,6 +207,26 @@ class DataList extends ViewableData implements SS_List, SS_Filterable, SS_Sortab $query->where($filter); }); } + + /** + * Return a new DataList instance with a WHERE clause added to this list's query. + * All conditions provided in the filter will be joined with an OR + * + * Supports parameterised queries. + * See SQLSelect::addWhere() for syntax examples, although DataList + * won't expand multiple method arguments as SQLSelect does. + * + * @param string|array|SQLConditionGroup $filter Predicate(s) to set, as escaped SQL statements or + * paramaterised queries + * @return DataList + */ + public function whereAny($filter) { + return $this->alterDataQuery(function($query) use ($filter){ + $query->whereAny($filter); + }); + } + + /** * Returns true if this DataList can be sorted by the given field. @@ -229,7 +268,7 @@ class DataList extends ViewableData implements SS_List, SS_Filterable, SS_Sortab * order set. * * @see SS_List::sort() - * @see SQLQuery::orderby + * @see SQLSelect::orderby * @example $list = $list->sort('Name'); // default ASC sorting * @example $list = $list->sort('Name DESC'); // DESC sorting * @example $list = $list->sort('Name', 'ASC'); @@ -310,7 +349,7 @@ class DataList extends ViewableData implements SS_List, SS_Filterable, SS_Sortab * * @todo extract the sql from $customQuery into a SQLGenerator class * - * @param string|array Key and Value pairs, the array values are automatically sanitised for the DB quesry + * @param string|array Escaped SQL statement. If passed as array, all keys and values will be escaped internally * @return DataList */ public function filter() { @@ -499,7 +538,7 @@ class DataList extends ViewableData implements SS_List, SS_Filterable, SS_Sortab * * @todo extract the sql from this method into a SQLGenerator class * - * @param string|array Escaped SQL statement. If passed as array, all keys and values are assumed to be escaped. + * @param string|array Escaped SQL statement. If passed as array, all keys and values will be escaped internally * @return DataList */ public function exclude() { @@ -527,13 +566,13 @@ class DataList extends ViewableData implements SS_List, SS_Filterable, SS_Sortab $t = singleton($list->dataClass())->dbObject($field); if($filterType) { $className = "{$filterType}Filter"; - } else { + } else { $className = 'ExactMatchFilter'; - } + } if(!class_exists($className)){ $className = 'ExactMatchFilter'; array_unshift($modifiers, $filterType); - } + } $t = new $className($field, $value, $modifiers); $t->exclude($subquery); } @@ -608,7 +647,7 @@ class DataList extends ViewableData implements SS_List, SS_Filterable, SS_Sortab /** * Return this list as an array and every object it as an sub array as well * - * @return type + * @return array */ public function toNestedArray() { $result = array(); @@ -793,14 +832,7 @@ class DataList extends ViewableData implements SS_List, SS_Filterable, SS_Sortab * @return DataObject|null */ public function find($key, $value) { - if($key == 'ID') { - $baseClass = ClassInfo::baseDataClass($this->dataClass); - $SQL_col = sprintf('"%s"."%s"', $baseClass, Convert::raw2sql($key)); - } else { - $SQL_col = sprintf('"%s"', Convert::raw2sql($key)); - } - - return $this->where("$SQL_col = '" . Convert::raw2sql($value) . "'")->First(); + return $this->filter($key, $value)->first(); } /** @@ -818,13 +850,11 @@ class DataList extends ViewableData implements SS_List, SS_Filterable, SS_Sortab /** * Filter this list to only contain the given Primary IDs * - * @param array $ids Array of integers, will be automatically cast/escaped. + * @param array $ids Array of integers * @return DataList */ public function byIDs(array $ids) { - $ids = array_map('intval', $ids); // sanitize - $baseClass = ClassInfo::baseDataClass($this->dataClass); - return $this->where("\"$baseClass\".\"ID\" IN (" . implode(',', $ids) .")"); + return $this->filter('ID', $ids); } /** @@ -834,8 +864,7 @@ class DataList extends ViewableData implements SS_List, SS_Filterable, SS_Sortab * @return DataObject */ public function byID($id) { - $baseClass = ClassInfo::baseDataClass($this->dataClass); - return $this->where("\"$baseClass\".\"ID\" = " . (int)$id)->First(); + return $this->filter('ID', $id)->first(); } /** @@ -965,7 +994,7 @@ class DataList extends ViewableData implements SS_List, SS_Filterable, SS_Sortab * This method are overloaded by HasManyList and ManyMany list to perform more sophisticated * list manipulation * - * @param type $item + * @param mixed $item */ public function add($item) { // Nothing needs to happen by default diff --git a/model/DataObject.php b/model/DataObject.php index b19e49b7c..4b11995ee 100644 --- a/model/DataObject.php +++ b/model/DataObject.php @@ -5,16 +5,16 @@ *

    Extensions

    * * See {@link Extension} and {@link DataExtension}. - * + * *

    Permission Control

    - * + * * Object-level access control by {@link Permission}. Permission codes are arbitrary * strings which can be selected on a group-by-group basis. - * + * * * class Article extends DataObject implements PermissionProvider { * static $api_access = true; - * + * * function canView($member = false) { * return Permission::check('ARTICLE_VIEW'); * } @@ -36,13 +36,13 @@ * ); * } * } - * + * * - * Object-level access control by {@link Group} membership: + * Object-level access control by {@link Group} membership: * * class Article extends DataObject { * static $api_access = true; - * + * * function canView($member = false) { * if(!$member) $member = Member::currentUser(); * return $member->inGroup('Subscribers'); @@ -51,18 +51,18 @@ * if(!$member) $member = Member::currentUser(); * return $member->inGroup('Editors'); * } - * + * * // ... * } * - * - * If any public method on this class is prefixed with an underscore, + * + * If any public method on this class is prefixed with an underscore, * the results are cached in memory through {@link cachedCall()}. - * - * + * + * * @todo Add instance specific removeExtension() which undos loadExtraStatics() * and defineMethods() - * + * * @package framework * @subpackage model * @@ -72,21 +72,21 @@ * @property string Created Date and time of DataObject creation. */ class DataObject extends ViewableData implements DataObjectInterface, i18nEntityProvider { - + /** * Human-readable singular name. * @var string * @config */ private static $singular_name = null; - + /** * Human-readable pluaral name * @var string * @config */ private static $plural_name = null; - + /** * Allow API access to this object? * @todo Define the options that can be set here @@ -99,27 +99,27 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * @var boolean */ public $destroyed = false; - + /** * The DataModel from this this object comes */ protected $model; - + /** - * Data stored in this objects database record. An array indexed by fieldname. - * + * Data stored in this objects database record. An array indexed by fieldname. + * * Use {@link toMap()} if you want an array representation * of this object, as the $record array might contain lazy loaded field aliases. - * + * * @var array */ protected $record; - + /** * Represents a field that hasn't changed (before === after, thus before == after) */ const CHANGE_NONE = 0; - + /** * Represents a field that has changed type, although not the loosely defined value. * (before !== after && before == after) @@ -127,19 +127,19 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * Value changes are by nature also considered strict changes. */ const CHANGE_STRICT = 1; - + /** * Represents a field that has changed the loosely defined value * (before != after, thus, before !== after)) * E.g. change false to true, but not false to 0 */ const CHANGE_VALUE = 2; - + /** * An array indexed by fieldname, true if the field has been changed. * Use {@link getChangedFields()} and {@link isChanged()} to inspect * the changed state. - * + * * @var array */ private $changed; @@ -156,13 +156,13 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * @var boolean */ protected $brokenOnDelete = false; - + /** * Used by onBeforeWrite() to ensure child classes call parent::onBeforeWrite() * @var boolean */ protected $brokenOnWrite = false; - + /** * @config * @var boolean Should dataobjects be validated before they are written? @@ -176,7 +176,6 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * Static caches used by relevant functions. */ public static $cache_has_own_table = array(); - public static $cache_has_own_table_field = array(); protected static $_cache_db = array(); protected static $_cache_get_one; protected static $_cache_get_class_ancestry; @@ -204,7 +203,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity /** * Returns when validation on DataObjects is enabled. - * + * * @deprecated 3.2 Use the "DataObject.validation_enabled" config setting instead * @return bool */ @@ -212,14 +211,14 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity Deprecation::notice('3.2', 'Use the "DataObject.validation_enabled" config setting instead'); return Config::inst()->get('DataObject', 'validation_enabled'); } - + /** * Set whether DataObjects should be validated before they are written. - * + * * Caution: Validation can contain safeguards against invalid/malicious data, * and check permission levels (e.g. on {@link Group}). Therefore it is recommended * to only disable validation for very specific use cases. - * + * * @param $enable bool * @see DataObject::validate() * @deprecated 3.2 Use the "DataObject.validation_enabled" config setting instead @@ -233,13 +232,41 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * @var [string] - class => ClassName field definition cache for self::database_fields */ private static $classname_spec_cache = array(); - + /** * Clear all cached classname specs. It's necessary to clear all cached subclassed names * for any classes if a new class manifest is generated. */ public static function clear_classname_spec_cache() { self::$classname_spec_cache = array(); + PolymorphicForeignKey::clear_classname_spec_cache(); + } + + /** + * Determines the specification for the ClassName field for the given class + * + * @param string $class + * @param boolean $queryDB Determine if the DB may be queried for additional information + * @return string Resulting ClassName spec. If $queryDB is true this will include all + * legacy types that no longer have concrete classes in PHP + */ + public static function get_classname_spec($class, $queryDB = true) { + // Check cache + if(!empty(self::$classname_spec_cache[$class])) return self::$classname_spec_cache[$class]; + + // Build known class names + $classNames = ClassInfo::subclassesFor($class); + + // Enhance with existing classes in order to prevent legacy details being lost + if($queryDB && DB::get_schema()->hasField($class, 'ClassName')) { + $existing = DB::query("SELECT DISTINCT \"ClassName\" FROM \"{$class}\"")->column(); + $classNames = array_unique(array_merge($classNames, $existing)); + } + $spec = "Enum('" . implode(', ', $classNames) . "')"; + + // Only cache full information if queried + if($queryDB) self::$classname_spec_cache[$class] = $spec; + return $spec; } /** @@ -247,30 +274,17 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * See {@link custom_database_fields()} for a getter that excludes these "base fields". * * @param string $class + * @param boolean $queryDB Determine if the DB may be queried for additional information * @return array */ - public static function database_fields($class) { + public static function database_fields($class, $queryDB = true) { if(get_parent_class($class) == 'DataObject') { - if(empty(self::$classname_spec_cache[$class])) { - $classNames = ClassInfo::subclassesFor($class); - - $db = DB::getConn(); - if($db->hasField($class, 'ClassName')) { - $existing = $db->query("SELECT DISTINCT \"ClassName\" FROM \"$class\"")->column(); - $classNames = array_unique(array_merge($classNames, $existing)); - } - - self::$classname_spec_cache[$class] = "Enum('" . implode(', ', $classNames) . "')"; - } - - return array_merge ( - // TODO: should this be using self::$fixed_fields? only difference is ID field - // and ClassName creates an Enum with all values - array ( - 'ClassName' => self::$classname_spec_cache[$class], - 'Created' => 'SS_Datetime', - 'LastEdited' => 'SS_Datetime' - ), + // Merge fixed with ClassName spec and custom db fields + $fixed = self::$fixed_fields; + unset($fixed['ID']); + return array_merge( + $fixed, + array('ClassName' => self::get_classname_spec($class, $queryDB)), self::custom_database_fields($class) ); } @@ -279,14 +293,14 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity } /** - * Get all database columns explicitly defined on a class in {@link DataObject::$db} - * and {@link DataObject::$has_one}. Resolves instances of {@link CompositeDBField} - * into the actual database fields, rather than the name of the field which + * Get all database columns explicitly defined on a class in {@link DataObject::$db} + * and {@link DataObject::$has_one}. Resolves instances of {@link CompositeDBField} + * into the actual database fields, rather than the name of the field which * might not equate a database column. - * + * * Does not include "base fields" like "ID", "ClassName", "Created", "LastEdited", * see {@link database_fields()}. - * + * * @uses CompositeDBField->compositeDatabaseFields() * * @param string $class @@ -302,18 +316,18 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity foreach(self::composite_fields($class, false) as $fieldName => $fieldClass) { // Remove the original fieldname, it's not an actual database column unset($fields[$fieldName]); - + // Add all composite columns $compositeFields = singleton($fieldClass)->compositeDatabaseFields(); if($compositeFields) foreach($compositeFields as $compositeName => $spec) { $fields["{$fieldName}{$compositeName}"] = $spec; } } - + // Add has_one relationships $hasOne = Config::inst()->get($class, 'has_one', Config::UNINHERITED); if($hasOne) foreach(array_keys($hasOne) as $field) { - + // Check if this is a polymorphic relation, in which case the relation // is a composite field if($hasOne[$field] === 'DataObject') { @@ -335,17 +349,22 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity return $output; } - + /** * Returns the field class if the given db field on the class is a composite field. * Will check all applicable ancestor classes and aggregate results. + * + * @param string $class Class to check + * @param string $name Field to check + * @param boolean $aggregated True if parent classes should be checked, or false to limit to this class + * @return string Class name of composite field if it exists */ public static function is_composite_field($class, $name, $aggregated = true) { if(!isset(DataObject::$_cache_composite_fields[$class])) self::cache_composite_fields($class); - + if(isset(DataObject::$_cache_composite_fields[$class][$name])) { return DataObject::$_cache_composite_fields[$class][$name]; - + } else if($aggregated && $class != 'DataObject' && ($parentClass=get_parent_class($class)) != 'DataObject') { return self::is_composite_field($parentClass, $name); } @@ -357,14 +376,14 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity */ public static function composite_fields($class, $aggregated = true) { if(!isset(DataObject::$_cache_composite_fields[$class])) self::cache_composite_fields($class); - + $compositeFields = DataObject::$_cache_composite_fields[$class]; - + if($aggregated && $class != 'DataObject' && ($parentClass=get_parent_class($class)) != 'DataObject') { - $compositeFields = array_merge($compositeFields, + $compositeFields = array_merge($compositeFields, self::composite_fields($parentClass)); } - + return $compositeFields; } @@ -373,7 +392,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity */ private static function cache_composite_fields($class) { $compositeFields = array(); - + $fields = Config::inst()->get($class, 'db', Config::UNINHERITED); if($fields) foreach($fields as $fieldName => $fieldClass) { if(!is_string($fieldClass)) continue; @@ -381,16 +400,16 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity // Strip off any parameters $bPos = strpos('(', $fieldClass); if($bPos !== FALSE) $fieldClass = substr(0,$bPos, $fieldClass); - + // Test to see if it implements CompositeDBField if(ClassInfo::classImplements($fieldClass, 'CompositeDBField')) { $compositeFields[$fieldName] = $fieldClass; } } - + DataObject::$_cache_composite_fields[$class] = $compositeFields; } - + /** * Construct a new DataObject. * @@ -465,7 +484,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity // prevent populateDefaults() and setField() from marking overwritten defaults as changed $this->changed = array(); } - + /** * Set the DataModel * @param DataModel $model @@ -498,14 +517,14 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity $className = $this->class; $clone = new $className( $this->toMap(), false, $this->model ); $clone->ID = 0; - + $clone->invokeWithExtensions('onBeforeDuplicate', $this, $doWrite); if($doWrite) { $clone->write(); $this->duplicateManyManyRelations($this, $clone); } $clone->invokeWithExtensions('onAfterDuplicate', $this, $doWrite); - + return $clone; } @@ -543,7 +562,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * Helper function to duplicate relations from one object to another * @param $sourceObject the source object to duplicate from * @param $destinationObject the destination object to populate with the duplicated relations - * @param $name the name of the relation to duplicate (e.g. members) + * @param $name the name of the relation to duplicate (e.g. members) */ private function duplicateRelations($sourceObject, $destinationObject, $name) { $relations = $sourceObject->$name(); @@ -570,7 +589,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity if (!ClassInfo::exists($className)) return get_class($this); return $className; } - + /** * Set the ClassName attribute. {@link $class} is also updated. * Warning: This will produce an inconsistent record, as the object @@ -596,7 +615,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * it ensures that the instance of the class is a match for the className of the * record. Don't set the {@link DataObject->class} or {@link DataObject->ClassName} * property manually before calling this method, as it will confuse change detection. - * + * * If the new class is different to the original class, defaults are populated again * because this will only occur automatically on instantiation of a DataObject if * there is no record, or the record has no ID. In this case, we do have an ID but @@ -615,7 +634,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity 'RecordClassName' => $originalClass, ) ), false, $this->model); - + if($newClassName != $originalClass) { $newInstance->setClassName($newClassName); $newInstance->populateDefaults(); @@ -684,9 +703,9 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * Returns TRUE if all values (other than "ID") are * considered empty (by weak boolean comparison). * Only checks for fields listed in {@link custom_database_fields()} - * + * * @todo Use DBField->hasValue() - * + * * @return boolean */ public function isEmpty(){ @@ -696,7 +715,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity foreach($map as $k=>$v){ // only look at custom fields if(!array_key_exists($k, $customFields)) continue; - + $dbObj = ($v instanceof DBField) ? $v : $this->dbObject($k); $isEmpty = ($isEmpty && !$dbObj->exists()); } @@ -715,7 +734,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity if(!$name = $this->stat('singular_name')) { $name = ucwords(trim(strtolower(preg_replace('/_?([A-Z])/', ' $1', $this->class)))); } - + return $name; } @@ -768,14 +787,14 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity $name = $this->plural_name(); return _t($this->class.'.PLURALNAME', $name); } - + /** * Standard implementation of a title/label for a specific * record. Tries to find properties 'Title' or 'Name', * and falls back to the 'ID'. Useful to provide * user-friendly identification of a record, e.g. in errormessages * or UI-selections. - * + * * Overload this method to have a more specialized implementation, * e.g. for an Address record this could be: * @@ -789,7 +808,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity public function getTitle() { if($this->hasDatabaseField('Title')) return $this->getField('Title'); if($this->hasDatabaseField('Name')) return $this->getField('Name'); - + return "#{$this->ID}"; } @@ -827,11 +846,11 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity /** * Update a number of fields on this object, given a map of the desired changes. - * + * * The field names can be simple names, or you can use a dot syntax to access $has_one relations. * For example, array("Author.FirstName" => "Jim") will set $this->Author()->FirstName to "Jim". - * - * update() doesn't write the main object, but if you use the dot syntax, it will write() + * + * update() doesn't write the main object, but if you use the dot syntax, it will write() * the related objects that it alters. * * @param array $data A map of field name to data values to update. @@ -859,8 +878,8 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity } } else { user_error( - "DataObject::update(): Can't traverse relationship '$relation'," . - "it has to be a has_one relationship or return a single DataObject", + "DataObject::update(): Can't traverse relationship '$relation'," . + "it has to be a has_one relationship or return a single DataObject", E_USER_NOTICE ); // unset relation object so we don't write properties to the wrong object @@ -884,7 +903,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity } return $this; } - + /** * Pass changes as a map, and try to * get automatic casting for these fields. @@ -996,7 +1015,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * Forces the record to think that all its data has changed. * Doesn't write to the database. Only sets fields as changed * if they are not already marked as changed. - * + * * @return DataObject $this */ public function forceChange() { @@ -1005,32 +1024,32 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity // $this->record might not contain the blank values so we loop on $this->inheritedDatabaseFields() as well $fieldNames = array_unique(array_merge( - array_keys($this->record), + array_keys($this->record), array_keys($this->inheritedDatabaseFields()))); - + foreach($fieldNames as $fieldName) { if(!isset($this->changed[$fieldName])) $this->changed[$fieldName] = self::CHANGE_STRICT; // Populate the null values in record so that they actually get written if(!isset($this->record[$fieldName])) $this->record[$fieldName] = null; } - + // @todo Find better way to allow versioned to write a new version after forceChange if($this->isChanged('Version')) unset($this->changed['Version']); return $this; } - + /** * Validate the current object. * * By default, there is no validation - objects are always valid! However, you can overload this method in your * DataObject sub-classes to specify custom validation, or use the hook through DataExtension. - * + * * Invalid objects won't be able to be written - a warning will be thrown and no write will occur. onBeforeWrite() * and onAfterWrite() won't get called either. - * + * * It is expected that you call validate() in your own application to test that an object is valid before * attempting a write, and respond appropriately if it isn't. - * + * * @return A {@link ValidationResult} object */ public function validate() { @@ -1045,12 +1064,12 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * database. Don't forget to call parent::onBeforeWrite(), though! * * This called after {@link $this->validate()}, so you can be sure that your data is valid. - * + * * @uses DataExtension->onBeforeWrite() */ protected function onBeforeWrite() { $this->brokenOnWrite = false; - + $dummy = null; $this->extend('onBeforeWrite', $dummy); } @@ -1077,11 +1096,11 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity */ protected function onBeforeDelete() { $this->brokenOnDelete = false; - + $dummy = null; $this->extend('onBeforeDelete', $dummy); } - + protected function onAfterDelete() { $this->extend('onAfterDelete'); } @@ -1090,22 +1109,22 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * Load the default values in from the self::$defaults array. * Will traverse the defaults of the current class and all its parent classes. * Called by the constructor when creating new records. - * + * * @uses DataExtension->populateDefaults() * @return DataObject $this */ public function populateDefaults() { $classes = array_reverse(ClassInfo::ancestry($this)); - + foreach($classes as $class) { $defaults = Config::inst()->get($class, 'defaults', Config::UNINHERITED); - + if($defaults && !is_array($defaults)) { user_error("Bad '$this->class' defaults given: " . var_export($defaults, true), E_USER_WARNING); $defaults = null; } - + if($defaults) foreach($defaults as $fieldName => $fieldValue) { // SRM 2007-03-06: Stricter check if(!isset($this->$fieldName) || $this->$fieldName === null) { @@ -1121,18 +1140,194 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity break; } } - + $this->extend('populateDefaults'); return $this; } + /** + * Determine validation of this object prior to write + * + * @return ValidationException Exception generated by this write, or null if valid + */ + protected function validateWrite() { + if ($this->ObsoleteClassName) { + return new ValidationException( + "Object is of class '{$this->ObsoleteClassName}' which doesn't exist - ". + "you need to change the ClassName before you can write it", + E_USER_WARNING + ); + } + + if(Config::inst()->get('DataObject', 'validation_enabled')) { + $result = $this->validate(); + if (!$result->valid()) { + return new ValidationException( + $result, + $result->message(), + E_USER_WARNING + ); + } + } + } + + /** + * Prepare an object prior to write + * + * @throws ValidationException + */ + protected function preWrite() { + // Validate this object + if($writeException = $this->validateWrite()) { + // Used by DODs to clean up after themselves, eg, Versioned + $this->invokeWithExtensions('onAfterSkippedWrite'); + throw $writeException; + } + + // Check onBeforeWrite + $this->brokenOnWrite = true; + $this->onBeforeWrite(); + if($this->brokenOnWrite) { + user_error("$this->class has a broken onBeforeWrite() function." + . " Make sure that you call parent::onBeforeWrite().", E_USER_ERROR); + } + } + + /** + * Detects and updates all changes made to this object + * + * @param bool $forceChanges If set to true, force all fields to be treated as changed + * @return bool True if any changes are detected + */ + protected function updateChanges($forceChanges = false) { + // Update the changed array with references to changed obj-fields + foreach($this->record as $field => $value) { + // Only mark ID as changed if $forceChanges + if($field === 'ID' && !$forceChanges) continue; + // Determine if this field should be forced, or can mark itself, changed + if($forceChanges + || !$this->isInDB() + || (is_object($value) && method_exists($value, 'isChanged') && $value->isChanged()) + ) { + $this->changed[$field] = self::CHANGE_VALUE; + } + } + + // Check changes exist, abort if there are no changes + return $this->changed && (bool)array_filter($this->changed); + } + + /** + * Writes a subset of changes for a specific table to the given manipulation + * + * @param string $baseTable Base table + * @param string $now Timestamp to use for the current time + * @param bool $isNewRecord Whether this should be treated as a new record write + * @param array $manipulation Manipulation to write to + * @param string $class Table and Class to select and write to + */ + protected function prepareManipulationTable($baseTable, $now, $isNewRecord, &$manipulation, $class) { + $manipulation[$class] = array(); + + // Extract records for this table + foreach($this->record as $fieldName => $fieldValue) { + + // Check if this record pertains to this table, and + // we're not attempting to reset the BaseTable->ID + if( empty($this->changed[$fieldName]) + || ($class === $baseTable && $fieldName === 'ID') + || (!self::has_own_table_database_field($class, $fieldName) + && !self::is_composite_field($class, $fieldName, false)) + ) { + continue; + } + + // if database column doesn't correlate to a DBField instance... + $fieldObj = $this->dbObject($fieldName); + if(!$fieldObj) { + $fieldObj = DBField::create_field('Varchar', $fieldValue, $fieldName); + } + + // Ensure DBField is repopulated and written to the manipulation + $fieldObj->setValue($fieldValue, $this->record); + $fieldObj->writeToManipulation($manipulation[$class]); + } + + // Ensure update of Created and LastEdited columns + if($baseTable === $class) { + $manipulation[$class]['fields']['LastEdited'] = $now; + if($isNewRecord) { + $manipulation[$class]['fields']['Created'] + = empty($this->record['Created']) + ? $now + : $this->record['Created']; + $manipulation[$class]['fields']['ClassName'] = $this->class; + } + } + + // Inserts done one the base table are performed in another step, so the manipulation should instead + // attempt an update, as though it were a normal update. + $manipulation[$class]['command'] = $isNewRecord ? 'insert' : 'update'; + $manipulation[$class]['id'] = $this->record['ID']; + } + + /** + * Ensures that a blank base record exists with the basic fixed fields for this dataobject + * + * Does nothing if an ID is already assigned for this record + * + * @param string $baseTable Base table + * @param string $now Timestamp to use for the current time + */ + protected function writeBaseRecord($baseTable, $now) { + // Generate new ID if not specified + if($this->isInDB()) return; + + // Perform an insert on the base table + $insert = new SQLInsert('"'.$baseTable.'"'); + $insert + ->assign('"Created"', $now) + ->execute(); + $this->changed['ID'] = self::CHANGE_VALUE; + $this->record['ID'] = DB::get_generated_id($baseTable); + } + + /** + * Generate and write the database manipulation for all changed fields + * + * @param string $baseTable Base table + * @param string $now Timestamp to use for the current time + * @param bool $isNewRecord If this is a new record + */ + protected function writeManipulation($baseTable, $now, $isNewRecord) { + // Generate database manipulations for each class + $manipulation = array(); + foreach($this->getClassAncestry() as $class) { + if(self::has_own_table($class)) { + $this->prepareManipulationTable($baseTable, $now, $isNewRecord, $manipulation, $class); + } + } + + // Allow extensions to extend this manipulation + $this->extend('augmentWrite', $manipulation); + + // New records have their insert into the base data table done first, so that they can pass the + // generated ID on to the rest of the manipulation + if($isNewRecord) { + $manipulation[$baseTable]['command'] = 'update'; + } + + // Perform the manipulation + DB::manipulate($manipulation); + } + /** * Writes all changes to this object to the database. * - It will insert a record whenever ID isn't set, otherwise update. * - All relevant tables will be updated. * - $this->onBeforeWrite() gets called beforehand. * - Extensions such as Versioned will ammend the database-write to ensure that a version is saved. - * + * * @uses DataExtension->augmentWrite() * * @param boolean $showDebug Show debugging information @@ -1145,198 +1340,66 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * @throws ValidationException Exception that can be caught and handled by the calling function */ public function write($showDebug = false, $forceInsert = false, $forceWrite = false, $writeComponents = false) { - $firstWrite = false; - $this->brokenOnWrite = true; - $isNewRecord = false; + $now = SS_Datetime::now()->Rfc2822(); - $writeException = null; + // Execute pre-write tasks + $this->preWrite(); - if ($this->ObsoleteClassName) { - $writeException = new ValidationException( - "Object is of class '{$this->ObsoleteClassName}' which doesn't exist - ". - "you need to change the ClassName before you can write it", - E_USER_WARNING - ); - } - else if(Config::inst()->get('DataObject', 'validation_enabled')) { - $valid = $this->validate(); - if (!$valid->valid()) { - $writeException = new ValidationException( - $valid, - $valid->message(), - E_USER_WARNING - ); - } - } + // Check if we are doing an update or an insert + $isNewRecord = !$this->isInDB() || $forceInsert; - if($writeException) { - // Used by DODs to clean up after themselves, eg, Versioned - $this->invokeWithExtensions('onAfterSkippedWrite'); - throw $writeException; - return false; - } + // Check changes exist, abort if there are none + $hasChanges = $this->updateChanges($forceInsert); + if($hasChanges || $forceWrite || $isNewRecord) { + // New records have their insert into the base data table done first, so that they can pass the + // generated primary key on to the rest of the manipulation + $baseTable = ClassInfo::baseDataClass($this->class); + $this->writeBaseRecord($baseTable, $now); - $this->onBeforeWrite(); - if($this->brokenOnWrite) { - user_error("$this->class has a broken onBeforeWrite() function." - . " Make sure that you call parent::onBeforeWrite().", E_USER_ERROR); - } - - // New record = everything has changed - - if(($this->ID && is_numeric($this->ID)) && !$forceInsert) { - $dbCommand = 'update'; - - // Update the changed array with references to changed obj-fields - foreach($this->record as $k => $v) { - if(is_object($v) && method_exists($v, 'isChanged') && $v->isChanged()) { - $this->changed[$k] = self::CHANGE_VALUE; - } - } - - } else{ - $dbCommand = 'insert'; + // Write the DB manipulation for all changed fields + $this->writeManipulation($baseTable, $now, $isNewRecord); + // If there's any relations that couldn't be saved before, save them now (we have an ID here) + $this->writeRelations(); + $this->onAfterWrite(); $this->changed = array(); - foreach($this->record as $k => $v) { - $this->changed[$k] = self::CHANGE_VALUE; - } - - $firstWrite = true; - } - - // No changes made - if($this->changed || $forceWrite) { - foreach($this->getClassAncestry() as $ancestor) { - if(self::has_own_table($ancestor)) - $ancestry[] = $ancestor; - } - - // Look for some changes to make - if(!$forceInsert) unset($this->changed['ID']); - - $hasChanges = false; - if (!$forceWrite) { - foreach ($this->changed as $fieldName => $changed) { - if ($changed) { - $hasChanges = true; - break; - } - } - } - if($hasChanges || $forceWrite || !$this->record['ID']) { - - // New records have their insert into the base data table done first, so that they can pass the - // generated primary key on to the rest of the manipulation - $baseTable = $ancestry[0]; - - if((!isset($this->record['ID']) || !$this->record['ID']) && isset($ancestry[0])) { - - DB::query("INSERT INTO \"{$baseTable}\" (\"Created\") VALUES (" . DB::getConn()->now() . ")"); - $this->record['ID'] = DB::getGeneratedID($baseTable); - $this->changed['ID'] = self::CHANGE_VALUE; - - $isNewRecord = true; - } - - // Divvy up field saving into a number of database manipulations - $manipulation = array(); - if(isset($ancestry) && is_array($ancestry)) { - foreach($ancestry as $idx => $class) { - $classSingleton = singleton($class); - - foreach($this->record as $fieldName => $fieldValue) { - if(isset($this->changed[$fieldName]) && $this->changed[$fieldName] - && $fieldType = $classSingleton->hasOwnTableDatabaseField($fieldName)) { - - $fieldObj = $this->dbObject($fieldName); - if(!isset($manipulation[$class])) $manipulation[$class] = array(); - - // if database column doesn't correlate to a DBField instance... - if(!$fieldObj) { - $fieldObj = DBField::create_field('Varchar', $this->record[$fieldName], $fieldName); - } - - // Both CompositeDBFields and regular fields need to be repopulated - $fieldObj->setValue($this->record[$fieldName], $this->record); - - if($class != $baseTable || $fieldName!='ID') - $fieldObj->writeToManipulation($manipulation[$class]); - } - } - - // Add the class name to the base object - if($idx == 0) { - $manipulation[$class]['fields']["LastEdited"] = "'".SS_Datetime::now()->Rfc2822()."'"; - if($dbCommand == 'insert') { - if(!empty($this->record["Created"])) { - $manipulation[$class]['fields']["Created"] - = DB::getConn()->prepStringForDB($this->record["Created"]); - } else { - $manipulation[$class]['fields']["Created"] - = DB::getConn()->prepStringForDB(SS_Datetime::now()->Rfc2822()); - } - //echo "
  • $this->class - " .get_class($this); - $manipulation[$class]['fields']["ClassName"] - = DB::getConn()->prepStringForDB($this->class); - } - } - - // In cases where there are no fields, this 'stub' will get picked up on - if(self::has_own_table($class)) { - $manipulation[$class]['command'] = $dbCommand; - $manipulation[$class]['id'] = $this->record['ID']; - } else { - unset($manipulation[$class]); - } - } - } - $this->extend('augmentWrite', $manipulation); - - // New records have their insert into the base data table done first, so that they can pass the - // generated ID on to the rest of the manipulation - if(isset($isNewRecord) && $isNewRecord && isset($manipulation[$baseTable])) { - $manipulation[$baseTable]['command'] = 'update'; - } - - DB::manipulate($manipulation); - - // If there's any relations that couldn't be saved before, save them now (we have an ID here) - if($this->unsavedRelations) { - foreach($this->unsavedRelations as $name => $list) { - $list->changeToList($this->$name()); - } - $this->unsavedRelations = array(); - } - - $this->onAfterWrite(); - - $this->changed = null; - } elseif ( $showDebug ) { - echo "Debug: no changes for DataObject
    "; - // Used by DODs to clean up after themselves, eg, Versioned - $this->invokeWithExtensions('onAfterSkippedWrite'); - } - - // Clears the cache for this object so get_one returns the correct object. - $this->flushCache(); - - if(!isset($this->record['Created'])) { - $this->record['Created'] = SS_Datetime::now()->Rfc2822(); - } - $this->record['LastEdited'] = SS_Datetime::now()->Rfc2822(); } else { + if($showDebug) Debug::message("no changes for DataObject"); + // Used by DODs to clean up after themselves, eg, Versioned $this->invokeWithExtensions('onAfterSkippedWrite'); } + // Ensure Created and LastEdited are populated + if(!isset($this->record['Created'])) { + $this->record['Created'] = $now; + } + $this->record['LastEdited'] = $now; + // Write relations as necessary - if($writeComponents) { - $this->writeComponents(true); - } + if($writeComponents) $this->writeComponents(true); + + // Clears the cache for this object so get_one returns the correct object. + $this->flushCache(); + return $this->record['ID']; } + /** + * Writes cached relation lists to the database, if possible + */ + public function writeRelations() { + if(!$this->isInDB()) return; + + // If there's any relations that couldn't be saved before, save them now (we have an ID here) + if($this->unsavedRelations) { + foreach($this->unsavedRelations as $name => $list) { + $list->changeToList($this->$name()); + } + $this->unsavedRelations = array(); + } + } + /** * Write the cached components to the database. Cached components could refer to two different instances of the * same record. @@ -1346,13 +1409,13 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity */ public function writeComponents($recursive = false) { if(!$this->components) return $this; - + foreach($this->components as $component) { $component->write(false, false, false, $recursive); } return $this; } - + /** * Delete this data object. * $this->onBeforeDelete() gets called. @@ -1366,7 +1429,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity user_error("$this->class has a broken onBeforeDelete() function." . " Make sure that you call parent::onBeforeDelete().", E_USER_ERROR); } - + // Deleting a record without an ID shouldn't do anything if(!$this->ID) throw new LogicException("DataObject::delete() called on a DataObject without an ID"); @@ -1376,14 +1439,12 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity // obviously, that means getting requireTable() to configure cascading deletes ;-) $srcQuery = DataList::create($this->class, $this->model)->where("ID = $this->ID")->dataQuery()->query(); foreach($srcQuery->queriedTables() as $table) { - $query = new SQLQuery("*", array('"' . $table . '"')); - $query->setWhere("\"ID\" = $this->ID"); - $query->setDelete(true); - $query->execute(); + $delete = new SQLDelete("\"$table\"", array('"ID"' => $this->ID)); + $delete->execute(); } // Remove this item out of any caches $this->flushCache(); - + $this->onAfterDelete(); $this->OldID = $this->ID; @@ -1436,17 +1497,17 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity if(isset($this->components[$componentName])) { return $this->components[$componentName]; } - + if($class = $this->has_one($componentName)) { $joinField = $componentName . 'ID'; $joinID = $this->getField($joinField); - + // Extract class name for polymorphic relations if($class === 'DataObject') { $class = $this->getField($componentName . 'Class'); if(empty($class)) return null; } - + if($joinID) { $component = $this->model->$class->byID($joinID); } @@ -1455,18 +1516,23 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity $component = $this->model->$class->newObject(); } } elseif($class = $this->belongs_to($componentName)) { - + $joinField = $this->getRemoteJoinField($componentName, 'belongs_to', $polymorphic); $joinID = $this->ID; - + if($joinID) { + $filter = $polymorphic - ? "\"{$joinField}ID\" = '".Convert::raw2sql($joinID)."' AND - \"{$joinField}Class\" = '".Convert::raw2sql($this->class)."'" - : "\"{$joinField}\" = '".Convert::raw2sql($joinID)."'"; - $component = DataObject::get_one($class, $filter); + ? array( + "{$joinField}ID" => $joinID, + "{$joinField}Class" => $this->class + ) + : array( + $joinField => $joinID + ); + $component = DataObject::get($class)->filter($filter)->first(); } - + if(empty($component)) { $component = $this->model->$class->newObject(); if($polymorphic) { @@ -1479,7 +1545,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity } else { throw new Exception("DataObject->getComponent(): Could not find component '$componentName'."); } - + $this->components[$componentName] = $component; return $component; } @@ -1514,13 +1580,13 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity if(!$this->ID) { if(!isset($this->unsavedRelations[$componentName])) { $this->unsavedRelations[$componentName] = - new UnsavedRelationList($this->class, $componentName, $componentClass); + new UnsavedRelationList($this->class, $componentName, $componentClass); } return $this->unsavedRelations[$componentName]; } // Determine type and nature of foreign relation - $joinField = $this->getRemoteJoinField($componentName, 'has_many', $polymorphic); + $joinField = $this->getRemoteJoinField($componentName, 'has_many', $polymorphic); if($polymorphic) { $result = PolymorphicHasManyList::create($componentClass, $joinField, $this->class); } else { @@ -1578,7 +1644,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity /** * Tries to find the database key on another object that is used to store a * relationship to this class. If no join field can be found it defaults to 'ParentID'. - * + * * If the remote field is polymorphic then $polymorphic is set to true, and the return value * is in the form 'Relation' instead of 'RelationID', referencing the composite DBField. * @@ -1589,7 +1655,6 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * @return string */ public function getRemoteJoinField($component, $type = 'has_many', &$polymorphic = false) { - // Extract relation from current object $remoteClass = $this->$type($component, false); if(empty($remoteClass)) { @@ -1600,16 +1665,16 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity "Class '$remoteClass' not found, but used in $type component '$component' on class '$this->class'" ); } - + // If presented with an explicit field name (using dot notation) then extract field name $remoteField = null; if(strpos($remoteClass, '.') !== false) { list($remoteClass, $remoteField) = explode('.', $remoteClass); } - + // Reference remote has_one to check against $remoteRelations = Config::inst()->get($remoteClass, 'has_one'); - + // Without an explicit field name, attempt to match the first remote field // with the same type as the current class if(empty($remoteField)) { @@ -1622,7 +1687,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity } } } - + // In case of an indeterminate remote field show an error if(empty($remoteField)) { $polymorphic = false; @@ -1634,10 +1699,10 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity } throw new Exception($message); } - + // If given an explicit field name ensure the related class specifies this if(empty($remoteRelations[$remoteField])) { - throw new Exception("Missing expected has_one named '$remoteField' + throw new Exception("Missing expected has_one named '$remoteField' on class '$remoteClass' referenced by $type named '$component' on class {$this->class}" ); @@ -1652,7 +1717,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity return $remoteField . 'ID'; } } - + /** * Returns a many-to-many component, as a ManyManyList. * @param string $componentName Name of the many-many component @@ -1667,11 +1732,11 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity if(!$this->ID) { if(!isset($this->unsavedRelations[$componentName])) { $this->unsavedRelations[$componentName] = - new UnsavedRelationList($parentClass, $componentName, $componentClass); + new UnsavedRelationList($parentClass, $componentName, $componentClass); } return $this->unsavedRelations[$componentName]; } - + $result = ManyManyList::create( $componentClass, $table, $componentField, $parentField, $this->many_many_extraFields($componentName) @@ -1686,9 +1751,9 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity ->sort($sort) ->limit($limit); } - + /** - * Return the class of a one-to-one component. If $component is null, return all of the one-to-one components and + * Return the class of a one-to-one component. If $component is null, return all of the one-to-one components and * their classes. If the selected has_one is a polymorphic field then 'DataObject' will be returned for the type. * * @param string $component Name of component @@ -1705,7 +1770,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity if($component) { $hasOne = Config::inst()->get($class, 'has_one', Config::UNINHERITED); - + if(isset($hasOne[$component])) { return $hasOne[$component]; } @@ -1714,7 +1779,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity // Validate the data foreach($newItems as $k => $v) { if(!is_string($k) || is_numeric($k) || !is_string($v)) { - user_error("$class::\$has_one has a bad entry: " + user_error("$class::\$has_one has a bad entry: " . var_export($k,true). " => " . var_export($v,true) . ". Each map key should be a" . " relationship name, and the map value should be the data class to join to.", E_USER_ERROR); } @@ -1724,7 +1789,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity } return isset($items) ? $items : null; } - + /** * Returns the class of a remote belongs_to relationship. If no component is specified a map of all components and * their class name will be returned. @@ -1736,7 +1801,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity */ public function belongs_to($component = null, $classOnly = true) { $belongsTo = $this->config()->belongs_to; - + if($component) { if($belongsTo && array_key_exists($component, $belongsTo)) { $belongsTo = $belongsTo[$component]; @@ -1744,14 +1809,14 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity return false; } } - + if($belongsTo && $classOnly) { return preg_replace('/(.+)?\..+/', '$1', $belongsTo); } else { return $belongsTo ? $belongsTo : array(); } } - + /** * Return all of the database fields defined in self::$db and all the parent classes. * Doesn't include any fields specified by self::$has_one. Use $this->has_one() to get these fields @@ -1788,7 +1853,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity // Validate the data foreach($dbItems as $k => $v) { if(!is_string($k) || is_numeric($k) || !is_string($v)) { - user_error("$class::\$db has a bad entry: " + user_error("$class::\$db has a bad entry: " . var_export($k,true). " => " . var_export($v,true) . ". Each map key should be a" . " property name, and the map value should be the property type.", E_USER_ERROR); } @@ -1812,7 +1877,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity */ public function has_many($component = null, $classOnly = true) { $hasMany = $this->config()->has_many; - + if($component) { if($hasMany && array_key_exists($component, $hasMany)) { $hasMany = $hasMany[$component]; @@ -1820,7 +1885,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity return false; } } - + if($hasMany && $classOnly) { return preg_replace('/(.+)?\..+/', '$1', $hasMany); } else { @@ -1830,10 +1895,10 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity /** * Return the many-to-many extra fields specification. - * + * * If you don't specify a component name, it returns all * extra fields for all components available. - * + * * @param string $component Name of component * @return array */ @@ -1853,13 +1918,13 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity if(isset($extraFields[$component])) { return $extraFields[$component]; } - + $manyMany = $SNG_class->stat('many_many'); $candidate = (isset($manyMany[$component])) ? $manyMany[$component] : null; if($candidate) { $SNG_candidate = singleton($candidate); $candidateManyMany = $SNG_candidate->stat('belongs_many_many'); - + // Find the relation given the class if($candidateManyMany) foreach($candidateManyMany as $relation => $relatedClass) { if($relatedClass == $class) { @@ -1867,7 +1932,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity break; } } - + if($relationName) { $extraFields = $SNG_candidate->stat('many_many_extraFields'); if(isset($extraFields[$relationName])) { @@ -1875,31 +1940,31 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity } } } - + $manyMany = $SNG_class->stat('belongs_many_many'); $candidate = (isset($manyMany[$component])) ? $manyMany[$component] : null; if($candidate) { $SNG_candidate = singleton($candidate); $candidateManyMany = $SNG_candidate->stat('many_many'); - + // Find the relation given the class if($candidateManyMany) foreach($candidateManyMany as $relation => $relatedClass) { if($relatedClass == $class) { $relationName = $relation; } } - + $extraFields = $SNG_candidate->stat('many_many_extraFields'); if(isset($extraFields[$relationName])) { return $extraFields[$relationName]; } } - + } else { - + // Find all the extra fields for all components $newItems = eval("return (array){$class}::\$many_many_extraFields;"); - + foreach($newItems as $k => $v) { if(!is_array($v)) { user_error( @@ -1910,12 +1975,12 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity ); } } - + return isset($items) ? array_merge($newItems, $items) : $newItems; } } } - + /** * Return information about a many-to-many component. * The return value is an array of (parentclass, childclass). If $component is null, then all many-many @@ -1969,18 +2034,18 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity // Validate the data foreach($newItems as $k => $v) { if(!is_string($k) || is_numeric($k) || !is_string($v)) { - user_error("$class::\$many_many has a bad entry: " + user_error("$class::\$many_many has a bad entry: " . var_export($k,true). " => " . var_export($v,true) . ". Each map key should be a" . " relationship name, and the map value should be the data class to join to.", E_USER_ERROR); } } $items = isset($items) ? array_merge($newItems, $items) : $newItems; - + $newItems = (array)Config::inst()->get($class, 'belongs_many_many', Config::UNINHERITED); // Validate the data foreach($newItems as $k => $v) { if(!is_string($k) || is_numeric($k) || !is_string($v)) { - user_error("$class::\$belongs_many_many has a bad entry: " + user_error("$class::\$belongs_many_many has a bad entry: " . var_export($k,true). " => " . var_export($v,true) . ". Each map key should be a" . " relationship name, and the map value should be the data class to join to.", E_USER_ERROR); } @@ -1989,20 +2054,20 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity $items = isset($items) ? array_merge($newItems, $items) : $newItems; } } - + return isset($items) ? $items : null; } - + /** * This returns an array (if it exists) describing the database extensions that are required, or false if none - * + * * This is experimental, and is currently only a Postgres-specific enhancement. - * + * * @return array or false */ public function database_extensions($class){ $extensions = Config::inst()->get($class, 'database_extensions', Config::UNINHERITED); - + if($extensions) return $extensions; else @@ -2017,12 +2082,12 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity */ public function getDefaultSearchContext() { return new SearchContext( - $this->class, - $this->scaffoldSearchFields(), + $this->class, + $this->scaffoldSearchFields(), $this->defaultSearchFilters() ); } - + /** * Determine which properties on the DataObject are * searchable, and map them to their default {@link FormField} @@ -2032,7 +2097,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * how generic or specific the field type is. * * Used by {@link SearchContext}. - * + * * @param array $_params * 'fieldClasses': Associative array of field names as keys and FormField classes as values * 'restrictFields': Numeric array of a field name whitelist @@ -2049,7 +2114,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity $fields = new FieldList(); foreach($this->searchableFields() as $fieldName => $spec) { if($params['restrictFields'] && !in_array($fieldName, $params['restrictFields'])) continue; - + // If a custom fieldclass is provided as a string, use it if($params['fieldClasses'] && isset($params['fieldClasses'][$fieldName])) { $fieldClass = $params['fieldClasses'][$fieldName]; @@ -2060,17 +2125,17 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity if(is_string($spec['field'])) { $fieldClass = $spec['field']; $field = new $fieldClass($fieldName); - + // If it's a FormField object, then just use that object directly. } else if($spec['field'] instanceof FormField) { $field = $spec['field']; - + // Otherwise we have a bug } else { user_error("Bad value for searchable_fields, 'field' value: " . var_export($spec['field'], true), E_USER_WARNING); } - + // Otherwise, use the database field's scaffolder } else { $field = $this->relObject($fieldName)->scaffoldSearchField(); @@ -2092,7 +2157,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * Field labels/titles will be auto generated from {@link DataObject::fieldLabels()}. * * @uses FormScaffolder - * + * * @param array $_params Associative array passing through properties to {@link FormScaffolder}. * @return FieldList */ @@ -2107,27 +2172,27 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity ), (array)$_params ); - + $fs = new FormScaffolder($this); $fs->tabbed = $params['tabbed']; $fs->includeRelations = $params['includeRelations']; $fs->restrictFields = $params['restrictFields']; $fs->fieldClasses = $params['fieldClasses']; $fs->ajaxSafe = $params['ajaxSafe']; - + return $fs->getFieldList(); } - + /** * Allows user code to hook into DataObject::getCMSFields prior to updateCMSFields * being called on extensions - * + * * @param callable $callback The callback to execute */ protected function beforeUpdateCMSFields($callback) { $this->beforeExtending('updateCMSFields', $callback); } - + /** * Centerpiece of every data administration interface in Silverstripe, * which returns a {@link FieldList} suitable for a {@link Form} object. @@ -2158,16 +2223,16 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity 'tabbed' => true, 'ajaxSafe' => true )); - + $this->extend('updateCMSFields', $tabbedFields); - + return $tabbedFields; } - + /** * need to be overload by solid dataobject, so that the customised actions of that dataobject, * including that dataobject's extensions customised actions could be added to the EditForm. - * + * * @return an Empty FieldList(); need to be overload by solid subclass */ public function getCMSActions() { @@ -2175,14 +2240,14 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity $this->extend('updateCMSActions', $actions); return $actions; } - + /** * Used for simple frontend forms without relation editing * or {@link TabSet} behaviour. Uses {@link scaffoldFormFields()} * by default. To customize, either overload this method in your * subclass, or extend it by {@link DataExtension->updateFrontEndFields()}. - * + * * @todo Decide on naming for "website|frontend|site|page" and stick with it in the API * * @param array $params See {@link scaffoldFormFields()} @@ -2191,7 +2256,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity public function getFrontEndFields($params = null) { $untabbedFields = $this->scaffoldFormFields($params); $this->extend('updateFrontEndFields', $untabbedFields); - + return $untabbedFields; } @@ -2230,7 +2295,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity // or a valid record has been loaded from the database $value = (isset($this->record[$field])) ? $this->record[$field] : null; if($value || $this->exists()) $fieldObj->setValue($value, $this->record, false); - + $this->record[$field] = $fieldObj; return $this->record[$field]; @@ -2260,7 +2325,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity } $dataQuery = new DataQuery($tableClass); - + // Reset query parameter context to that of this DataObject if($params = $this->getSourceQueryParams()) { foreach($params as $key => $value) $dataQuery->setQueryParam($key, $value); @@ -2279,7 +2344,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity // Add SQL for fields, both simple & multi-value // TODO: This is copy & pasted from buildSQL(), it could be moved into a method - $databaseFields = self::database_fields($tableClass); + $databaseFields = self::database_fields($tableClass, false); if($databaseFields) foreach($databaseFields as $k => $v) { if(!isset($this->record[$k]) || $this->record[$k] === null) { $columns[] = $k; @@ -2317,7 +2382,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity /** * Return the fields that have changed. - * + * * The change level affects what the functions defines as "changed": * - Level CHANGE_STRICT (integer 1) will return strict changes, even !== ones. * - Level CHANGE_VALUE (integer 2) is more lenient, it will only return real data changes, @@ -2336,14 +2401,14 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity */ public function getChangedFields($databaseFieldsOnly = false, $changeLevel = self::CHANGE_STRICT) { $changedFields = array(); - + // Update the changed array with references to changed obj-fields foreach($this->record as $k => $v) { if(is_object($v) && method_exists($v, 'isChanged') && $v->isChanged()) { $this->changed[$k] = self::CHANGE_VALUE; } } - + if($databaseFieldsOnly) { $databaseFields = $this->inheritedDatabaseFields(); $databaseFields['ID'] = true; @@ -2363,7 +2428,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity } } } - + if($fields) foreach($fields as $name => $level) { $changedFields[$name] = array( 'before' => array_key_exists($name, $this->original) ? $this->original[$name] : null, @@ -2374,11 +2439,11 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity return $changedFields; } - + /** * Uses {@link getChangedFields()} to determine if fields have been changed * since loading them from the database. - * + * * @param string $fieldName Name of the database field to check, will check for any if not given * @param int $changeLevel See {@link getChangedFields()} * @return boolean @@ -2387,7 +2452,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity $changed = $this->getChangedFields(false, $changeLevel); if(!isset($fieldName)) { return !empty($changed); - } + } else { return array_key_exists($fieldName, $changed); } @@ -2418,15 +2483,14 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity if(is_object($val) && $this->db($fieldName)) { user_error('DataObject::setField: passed an object that is not a DBField', E_USER_WARNING); } - - $defaults = $this->stat('defaults'); + // if a field is not existing or has strictly changed if(!isset($this->record[$fieldName]) || $this->record[$fieldName] !== $val) { // TODO Add check for php-level defaults which are not set in the db // TODO Add check for hidden input-fields (readonly) which are not set in the db // At the very least, the type has changed $this->changed[$fieldName] = self::CHANGE_STRICT; - + if((!isset($this->record[$fieldName]) && $val) || (isset($this->record[$fieldName]) && $this->record[$fieldName] != $val)) { @@ -2472,8 +2536,8 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity } /** - * Returns true if the given field exists in a database column on any of - * the objects tables and optionally look up a dynamic getter with + * Returns true if the given field exists in a database column on any of + * the objects tables and optionally look up a dynamic getter with * get(). * * @param string $field Name of the field @@ -2500,7 +2564,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity return array_key_exists($field, $this->inheritedDatabaseFields()); } - + /** * Returns the field type of the given field, if it belongs to this class, and not a parent. * Note that the field type will not include constructor arguments in round brackets, only the classname. @@ -2509,58 +2573,31 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * @return string The field type of the given field */ public function hasOwnTableDatabaseField($field) { - // Add base fields which are not defined in static $db + return self::has_own_table_database_field($this->class, $field); + } + + /** + * Returns the field type of the given field, if it belongs to this class, and not a parent. + * Note that the field type will not include constructor arguments in round brackets, only the classname. + * + * @param string $class Class name to check + * @param string $field Name of the field + * @return string The field type of the given field + */ + public static function has_own_table_database_field($class, $field) { + // Since database_fields omits 'ID' if($field == "ID") return "Int"; - if($field == "ClassName" && get_parent_class($this) == "DataObject") return "Enum"; - if($field == "LastEdited" && get_parent_class($this) == "DataObject") return "SS_Datetime"; - if($field == "Created" && get_parent_class($this) == "DataObject") return "SS_Datetime"; - // Add fields from Versioned extension - if($field == 'Version' && $this->hasExtension('Versioned')) { - return 'Int'; - } - // get cached fieldmap - $fieldMap = isset(DataObject::$cache_has_own_table_field[$this->class]) - ? DataObject::$cache_has_own_table_field[$this->class] : null; - - // if no fieldmap is cached, get all fields - if(!$fieldMap) { - $fieldMap = Config::inst()->get($this->class, 'db', Config::UNINHERITED); - - // all $db fields on this specific class (no parents) - foreach(self::composite_fields($this->class, false) as $fieldname => $fieldtype) { - $combined_db = singleton($fieldtype)->compositeDatabaseFields(); - foreach($combined_db as $name => $type){ - $fieldMap[$fieldname.$name] = $type; - } - } - - // all has_one relations on this specific class, - // add foreign key - - $hasOne = Config::inst()->get($this->class, 'has_one', Config::UNINHERITED); - if($hasOne) foreach($hasOne as $fieldName => $fieldSchema) { - if($fieldSchema === 'DataObject') { - // For polymorphic has_one relation break into individual subfields - $fieldMap[$fieldName . 'ID'] = "Int"; - $fieldMap[$fieldName . 'Class'] = "Enum"; - $fieldMap[$fieldName] = "PolymorphicForeignKey"; - } else { - $fieldMap[$fieldName . 'ID'] = "ForeignKey"; - } - } - - // set cached fieldmap - DataObject::$cache_has_own_table_field[$this->class] = $fieldMap; - } + $fieldMap = self::database_fields($class, false); // Remove string-based "constructor-arguments" from the DBField definition if(isset($fieldMap[$field])) { - if(is_string($fieldMap[$field])) return strtok($fieldMap[$field],'('); - else return $fieldMap[$field]['type']; + $spec = $fieldMap[$field]; + if(is_string($spec)) return strtok($spec,'('); + else return $spec['type']; } } - + /** * Returns true if given class has its own table. Uses the rules for whether the table should exist rather than * actually looking in the database. @@ -2570,19 +2607,19 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity */ public static function has_own_table($dataClass) { if(!is_subclass_of($dataClass,'DataObject')) return false; - + if(!isset(DataObject::$cache_has_own_table[$dataClass])) { if(get_parent_class($dataClass) == 'DataObject') { DataObject::$cache_has_own_table[$dataClass] = true; } else { - DataObject::$cache_has_own_table[$dataClass] - = Config::inst()->get($dataClass, 'db', Config::UNINHERITED) + DataObject::$cache_has_own_table[$dataClass] + = Config::inst()->get($dataClass, 'db', Config::UNINHERITED) || Config::inst()->get($dataClass, 'has_one', Config::UNINHERITED); } } return DataObject::$cache_has_own_table[$dataClass]; } - + /** * Returns true if the member is allowed to do the given action. * See {@link extendedCan()} for a more versatile tri-state permission control. @@ -2618,7 +2655,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity $groupList = implode(', ', $groups->column("ID")); // TODO Fix relation table hardcoding - $query = new SQLQuery( + $query = new SQLSelect( "\"Page_Can$perm\".PageID", array("\"Page_Can$perm\""), "GroupID IN ($groupList)"); @@ -2627,7 +2664,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity if($perm == "View") { // TODO Fix relation table hardcoding - $query = new SQLQuery("\"SiteTree\".\"ID\"", array( + $query = new SQLSelect("\"SiteTree\".\"ID\"", array( "\"SiteTree\"", "LEFT JOIN \"Page_CanView\" ON \"Page_CanView\".\"PageID\" = \"SiteTree\".\"ID\"" ), "\"Page_CanView\".\"PageID\" IS NULL"); @@ -2656,11 +2693,11 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity /** * Process tri-state responses from permission-alterting extensions. The extensions are * expected to return one of three values: - * + * * - false: Disallow this permission, regardless of what other extensions say * - true: Allow this permission, as long as no other extensions return false * - NULL: Don't affect the outcome - * + * * This method itself returns a tri-state value, and is designed to be used like this: * * @@ -2668,7 +2705,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * if($extended !== null) return $extended; * else return $normalValue; * - * + * * @param String $methodName Method on the same object, e.g. {@link canEdit()} * @param Member|int $member * @return boolean|null @@ -2679,12 +2716,12 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity // Remove NULLs $results = array_filter($results, function($v) {return !is_null($v);}); // If there are any non-NULL responses, then return the lowest one of them. - // If any explicitly deny the permission, then we don't get access + // If any explicitly deny the permission, then we don't get access if($results) return min($results); } return null; } - + /** * @param Member $member * @return boolean @@ -2763,7 +2800,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity // If we have a CompositeDBField object in $this->record, then return that if(isset($this->record[$fieldName]) && is_object($this->record[$fieldName])) { return $this->record[$fieldName]; - + // Special case for ID field } else if($fieldName == 'ID') { return new PrimaryKey($fieldName, $this); @@ -2771,22 +2808,22 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity // Special case for ClassName } else if($fieldName == 'ClassName') { $val = get_class($this); - return DBField::create_field('Varchar', $val, $fieldName, $this); + return DBField::create_field('Varchar', $val, $fieldName); } else if(array_key_exists($fieldName, self::$fixed_fields)) { - return DBField::create_field(self::$fixed_fields[$fieldName], $this->$fieldName, $fieldName, $this); + return DBField::create_field(self::$fixed_fields[$fieldName], $this->$fieldName, $fieldName); // General casting information for items in $db } else if($helper = $this->db($fieldName)) { $obj = Object::create_from_string($helper, $fieldName); $obj->setValue($this->$fieldName, $this->record, false); return $obj; - + // Special case for has_one relationships } else if(preg_match('/ID$/', $fieldName) && $this->has_one(substr($fieldName,0,-2))) { $val = $this->$fieldName; return DBField::create_field('ForeignKey', $val, $fieldName, $this); - + // has_one for polymorphic relations do not end in ID } else if(($type = $this->has_one($fieldName)) && ($type === 'DataObject')) { $val = $this->$fieldName(); @@ -2798,7 +2835,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity /** * Traverses to a DBField referenced by relationships between data objects. * - * The path to the related field is specified with dot separated syntax + * The path to the related field is specified with dot separated syntax * (eg: Parent.Child.Child.FieldName). * * @param string $fieldPath @@ -2860,7 +2897,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity $component = $component->relation($relation); } elseif($component instanceof DataObject && ($dbObject = $component->dbObject($relation)) - ) { + ) { // Select db object $component = $dbObject; } else { @@ -2868,7 +2905,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity } } } - + // Bail if the component is null if(!$component) { return null; @@ -2882,7 +2919,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity /** * Temporary hack to return an association name, based on class, to get around the mangle * of having to deal with reverse lookup of relationships to determine autogenerated foreign keys. - * + * * @return String */ public function getReverseAssociation($className) { @@ -2898,7 +2935,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity $has_one = array_flip($this->has_one()); if (array_key_exists($className, $has_one)) return $has_one[$className]; } - + return false; } @@ -2907,14 +2944,17 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * sub-classes are automatically selected and included * * @param string $callerClass The class of objects to be returned - * @param string $filter A filter to be inserted into the WHERE clause. - * @param string|array $sort A sort expression to be inserted into the ORDER BY clause. If omitted, - * self::$default_sort will be used. + * @param string|array $filter A filter to be inserted into the WHERE clause. + * Supports parameterised queries. See SQLSelect::addWhere() for syntax examples. + * @param string|array $sort A sort expression to be inserted into the ORDER + * BY clause. If omitted, self::$default_sort will be used. * @param string $join Deprecated 3.0 Join clause. Use leftJoin($table, $joinClause) instead. * @param string|array $limit A limit expression to be inserted into the LIMIT clause. * @param string $containerClass The container class to return the results in. * - * @return DataList + * @todo $containerClass is Ignored, why? + * + * @return DataList The objects matching the filter, in the class specified by $containerClass */ public static function get($callerClass = null, $filter = "", $sort = "", $join = "", $limit = null, $containerClass = 'DataList') { @@ -2924,12 +2964,12 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity if($callerClass == 'DataObject') { throw new \InvalidArgumentException('Call ::get() instead of DataObject::get()'); } - + if($filter || $sort || $join || $limit || ($containerClass != 'DataList')) { throw new \InvalidArgumentException('If calling ::get() then you shouldn\'t pass any other' . ' arguments'); } - + $result = DataList::create(get_called_class()); $result->setDataModel(DataModel::inst()); return $result; @@ -2940,7 +2980,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity 'The $join argument has been removed. Use leftJoin($table, $joinClause) instead.' ); } - + $result = DataList::create($callerClass)->where($filter)->sort($sort); if($limit && strpos($limit, ',') !== false) { @@ -2953,7 +2993,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity $result->setDataModel(DataModel::inst()); return $result; } - + /** * @deprecated 3.1 Use DataList::create and DataList to do your querying */ @@ -2989,7 +3029,8 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * All calls to get_one() are cached. * * @param string $callerClass The class of objects to be returned - * @param string $filter A filter to be inserted into the WHERE clause + * @param string|array $filter A filter to be inserted into the WHERE clause. + * Supports parameterised queries. See SQLSelect::addWhere() for syntax examples. * @param boolean $cache Use caching * @param string $orderby A sort expression to be inserted into the ORDER BY clause. * @@ -2998,21 +3039,18 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity public static function get_one($callerClass, $filter = "", $cache = true, $orderby = "") { $SNG = singleton($callerClass); - $cacheKey = "{$filter}-{$orderby}"; - if($extra = $SNG->extend('cacheKeyComponent')) { - $cacheKey .= '-' . implode("-", $extra); - } - $cacheKey = md5($cacheKey); - + $cacheComponents = array($filter, $orderby, $SNG->extend('cacheKeyComponent')); + $cacheKey = md5(var_export($cacheComponents, true)); + // Flush destroyed items out of the cache - if($cache && isset(DataObject::$_cache_get_one[$callerClass][$cacheKey]) - && DataObject::$_cache_get_one[$callerClass][$cacheKey] instanceof DataObject + if($cache && isset(DataObject::$_cache_get_one[$callerClass][$cacheKey]) + && DataObject::$_cache_get_one[$callerClass][$cacheKey] instanceof DataObject && DataObject::$_cache_get_one[$callerClass][$cacheKey]->destroyed) { DataObject::$_cache_get_one[$callerClass][$cacheKey] = false; } if(!$cache || !isset(DataObject::$_cache_get_one[$callerClass][$cacheKey])) { - $dl = $callerClass::get()->where($filter)->sort($orderby); + $dl = DataObject::get($callerClass)->where($filter)->sort($orderby); $item = $dl->First(); if($cache) { @@ -3030,12 +3068,12 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * Also clears any cached aggregate data. * * @param boolean $persistent When true will also clear persistent data stored in the Cache system. - * When false will just clear session-local cached data + * When false will just clear session-local cached data * @return DataObject $this */ public function flushCache($persistent = true) { if($persistent) Aggregate::flushCache($this->class); - + if($this->class == 'DataObject') { DataObject::$_cache_get_one = array(); return $this; @@ -3045,9 +3083,9 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity foreach($classes as $class) { if(isset(DataObject::$_cache_get_one[$class])) unset(DataObject::$_cache_get_one[$class]); } - + $this->extend('flushCache'); - + $this->components = array(); return $this; } @@ -3063,13 +3101,13 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity } DataObject::$_cache_get_one = array(); } - + /** * Reset all global caches associated with DataObject. */ public static function reset() { + self::clear_classname_spec_cache(); DataObject::$cache_has_own_table = array(); - DataObject::$cache_has_own_table_field = array(); DataObject::$_cache_db = array(); DataObject::$_cache_get_one = array(); DataObject::$_cache_composite_fields = array(); @@ -3088,18 +3126,21 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * @return DataObject The element */ public static function get_by_id($callerClass, $id, $cache = true) { - if(is_numeric($id)) { - if(is_subclass_of($callerClass, 'DataObject')) { - $baseClass = ClassInfo::baseDataClass($callerClass); - return DataObject::get_one($callerClass,"\"$baseClass\".\"ID\" = $id", $cache); - - // This simpler code will be used by non-DataObject classes that implement DataObjectInterface - } else { - return DataObject::get_one($callerClass,"\"ID\" = $id", $cache); - } - } else { + if(!is_numeric($id)) { user_error("DataObject::get_by_id passed a non-numeric ID #$id", E_USER_WARNING); } + + // Check filter column + if(is_subclass_of($callerClass, 'DataObject')) { + $baseClass = ClassInfo::baseDataClass($callerClass); + $column = "\"$baseClass\".\"ID\""; + } else{ + // This simpler code will be used by non-DataObject classes that implement DataObjectInterface + $column = '"ID"'; + } + + // Relegate to get_one + return DataObject::get_one($callerClass, array($column => $id), $cache); } /** @@ -3112,7 +3153,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity /** * @var Array Parameters used in the query that built this object. - * This can be used by decorators (e.g. lazy loading) to + * This can be used by decorators (e.g. lazy loading) to * run additional queries using the same context. */ protected $sourceQueryParams; @@ -3185,7 +3226,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity /** * Check the database schema and update it as necessary. - * + * * @uses DataExtension->augmentDatabase() */ public function requireTable() { @@ -3197,10 +3238,10 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity if($fields) { $hasAutoIncPK = ($this->class == ClassInfo::baseDataClass($this->class)); - DB::requireTable($this->class, $fields, $indexes, $hasAutoIncPK, $this->stat('create_table_options'), + DB::require_table($this->class, $fields, $indexes, $hasAutoIncPK, $this->stat('create_table_options'), $extensions); } else { - DB::dontRequireTable($this->class); + DB::dont_require_table($this->class); } // Build any child tables for many_many items @@ -3221,8 +3262,8 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity "{$this->class}ID" => true, (($this->class == $childClass) ? "ChildID" : "{$childClass}ID") => true, ); - - DB::requireTable("{$this->class}_$relationship", $manymanyFields, $manymanyIndexes, true, null, + + DB::require_table("{$this->class}_$relationship", $manymanyFields, $manymanyIndexes, true, null, $extensions); } } @@ -3236,7 +3277,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * database is built, after the database tables have all been created. Overload * this to add default records when the database is built, but make sure you * call parent::requireDefaultRecords(). - * + * * @uses DataExtension->requireDefaultRecords() */ public function requireDefaultRecords() { @@ -3253,11 +3294,11 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity DB::alteration_message("Added default records to $className table","created"); } } - + // Let any extentions make their own database default data $this->extend('requireDefaultRecords', $dummy); } - + /** * Returns fields bu traversing the class heirachy in a bottom-up direction. * @@ -3271,18 +3312,18 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity public function inheritedDatabaseFields() { $fields = array(); $currentObj = $this->class; - + while($currentObj != 'DataObject') { $fields = array_merge($fields, self::custom_database_fields($currentObj)); $currentObj = get_parent_class($currentObj); } - + return (array) $fields; } /** - * Get the default searchable fields for this object, as defined in the - * $searchable_fields list. If searchable fields are not defined on the + * Get the default searchable fields for this object, as defined in the + * $searchable_fields list. If searchable fields are not defined on the * data object, uses a default selection of summary fields. * * @return array @@ -3291,7 +3332,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity // can have mixed format, need to make consistent in most verbose form $fields = $this->stat('searchable_fields'); $labels = $this->fieldLabels(); - + // fallback to summary fields (unless empty array is explicitly specified) if( ! $fields && ! is_array($fields)) { $summaryFields = array_keys($this->summaryFields()); @@ -3315,7 +3356,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity } } } - + // we need to make sure the format is unified before // augmenting fields, so extensions can apply consistent checks // but also after augmenting fields, because the extension @@ -3355,13 +3396,13 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity } $fields = $rewrite; - + // apply DataExtensions if present $this->extend('updateSearchableFields', $fields); return $fields; } - + /** * Get any user defined searchable fields labels that * exist. Allows overriding of default field names in the form @@ -3374,23 +3415,23 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * would generally only be set in the case of more complex relationships * between data object being required in the search interface. * - * Generates labels based on name of the field itself, if no static property + * Generates labels based on name of the field itself, if no static property * {@link self::field_labels} exists. * * @uses $field_labels * @uses FormField::name_to_label() * * @param boolean $includerelations a boolean value to indicate if the labels returned include relation fields - * + * * @return array|string Array of all element labels if no argument given, otherwise the label of the field */ public function fieldLabels($includerelations = true) { $cacheKey = $this->class . '_' . $includerelations; - + if(!isset(self::$_cache_field_labels[$cacheKey])) { $customLabels = $this->stat('field_labels'); $autoLabels = array(); - + // get all translated static properties as defined in i18nCollectStatics() $ancestry = ClassInfo::ancestry($this->class); $ancestry = array_reverse($ancestry); @@ -3412,20 +3453,20 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity } $labels = array_merge((array)$autoLabels, (array)$customLabels); - $this->extend('updateFieldLabels', $labels); + $this->extend('updateFieldLabels', $labels); self::$_cache_field_labels[$cacheKey] = $labels; } - + return self::$_cache_field_labels[$cacheKey]; } - + /** * Get a human-readable label for a single field, * see {@link fieldLabels()} for more details. - * + * * @uses fieldLabels() * @uses FormField::name_to_label() - * + * * @param string $name Name of the field * @return string Label of the field */ @@ -3459,7 +3500,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity if ($this->hasField('FirstName')) $fields['FirstName'] = 'First Name'; } $this->extend("updateSummaryFields", $fields); - + // Final fail-over, just list ID field if(!$fields) $fields['ID'] = 'ID'; @@ -3467,7 +3508,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity foreach($this->fieldLabels(false) as $name => $label) { if(isset($fields[$name])) $fields[$name] = $label; } - + return $fields; } @@ -3487,7 +3528,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity foreach($this->searchableFields() as $name => $spec) { $filterClass = $spec['filter']; - + if($spec['filter'] instanceof SearchFilter) { $filters[$name] = $spec['filter']; } else { @@ -3514,8 +3555,8 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity /* * @ignore */ - private static $subclass_access = true; - + private static $subclass_access = true; + /** * Temporarily disable subclass access in data object qeur */ @@ -3525,7 +3566,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity public static function enable_subclass_access() { self::$subclass_access = true; } - + //-------------------------------------------------------------------------------------------// /** @@ -3547,13 +3588,13 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity "Created" => "SS_Datetime", "Title" => 'Text', ); - + /** * Specify custom options for a CREATE TABLE call. * Can be used to specify a custom storage engine for specific database table. * All options have to be keyed for a specific database implementation, * identified by their class name (extending from {@link SS_Database}). - * + * * * array( * 'MySQLDatabase' => 'ENGINE=MyISAM' @@ -3562,7 +3603,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * * Caution: This API is experimental, and might not be * included in the next major release. Please use with care. - * + * * @var array * @config */ @@ -3574,7 +3615,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * If a field is in this array, then create a database index * on that field. This is a map from fieldname to index type. * See {@link SS_Database->requireIndex()} and custom subclasses for details on the array notation. - * + * * @var array * @config */ @@ -3584,11 +3625,11 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * Inserts standard column-values when a DataObject * is instanciated. Does not insert default records {@see $default_records}. * This is a map from fieldname to default value. - * + * * - If you would like to change a default value in a sub-class, just specify it. * - If you would like to disable the default value given by a parent class, set the default value to 0,'', * or false in your subclass. Setting it to null won't work. - * + * * @var array * @config */ @@ -3621,7 +3662,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * @config */ private static $has_one = null; - + /** * A meta-relationship that allows you to define the reverse side of a {@link DataObject::$has_one}. * @@ -3635,7 +3676,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * @config */ private static $belongs_to; - + /** * This defines a one-to-many relationship. It is a map of component name to the remote data class. * @@ -3660,7 +3701,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity /** * Extra fields to include on the connecting many-many table. * This is a map from field name to field type. - * + * * Example code: * * public static $many_many_extraFields = array( @@ -3669,7 +3710,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * ) * ); * - * + * * @var array * @config */ @@ -3701,7 +3742,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * "Name" => "PartialMatchFilter" * ); * - * + * * Overriding the default form fields, with a custom defined field. * The 'filter' parameter will be generated from {@link DBField::$default_search_filter_class}. * The 'title' parameter will be generated from {@link DataObject->fieldLabels()}. @@ -3717,7 +3758,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * * static $searchable_fields = array( * "Organisation.ZipCode" => array( - * "field" => "TextField", + * "field" => "TextField", * "filter" => "PartialMatchFilter", * "title" => 'Organisation ZIP' * ) @@ -3740,44 +3781,44 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * @config */ private static $summary_fields = null; - + /** * Provides a list of allowed methods that can be called via RESTful api. */ public static $allowed_actions = null; - + /** * Collect all static properties on the object * which contain natural language, and need to be translated. * The full entity name is composed from the class name and a custom identifier. - * + * * @return array A numerical array which contains one or more entities in array-form. * Each numeric entity array contains the "arguments" for a _t() call as array values: * $entity, $string, $priority, $context. */ public function provideI18nEntities() { $entities = array(); - + $entities["{$this->class}.SINGULARNAME"] = array( $this->singular_name(), - + 'Singular name of the object, used in dropdowns and to generally identify a single object in the interface' ); $entities["{$this->class}.PLURALNAME"] = array( $this->plural_name(), - + 'Pural name of the object, used in dropdowns and to generally identify a collection of this object in the' . ' interface' ); - + return $entities; } - + /** * Returns true if the given method/parameter has a value * (Uses the DBField::hasValue if the parameter is a database field) - * + * * @param string $field The field name * @param array $arguments * @param bool $cache diff --git a/model/DataQuery.php b/model/DataQuery.php index 089f275ca..617647b68 100644 --- a/model/DataQuery.php +++ b/model/DataQuery.php @@ -2,7 +2,7 @@ /** * An object representing a query of data from the DataObject's supporting database. - * Acts as a wrapper over {@link SQLQuery} and performs all of the query generation. + * Acts as a wrapper over {@link SQLSelect} and performs all of the query generation. * Used extensively by {@link DataList}. * * Unlike DataList, modifiers on DataQuery modify the object rather than returning a clone. @@ -19,7 +19,7 @@ class DataQuery { protected $dataClass; /** - * @var SQLQuery + * @var SQLSelect */ protected $query; @@ -65,7 +65,7 @@ class DataQuery { } /** - * Return the {@link SQLQuery} object that represents the current query; note that it will + * Return the {@link SQLSelect} object that represents the current query; note that it will * be a clone of the object. */ public function query() { @@ -75,15 +75,41 @@ class DataQuery { /** * Remove a filter from the query + * + * @param string|array $fieldExpression The predicate of the condition to remove + * (ignoring parameters). The expression will be considered a match if it's + * contained within any other predicate. + * @return DataQuery Self reference */ public function removeFilterOn($fieldExpression) { $matched = false; + + // If given a parameterised condition extract only the condition + if(is_array($fieldExpression)) { + reset($fieldExpression); + $fieldExpression = key($fieldExpression); + } $where = $this->query->getWhere(); - foreach($where as $i => $clause) { - if(strpos($clause, $fieldExpression) !== false) { - unset($where[$i]); - $matched = true; + // Iterate through each condition + foreach($where as $i => $condition) { + + // Rewrite condition groups as plain conditions before comparison + if($condition instanceof SQLConditionGroup) { + $predicate = $condition->conditionSQL($parameters); + $condition = array($predicate => $parameters); + } + + // As each condition is a single length array, do a single + // iteration to extract the predicate and parameters + foreach($condition as $predicate => $parameters) { + // @see SQLSelect::addWhere for why this is required here + if(strpos($predicate, $fieldExpression) !== false) { + unset($where[$i]); + $matched = true; + } + // Enforce single-item condition predicate => parameters structure + break; } } @@ -120,7 +146,7 @@ class DataQuery { $baseClass = array_shift($tableClasses); // Build our intial query - $this->query = new SQLQuery(array()); + $this->query = new SQLSelect(array()); $this->query->setDistinct(true); if($sort = singleton($this->dataClass)->stat('default_sort')) { @@ -139,8 +165,9 @@ class DataQuery { /** * Ensure that the query is ready to execute. - * - * @return SQLQuery + * + * @param array|null $queriedColumns Any columns to filter the query by + * @return SQLSelect The finalised sql query */ public function getFinalisedQuery($queriedColumns = null) { if(!$queriedColumns) $queriedColumns = $this->queriedColumns; @@ -157,8 +184,8 @@ class DataQuery { if($queriedColumns) { // Specifying certain columns allows joining of child tables $tableClasses = ClassInfo::dataClassesFor($this->dataClass); - - foreach ($query->getWhere() as $where) { + + foreach ($query->getWhereParameterised($parameters) as $where) { // Check for just the column, in the form '"Column" = ?' and the form '"Table"."Column"' = ? if (preg_match('/^"([^"]+)"/', $where, $matches) || preg_match('/^"([^"]+)"\."[^"]+"/', $where, $matches)) { @@ -180,7 +207,7 @@ class DataQuery { $selectColumns = null; if ($queriedColumns) { // Restrict queried columns to that on the selected table - $tableFields = DataObject::database_fields($tableClass); + $tableFields = DataObject::database_fields($tableClass, false); $selectColumns = array_intersect($queriedColumns, array_keys($tableFields)); } @@ -206,7 +233,7 @@ class DataQuery { if(preg_match('/^"([^"]+)"/', $collision, $matches)) { $collisionBase = $matches[1]; $collisionClasses = ClassInfo::subclassesFor($collisionBase); - $collisionClasses = array_map(array(DB::getConn(), 'prepStringForDB'), $collisionClasses); + $collisionClasses = Convert::raw2sql($collisionClasses, true); $caseClauses[] = "WHEN \"$baseClass\".\"ClassName\" IN (" . implode(", ", $collisionClasses) . ") THEN $collision"; } else { @@ -224,14 +251,19 @@ class DataQuery { // Get the ClassName values to filter to $classNames = ClassInfo::subclassesFor($this->dataClass); if(!$classNames) user_error("DataList::create() Can't find data sub-classes for '$callerClass'"); - $classNames = array_map(array(DB::getConn(), 'prepStringForDB'), $classNames); - $query->addWhere("\"$baseClass\".\"ClassName\" IN (" . implode(",", $classNames) . ")"); + $classNamesPlaceholders = DB::placeholders($classNames); + $query->addWhere(array( + "\"$baseClass\".\"ClassName\" IN ($classNamesPlaceholders)" => $classNames + )); } } $query->selectField("\"$baseClass\".\"ID\"", "ID"); - $query->selectField("CASE WHEN \"$baseClass\".\"ClassName\" IS NOT NULL THEN \"$baseClass\".\"ClassName\"" - . " ELSE ".DB::getConn()->prepStringForDB($baseClass)." END", "RecordClassName"); + $query->selectField(" + CASE WHEN \"$baseClass\".\"ClassName\" IS NOT NULL THEN \"$baseClass\".\"ClassName\" + ELSE ".Convert::raw2sql($baseClass, true)." END", + "RecordClassName" + ); // TODO: Versioned, Translatable, SiteTreeSubsites, etc, could probably be better implemented as subclasses // of DataQuery @@ -247,7 +279,7 @@ class DataQuery { /** * Ensure that if a query has an order by clause, those columns are present in the select. * - * @param SQLQuery $query + * @param SQLSelect $query * @return null */ protected function ensureSelectContainsOrderbyColumns($query, $originalSelect = array()) { @@ -273,13 +305,8 @@ class DataQuery { } if(count($parts) == 1) { - $databaseFields = DataObject::database_fields($baseClass); - - // database_fields() doesn't return ID, so we need to - // manually add it here - $databaseFields['ID'] = true; - - if(isset($databaseFields[$parts[0]])) { + + if(DataObject::has_own_table_database_field($baseClass, $parts[0])) { $qualCol = "\"$baseClass\".\"{$parts[0]}\""; } else { $qualCol = "\"$parts[0]\""; @@ -290,7 +317,7 @@ class DataQuery { // add new columns sort $newOrderby[$qualCol] = $dir; - // To-do: Remove this if block once SQLQuery::$select has been refactored to store getSelect() + // To-do: Remove this if block once SQLSelect::$select has been refactored to store getSelect() // format internally; then this check can be part of selectField() $selects = $query->getSelect(); if(!isset($selects[$col]) && !in_array($qualCol, $selects)) { @@ -299,7 +326,7 @@ class DataQuery { } else { $qualCol = '"' . implode('"."', $parts) . '"'; - // To-do: Remove this if block once SQLQuery::$select has been refactored to store getSelect() + // To-do: Remove this if block once SQLSelect::$select has been refactored to store getSelect() // format internally; then this check can be part of selectField() if(!in_array($qualCol, $query->getSelect())) { $query->selectField($qualCol); @@ -320,9 +347,18 @@ class DataQuery { /** * Return this query's SQL + * + * @param array $parameters Out variable for parameters required for this query + * @return string The resulting SQL query (may be paramaterised) */ - public function sql() { - return $this->getFinalisedQuery()->sql(); + public function sql(&$parameters = array()) { + if(func_num_args() == 0) { + Deprecation::notice( + '3.2', + 'DataQuery::sql() now may produce parameters which are necessary to execute this query' + ); + } + return $this->getFinalisedQuery()->sql($parameters); } /** @@ -337,37 +373,41 @@ class DataQuery { /** * Return the maximum value of the given field in this DataList * - * @param String $field Unquoted database column name (will be escaped automatically) + * @param String $field Unquoted database column name. Will be ANSI quoted + * automatically so must not contain double quotes. */ public function max($field) { - return $this->aggregate(sprintf('MAX("%s")', Convert::raw2sql($field))); + return $this->aggregate("MAX(\"$field\")"); } /** * Return the minimum value of the given field in this DataList * - * @param String $field Unquoted database column name (will be escaped automatically) + * @param String $field Unquoted database column name. Will be ANSI quoted + * automatically so must not contain double quotes. */ public function min($field) { - return $this->aggregate(sprintf('MIN("%s")', Convert::raw2sql($field))); + return $this->aggregate("MIN(\"$field\")"); } /** * Return the average value of the given field in this DataList * - * @param String $field Unquoted database column name (will be escaped automatically) + * @param String $field Unquoted database column name. Will be ANSI quoted + * automatically so must not contain double quotes. */ public function avg($field) { - return $this->aggregate(sprintf('AVG("%s")', Convert::raw2sql($field))); + return $this->aggregate("AVG(\"$field\")"); } /** * Return the sum of the values of the given field in this DataList * - * @param String $field Unquoted database column name (will be escaped automatically) + * @param String $field Unquoted database column name. Will be ANSI quoted + * automatically so must not contain double quotes. */ public function sum($field) { - return $this->aggregate(sprintf('SUM("%s")', Convert::raw2sql($field))); + return $this->aggregate("SUM(\"$field\")"); } /** @@ -396,9 +436,9 @@ class DataQuery { /** * Update the SELECT clause of the query with the columns from the given table */ - protected function selectColumnsFromTable(SQLQuery &$query, $tableClass, $columns = null) { + protected function selectColumnsFromTable(SQLSelect &$query, $tableClass, $columns = null) { // Add SQL for multi-value fields - $databaseFields = DataObject::database_fields($tableClass); + $databaseFields = DataObject::database_fields($tableClass, false); $compositeFields = DataObject::composite_fields($tableClass, false); if($databaseFields) foreach($databaseFields as $k => $v) { if((is_null($columns) || in_array($k, $columns)) && !isset($compositeFields[$k])) { @@ -463,18 +503,14 @@ class DataQuery { } /** - * Append a WHERE clause to this query. - * There are two different ways of doing this: + * Adds a WHERE clause. + * + * @see SQLSelect::addWhere() for syntax examples, although DataQuery + * won't expand multiple arguments as SQLSelect does. * - * - * // the entire predicate as a single string - * $query->where("\"Column\" = 'Value'"); - * - * // multiple predicates as an array - * $query->where(array("\"Column\" = 'Value'", "\"Column\" != 'Value'")); - * - * - * @param string|array $where Predicate(s) to set, as escaped SQL statements. + * @param string|array|SQLConditionGroup $filter Predicate(s) to set, as escaped SQL statements or + * paramaterised queries + * @return DataQuery */ public function where($filter) { if($filter) { @@ -486,10 +522,11 @@ class DataQuery { /** * Append a WHERE with OR. * - * @example $dataQuery->whereAny(array("\"Monkey\" = 'Chimp'", "\"Color\" = 'Brown'")); - * @see where() + * @see SQLSelect::addWhere() for syntax examples, although DataQuery + * won't expand multiple method arguments as SQLSelect does. * - * @param array $filter Escaped SQL statement. + * @param string|array|SQLConditionGroup $filter Predicate(s) to set, as escaped SQL statements or + * paramaterised queries * @return DataQuery */ public function whereAny($filter) { @@ -502,7 +539,7 @@ class DataQuery { /** * Set the ORDER BY clause of this query * - * @see SQLQuery::orderby() + * @see SQLSelect::orderby() * * @param String $sort Column to sort on (escaped SQL statement) * @param String $direction Direction ("ASC" or "DESC", escaped SQL statement) @@ -662,7 +699,8 @@ class DataQuery { $subSelect->setSelect(array()); $subSelect->selectField($fieldExpression, $field); $subSelect->setOrderBy(null); - $this->where($this->expressionForField($field).' NOT IN ('.$subSelect->sql().')'); + $subSelectSQL = $subSelect->sql($subSelectParameters); + $this->where(array($this->expressionForField($field)." NOT IN ($subSelectSQL)" => $subSelectParameters)); return $this; } @@ -674,9 +712,9 @@ class DataQuery { * @param Array $fields Database column names (will be escaped automatically) */ public function selectFromTable($table, $fields) { - $table = Convert::raw2sql($table); - $fieldExpressions = array_map(create_function('$item', - "return '\"$table\".\"' . Convert::raw2sql(\$item) . '\"';"), $fields); + $fieldExpressions = array_map(function($item) use($table) { + return "\"$table\".\"$item\""; + }, $fields); $this->query->setSelect($fieldExpressions); @@ -702,7 +740,7 @@ class DataQuery { /** * @param String $field Select statement identifier, either the unquoted column name, - * the full composite SQL statement, or the alias set through {@link SQLQuery->selectField()}. + * the full composite SQL statement, or the alias set through {@link SQLSelect->selectField()}. * @return String The expression used to query this field via this DataQuery */ protected function expressionForField($field) { @@ -767,41 +805,25 @@ class DataQuery { /** * Represents a subgroup inside a WHERE clause in a {@link DataQuery} * - * Stores the clauses for the subgroup inside a specific {@link SQLQuery} - * object. - * + * Stores the clauses for the subgroup inside a specific {@link SQLSelect} object. * All non-where methods call their DataQuery versions, which uses the base * query object. * * @package framework */ -class DataQuery_SubGroup extends DataQuery { +class DataQuery_SubGroup extends DataQuery implements SQLConditionGroup { protected $whereQuery; public function __construct(DataQuery $base, $connective) { $this->dataClass = $base->dataClass; $this->query = $base->query; - $this->whereQuery = new SQLQuery; + $this->whereQuery = new SQLSelect(); $this->whereQuery->setConnective($connective); $base->where($this); } - /** - * Set the WHERE clause of this query. - * There are two different ways of doing this: - * - * - * // the entire predicate as a single string - * $query->where("\"Column\" = 'Value'"); - * - * // multiple predicates as an array - * $query->where(array("\"Column\" = 'Value'", "\"Column\" != 'Value'")); - * - * - * @param string|array $where Predicate(s) to set, as escaped SQL statements. - */ public function where($filter) { if($filter) { $this->whereQuery->addWhere($filter); @@ -809,16 +831,7 @@ class DataQuery_SubGroup extends DataQuery { return $this; } - - /** - * Set a WHERE with OR. - * - * @example $dataQuery->whereAny(array("\"Monkey\" = 'Chimp'", "\"Color\" = 'Brown'")); - * @see where() - * - * @param array $filter Escaped SQL statement. - * @return DataQuery - */ + public function whereAny($filter) { if($filter) { $this->whereQuery->addWhereAny($filter); @@ -827,19 +840,15 @@ class DataQuery_SubGroup extends DataQuery { return $this; } - public function __toString() { - if(!$this->whereQuery->getWhere()) { - // We always need to have something so we don't end up with something like '... AND () AND ...' - return '1=1'; - } - - $sql = DB::getConn()->sqlWhereToString( - $this->whereQuery->getWhere(), - $this->whereQuery->getConnective() - ); + public function conditionSQL(&$parameters) { + $parameters = array(); - $sql = preg_replace('[^\s*WHERE\s*]', '', $sql); - - return $sql; + // Ignore empty conditions + $where = $this->whereQuery->getWhere(); + if(empty($where)) return null; + + // Allow database to manage joining of conditions + $sql = DB::get_conn()->getQueryBuilder()->buildWhereFragment($this->whereQuery, $parameters); + return preg_replace('/^\s*WHERE\s*/i', '', $sql); } } diff --git a/model/Database.php b/model/Database.php deleted file mode 100644 index 9ea005ae4..000000000 --- a/model/Database.php +++ /dev/null @@ -1,1055 +0,0 @@ - field spec. - * @param string $table The table name. - * @return array - */ - abstract protected function fieldList($table); - - /** - * Returns a list of all tables in the database. - * Keys are table names in lower case, values are table names in case that - * database expects. - * @return array - */ - - /** - * - * This is a stub function. Postgres caches the fieldlist results. - * - * @param string $tableName - * - * @return boolean - */ - public function clearCachedFieldlist($tableName=false){ - return true; - } - - abstract protected function tableList(); - - - /** - * Returns true if the given table exists in the database - */ - abstract public function hasTable($tableName); - - /** - * Returns the enum values available on the given field - */ - abstract public function enumValuesForField($tableName, $fieldName); - - /** - * Returns an escaped string. - * - * @param string - * @return string - escaped string - */ - abstract public function addslashes($val); - - /** - * The table list, generated by the tableList() function. - * Used by the requireTable() function. - * @var array - */ - protected $tableList; - - /** - * The field list, generated by the fieldList() function. - * An array of maps of field name => field spec, indexed - * by table name. - * @var array - */ - protected $fieldList; - - /** - * The index list for each table, generated by the indexList() function. - * An map from table name to an array of index names. - * @var array - */ - protected $indexList; - - /** - * Keeps track whether we are currently updating the schema. - */ - protected $schemaIsUpdating = false; - - /** - * Large array structure that represents a schema update transaction - */ - protected $schemaUpdateTransaction; - - /** - * Start a schema-updating transaction. - * All calls to requireTable/Field/Index will keep track of the changes requested, but not actually do anything. - * Once - */ - public function beginSchemaUpdate() { - $this->schemaIsUpdating = true; - $this->tableList = array(); - $tables = $this->tableList(); - foreach($tables as $table) $this->tableList[strtolower($table)] = $table; - - $this->indexList = null; - $this->fieldList = null; - $this->schemaUpdateTransaction = array(); - } - - /** - * Completes a schema-updated transaction, executing all the schema chagnes. - */ - public function endSchemaUpdate() { - foreach($this->schemaUpdateTransaction as $tableName => $changes) { - switch($changes['command']) { - case 'create': - $this->createTable($tableName, $changes['newFields'], $changes['newIndexes'], $changes['options'], - isset($changes['advancedOptions']) ? $changes['advancedOptions'] : null - ); - break; - - case 'alter': - $this->alterTable($tableName, $changes['newFields'], $changes['newIndexes'], - $changes['alteredFields'], $changes['alteredIndexes'], $changes['alteredOptions'], - isset($changes['advancedOptions']) ? $changes['advancedOptions'] : null - ); - break; - } - } - $this->schemaUpdateTransaction = null; - $this->schemaIsUpdating = false; - } - - /** - * Cancels the schema updates requested after a beginSchemaUpdate() call. - */ - public function cancelSchemaUpdate() { - $this->schemaUpdateTransaction = null; - $this->schemaIsUpdating = false; - } - - /** - * Returns true if we are during a schema update. - */ - function isSchemaUpdating() { - return $this->schemaIsUpdating; - } - - /** - * Returns true if schema modifications were requested after a beginSchemaUpdate() call. - */ - public function doesSchemaNeedUpdating() { - return (bool)$this->schemaUpdateTransaction; - } - - // Transactional schema altering functions - they don't do anyhting except for update schemaUpdateTransaction - - /** - * @param string $table - * @param string $options - */ - public function transCreateTable($table, $options = null, $advanced_options = null) { - $this->schemaUpdateTransaction[$table] = array( - 'command' => 'create', - 'newFields' => array(), - 'newIndexes' => array(), - 'options' => $options, - 'advancedOptions' => $advanced_options - ); - } - - /** - * @param string $table - * @param array $options - */ - public function transAlterTable($table, $options, $advanced_options) { - $this->transInitTable($table); - $this->schemaUpdateTransaction[$table]['alteredOptions'] = $options; - $this->schemaUpdateTransaction[$table]['advancedOptions'] = $advanced_options; - } - - public function transCreateField($table, $field, $schema) { - $this->transInitTable($table); - $this->schemaUpdateTransaction[$table]['newFields'][$field] = $schema; - } - public function transCreateIndex($table, $index, $schema) { - $this->transInitTable($table); - $this->schemaUpdateTransaction[$table]['newIndexes'][$index] = $schema; - } - public function transAlterField($table, $field, $schema) { - $this->transInitTable($table); - $this->schemaUpdateTransaction[$table]['alteredFields'][$field] = $schema; - } - public function transAlterIndex($table, $index, $schema) { - $this->transInitTable($table); - $this->schemaUpdateTransaction[$table]['alteredIndexes'][$index] = $schema; - } - - /** - * Handler for the other transXXX methods - mark the given table as being altered - * if it doesn't already exist - */ - protected function transInitTable($table) { - if(!isset($this->schemaUpdateTransaction[$table])) { - $this->schemaUpdateTransaction[$table] = array( - 'command' => 'alter', - 'newFields' => array(), - 'newIndexes' => array(), - 'alteredFields' => array(), - 'alteredIndexes' => array(), - 'alteredOptions' => '' - ); - } - } - - - /** - * Generate the following table in the database, modifying whatever already exists - * as necessary. - * @todo Change detection for CREATE TABLE $options other than "Engine" - * - * @param string $table The name of the table - * @param string $fieldSchema A list of the fields to create, in the same form as DataObject::$db - * @param string $indexSchema A list of indexes to create. See {@link requireIndex()} - * @param array $options - */ - public function requireTable($table, $fieldSchema = null, $indexSchema = null, $hasAutoIncPK=true, - $options = Array(), $extensions=false) { - - if(!isset($this->tableList[strtolower($table)])) { - $this->transCreateTable($table, $options, $extensions); - $this->alterationMessage("Table $table: created","created"); - } else { - if(Config::inst()->get('SS_Database', 'check_and_repair_on_build')) { - $this->checkAndRepairTable($table, $options); - } - - // Check if options changed - $tableOptionsChanged = false; - if(isset($options[get_class($this)]) || true) { - if(isset($options[get_class($this)])) { - if(preg_match('/ENGINE=([^\s]*)/', $options[get_class($this)], $alteredEngineMatches)) { - $alteredEngine = $alteredEngineMatches[1]; - $tableStatus = DB::query(sprintf( - 'SHOW TABLE STATUS LIKE \'%s\'', - $table - ))->first(); - $tableOptionsChanged = ($tableStatus['Engine'] != $alteredEngine); - } - } - } - - if($tableOptionsChanged || ($extensions && DB::getConn()->supportsExtensions())) - $this->transAlterTable($table, $options, $extensions); - - } - - //DB ABSTRACTION: we need to convert this to a db-specific version: - $this->requireField($table, 'ID', DB::getConn()->IdColumn(false, $hasAutoIncPK)); - - // Create custom fields - if($fieldSchema) { - foreach($fieldSchema as $fieldName => $fieldSpec) { - - //Is this an array field? - $arrayValue=''; - if(strpos($fieldSpec, '[')!==false){ - //If so, remove it and store that info separately - $pos=strpos($fieldSpec, '['); - $arrayValue=substr($fieldSpec, $pos); - $fieldSpec=substr($fieldSpec, 0, $pos); - } - - $fieldObj = Object::create_from_string($fieldSpec, $fieldName); - $fieldObj->arrayValue=$arrayValue; - - $fieldObj->setTable($table); - $fieldObj->requireField(); - } - } - - // Create custom indexes - if($indexSchema) { - foreach($indexSchema as $indexName => $indexDetails) { - $this->requireIndex($table, $indexName, $indexDetails); - } - } - } - - /** - * If the given table exists, move it out of the way by renaming it to _obsolete_(tablename). - * @param string $table The table name. - */ - public function dontRequireTable($table) { - if(isset($this->tableList[strtolower($table)])) { - $suffix = ''; - while(isset($this->tableList[strtolower("_obsolete_{$table}$suffix")])) { - $suffix = $suffix ? ($suffix+1) : 2; - } - $this->renameTable($table, "_obsolete_{$table}$suffix"); - $this->alterationMessage("Table $table: renamed to _obsolete_{$table}$suffix","obsolete"); - } - } - - /** - * Generate the given index in the database, modifying whatever already exists as necessary. - * - * The keys of the array are the names of the index. - * The values of the array can be one of: - * - true: Create a single column index on the field named the same as the index. - * - array('type' => 'index|unique|fulltext', 'value' => 'FieldA, FieldB'): This gives you full - * control over the index. - * - * @param string $table The table name. - * @param string $index The index name. - * @param string|boolean $spec The specification of the index. See requireTable() for more information. - */ - public function requireIndex($table, $index, $spec) { - $newTable = false; - - //DB Abstraction: remove this ===true option as a possibility? - if($spec === true) { - $spec = "(\"$index\")"; - } - - //Indexes specified as arrays cannot be checked with this line: (it flattens out the array) - if(!is_array($spec)) { - $spec = preg_replace('/\s*,\s*/', ',', $spec); - } - - if(!isset($this->tableList[strtolower($table)])) $newTable = true; - - if(!$newTable && !isset($this->indexList[$table])) { - $this->indexList[$table] = $this->indexList($table); - } - - //Fix up the index for database purposes - $index=DB::getConn()->getDbSqlDefinition($table, $index, null, true); - - //Fix the key for database purposes - $index_alt=DB::getConn()->modifyIndex($index, $spec); - - if(!$newTable) { - if(isset($this->indexList[$table][$index_alt])) { - if(is_array($this->indexList[$table][$index_alt])) { - $array_spec = $this->indexList[$table][$index_alt]['spec']; - } else { - $array_spec = $this->indexList[$table][$index_alt]; - } - } - } - - if($newTable || !isset($this->indexList[$table][$index_alt])) { - $this->transCreateIndex($table, $index, $spec); - $this->alterationMessage("Index $table.$index: created as " - . DB::getConn()->convertIndexSpec($spec),"created"); - } else if($array_spec != DB::getConn()->convertIndexSpec($spec)) { - $this->transAlterIndex($table, $index, $spec); - $spec_msg=DB::getConn()->convertIndexSpec($spec); - $this->alterationMessage("Index $table.$index: changed to $spec_msg" - . " (from {$array_spec})","changed"); - } - } - - /** - * Return true if the table exists and already has a the field specified - * @param string $tableName - The table to check - * @param string $fieldName - The field to check - * @return bool - True if the table exists and the field exists on the table - */ - public function hasField($tableName, $fieldName) { - if (!$this->hasTable($tableName)) return false; - $fields = $this->fieldList($tableName); - return array_key_exists($fieldName, $fields); - } - - /** - * Generate the given field on the table, modifying whatever already exists as necessary. - * @param string $table The table name. - * @param string $field The field name. - * @param array|string $spec The field specification. If passed in array syntax, the specific database - * driver takes care of the ALTER TABLE syntax. If passed as a string, its assumed to - * be prepared as a direct SQL framgment ready for insertion into ALTER TABLE. In this case you'll - * need to take care of database abstraction in your DBField subclass. - */ - public function requireField($table, $field, $spec) { - //TODO: this is starting to get extremely fragmented. - //There are two different versions of $spec floating around, and their content changes depending - //on how they are structured. This needs to be tidied up. - $fieldValue = null; - $newTable = false; - - // backwards compatibility patch for pre 2.4 requireField() calls - $spec_orig=$spec; - - if(!is_string($spec)) { - $spec['parts']['name'] = $field; - $spec_orig['parts']['name'] = $field; - //Convert the $spec array into a database-specific string - $spec=DB::getConn()->$spec['type']($spec['parts'], true); - } - - // Collations didn't come in until MySQL 4.1. Anything earlier will throw a syntax error if you try and use - // collations. - // TODO: move this to the MySQLDatabase file, or drop it altogether? - if(!$this->supportsCollations()) { - $spec = preg_replace('/ *character set [^ ]+( collate [^ ]+)?( |$)/', '\\2', $spec); - } - - if(!isset($this->tableList[strtolower($table)])) $newTable = true; - - if(!$newTable && !isset($this->fieldList[$table])) { - $this->fieldList[$table] = $this->fieldList($table); - } - - if(is_array($spec)) { - $specValue = DB::getConn()->$spec_orig['type']($spec_orig['parts']); - } else { - $specValue = $spec; - } - - // We need to get db-specific versions of the ID column: - if($spec_orig==DB::getConn()->IdColumn() || $spec_orig==DB::getConn()->IdColumn(true)) - $specValue=DB::getConn()->IdColumn(true); - - if(!$newTable) { - if(isset($this->fieldList[$table][$field])) { - if(is_array($this->fieldList[$table][$field])) { - $fieldValue = $this->fieldList[$table][$field]['data_type']; - } else { - $fieldValue = $this->fieldList[$table][$field]; - } - } - } - - // Get the version of the field as we would create it. This is used for comparison purposes to see if the - // existing field is different to what we now want - if(is_array($spec_orig)) { - $spec_orig=DB::getConn()->$spec_orig['type']($spec_orig['parts']); - } - - if($newTable || $fieldValue=='') { - $this->transCreateField($table, $field, $spec_orig); - $this->alterationMessage("Field $table.$field: created as $spec_orig","created"); - } else if($fieldValue != $specValue) { - // If enums/sets are being modified, then we need to fix existing data in the table. - // Update any records where the enum is set to a legacy value to be set to the default. - // One hard-coded exception is SiteTree - the default for this is Page. - foreach(array('enum','set') as $enumtype) { - if(preg_match("/^$enumtype/i",$specValue)) { - $newStr = preg_replace("/(^$enumtype\s*\(')|('$\).*)/i","",$spec_orig); - $new = preg_split("/'\s*,\s*'/", $newStr); - - $oldStr = preg_replace("/(^$enumtype\s*\(')|('$\).*)/i","", $fieldValue); - $old = preg_split("/'\s*,\s*'/", $newStr); - - $holder = array(); - foreach($old as $check) { - if(!in_array($check, $new)) { - $holder[] = $check; - } - } - if(count($holder)) { - $default = explode('default ', $spec_orig); - $default = $default[1]; - if($default == "'SiteTree'") $default = "'Page'"; - $query = "UPDATE \"$table\" SET $field=$default WHERE $field IN ("; - for($i=0;$i+1alterationMessage("Changed $amount rows to default value of field $field" - . " (Value: $default)"); - } - } - } - $this->transAlterField($table, $field, $spec_orig); - $this->alterationMessage( - "Field $table.$field: changed to $specValue (from {$fieldValue})", - "changed" - ); - } - } - - /** - * If the given field exists, move it out of the way by renaming it to _obsolete_(fieldname). - * - * @param string $table - * @param string $fieldName - */ - public function dontRequireField($table, $fieldName) { - $fieldList = $this->fieldList($table); - if(array_key_exists($fieldName, $fieldList)) { - $suffix = ''; - while(isset($fieldList[strtolower("_obsolete_{$fieldName}$suffix")])) { - $suffix = $suffix ? ($suffix+1) : 2; - } - $this->renameField($table, $fieldName, "_obsolete_{$fieldName}$suffix"); - $this->alterationMessage("Field $table.$fieldName: renamed to $table._obsolete_{$fieldName}$suffix", - "obsolete"); - } - } - - /** - * Execute a complex manipulation on the database. - * A manipulation is an array of insert / or update sequences. The keys of the array are table names, - * and the values are map containing 'command' and 'fields'. Command should be 'insert' or 'update', - * and fields should be a map of field names to field values, including quotes. The field value can - * also be a SQL function or similar. - * @param array $manipulation - */ - public function manipulate($manipulation) { - if($manipulation) foreach($manipulation as $table => $writeInfo) { - - if(isset($writeInfo['fields']) && $writeInfo['fields']) { - $fieldList = $columnList = $valueList = array(); - foreach($writeInfo['fields'] as $fieldName => $fieldVal) { - $fieldList[] = "\"$fieldName\" = $fieldVal"; - $columnList[] = "\"$fieldName\""; - - // Empty strings inserted as null in INSERTs. Replacement of SS_Database::replace_with_null(). - if($fieldVal === "''") $valueList[] = "null"; - else $valueList[] = $fieldVal; - } - - if(!isset($writeInfo['where']) && isset($writeInfo['id'])) { - $writeInfo['where'] = "\"ID\" = " . (int)$writeInfo['id']; - } - - switch($writeInfo['command']) { - case "update": - // Test to see if this update query shouldn't, in fact, be an insert - if($this->query("SELECT \"ID\" FROM \"$table\" WHERE $writeInfo[where]")->value()) { - $fieldList = implode(", ", $fieldList); - $sql = "UPDATE \"$table\" SET $fieldList where $writeInfo[where]"; - $this->query($sql); - break; - } - - // ...if not, we'll skip on to the insert code - - case "insert": - if(!isset($writeInfo['fields']['ID']) && isset($writeInfo['id'])) { - $columnList[] = "\"ID\""; - $valueList[] = (int)$writeInfo['id']; - } - - $columnList = implode(", ", $columnList); - $valueList = implode(", ", $valueList); - $sql = "INSERT INTO \"$table\" ($columnList) VALUES ($valueList)"; - $this->query($sql); - break; - - default: - $sql = null; - user_error("SS_Database::manipulate() Can't recognise command '$writeInfo[command]'", - E_USER_ERROR); - } - } - } - } - - /** Replaces "\'\'" with "null", recursively walks through the given array. - * @param string $array Array where the replacement should happen - */ - public static function replace_with_null(&$array) { - $array = preg_replace('/= *\'\'/', '= null', $array); - - if(is_array($array)) { - foreach($array as $key => $value) { - if(is_array($value)) { - array_walk($array, array(SS_Database, 'replace_with_null')); - } - } - } - - return $array; - } - - /** - * Error handler for database errors. - * All database errors will call this function to report the error. It isn't a static function; - * it will be called on the object itself and as such can be overridden in a subclass. - * @todo hook this into a more well-structured error handling system. - * @param string $msg The error message. - * @param int $errorLevel The level of the error to throw. - */ - public function databaseError($msg, $errorLevel = E_USER_ERROR) { - user_error($msg, $errorLevel); - } - - /** - * Enable supression of database messages. - */ - public function quiet() { - $this->supressOutput = true; - } - - /** - * Show a message about database alteration - * - * @param string message to display - * @param string type one of [created|changed|repaired|obsolete|deleted|error] - */ - public function alterationMessage($message,$type=""){ - if(!$this->supressOutput) { - if(Director::is_cli()) { - switch ($type){ - case "created": - case "changed": - case "repaired": - $sign = "+"; - break; - case "obsolete": - case "deleted": - $sign = '-'; - break; - case "error": - $sign = "!"; - break; - default: - $sign=" "; - } - $message = strip_tags($message); - echo " $sign $message\n"; - } else { - switch ($type){ - case "created": - $color = "green"; - break; - case "obsolete": - $color = "red"; - break; - case "error": - $color = "red"; - break; - case "deleted": - $color = "red"; - break; - case "changed": - $color = "blue"; - break; - case "repaired": - $color = "blue"; - break; - default: - $color=""; - } - echo "
  • $message
  • "; - } - } - } - - /** - * Returns the SELECT clauses ready for inserting into a query. - * Caution: Expects correctly quoted and escaped SQL fragments. - * - * @param array $select Select columns - * @param boolean $distinct Distinct select? - * @return string - */ - public function sqlSelectToString($select, $distinct = false) { - $clauses = array(); - - foreach($select as $alias => $field) { - // Don't include redundant aliases. - if($alias === $field || preg_match('/"' . preg_quote($alias) . '"$/', $field)) $clauses[] = $field; - else $clauses[] = "$field AS \"$alias\""; - } - - $text = 'SELECT '; - if($distinct) $text .= 'DISTINCT '; - return $text .= implode(', ', $clauses); - } - - /** - * Return the FROM clause ready for inserting into a query. - * Caution: Expects correctly quoted and escaped SQL fragments. - * - * @return string - */ - public function sqlFromToString($from) { - return ' FROM ' . implode(' ', $from); - } - - /** - * Returns the WHERE clauses ready for inserting into a query. - * Caution: Expects correctly quoted and escaped SQL fragments. - * - * @return string - */ - public function sqlWhereToString($where, $connective) { - return ' WHERE (' . implode(") {$connective} (" , $where) . ')'; - } - - /** - * Returns the ORDER BY clauses ready for inserting into a query. - * Caution: Expects correctly quoted and escaped SQL fragments. - * - * @return string - */ - public function sqlOrderByToString($orderby) { - $statements = array(); - - foreach($orderby as $clause => $dir) { - $statements[] = trim($clause . ' ' . $dir); - } - - return ' ORDER BY ' . implode(', ', $statements); - } - - /** - * Returns the GROUP BY clauses ready for inserting into a query. - * Caution: Expects correctly quoted and escaped SQL fragments. - * - * @return string - */ - public function sqlGroupByToString($groupby) { - return ' GROUP BY ' . implode(', ', $groupby); - } - - /** - * Returns the HAVING clauses ready for inserting into a query. - * Caution: Expects correctly quoted and escaped SQL fragments. - * - * @return string - */ - public function sqlHavingToString($having) { - return ' HAVING ( ' . implode(' ) AND ( ', $having) . ')'; - } - - /** - * Return the LIMIT clause ready for inserting into a query. - * Caution: Expects correctly quoted and escaped SQL fragments. - * - * @return string - */ - public function sqlLimitToString($limit) { - $clause = ''; - - // Pass limit as array or SQL string value - if(is_array($limit)) { - if(!array_key_exists('limit', $limit)) { - throw new InvalidArgumentException('Database::sqlLimitToString(): Wrong format for $limit: ' - . var_export($limit, true)); - } - - if(isset($limit['start']) && is_numeric($limit['start']) && isset($limit['limit']) - && is_numeric($limit['limit'])) { - - $combinedLimit = $limit['start'] ? "$limit[limit] OFFSET $limit[start]" : "$limit[limit]"; - } elseif(isset($limit['limit']) && is_numeric($limit['limit'])) { - $combinedLimit = (int)$limit['limit']; - } else { - $combinedLimit = false; - } - if(!empty($combinedLimit)) $clause .= ' LIMIT ' . $combinedLimit; - } else { - $clause .= ' LIMIT ' . $limit; - } - - return $clause; - } - - /** - * Convert a SQLQuery object into a SQL statement - * Caution: Expects correctly quoted and escaped SQL fragments. - * - * @param $query SQLQuery - */ - public function sqlQueryToString(SQLQuery $query) { - if($query->getDelete()) { - $text = 'DELETE '; - } else { - $text = $this->sqlSelectToString($query->getSelect(), $query->getDistinct()); - } - - if($query->getFrom()) $text .= $this->sqlFromToString($query->getFrom()); - if($query->getWhere()) $text .= $this->sqlWhereToString($query->getWhere(), $query->getConnective()); - - // these clauses only make sense in SELECT queries, not DELETE - if(!$query->getDelete()) { - if($query->getGroupBy()) $text .= $this->sqlGroupByToString($query->getGroupBy()); - if($query->getHaving()) $text .= $this->sqlHavingToString($query->getHaving()); - if($query->getOrderBy()) $text .= $this->sqlOrderByToString($query->getOrderBy()); - if($query->getLimit()) $text .= $this->sqlLimitToString($query->getLimit()); - } - - return $text; - } - - /** - * Wrap a string into DB-specific quotes. MySQL, PostgreSQL and SQLite3 only need single quotes around the string. - * MSSQL will overload this and include it's own N prefix to mark the string as unicode, so characters like macrons - * are saved correctly. - * - * @param string $string String to be prepared for database query - * @return string Prepared string - */ - public function prepStringForDB($string) { - return "'" . Convert::raw2sql($string) . "'"; - } - - /** - * Generate a WHERE clause for text matching. - * - * @param String $field Quoted field name - * @param String $value Escaped search. Can include percentage wildcards. - * @param boolean $exact Exact matches or wildcard support. - * @param boolean $negate Negate the clause. - * @param boolean $caseSensitive Perform case sensitive search. - * @return String SQL - */ - abstract public function comparisonClause($field, $value, $exact = false, $negate = false, $caseSensitive = false); - - /** - * function to return an SQL datetime expression that can be used with the adapter in use - * used for querying a datetime in a certain format - * @param string $date to be formated, can be either 'now', literal datetime like '1973-10-14 10:30:00' or - * field name, e.g. '"SiteTree"."Created"' - * @param string $format to be used, supported specifiers: - * %Y = Year (four digits) - * %m = Month (01..12) - * %d = Day (01..31) - * %H = Hour (00..23) - * %i = Minutes (00..59) - * %s = Seconds (00..59) - * %U = unix timestamp, can only be used on it's own - * @return string SQL datetime expression to query for a formatted datetime - */ - abstract public function formattedDatetimeClause($date, $format); - - /** - * function to return an SQL datetime expression that can be used with the adapter in use - * used for querying a datetime addition - * @param string $date, can be either 'now', literal datetime like '1973-10-14 10:30:00' or field name, - * e.g. '"SiteTree"."Created"' - * @param string $interval to be added, use the format [sign][integer] [qualifier], e.g. -1 Day, +15 minutes, - * +1 YEAR - * supported qualifiers: - * - years - * - months - * - days - * - hours - * - minutes - * - seconds - * This includes the singular forms as well - * @return string SQL datetime expression to query for a datetime (YYYY-MM-DD hh:mm:ss) which is the result of - * the addition - */ - abstract public function datetimeIntervalClause($date, $interval); - - /** - * function to return an SQL datetime expression that can be used with the adapter in use - * used for querying a datetime substraction - * @param string $date1, can be either 'now', literal datetime like '1973-10-14 10:30:00' or field name - * e.g. '"SiteTree"."Created"' - * @param string $date2 to be substracted of $date1, can be either 'now', literal datetime - * like '1973-10-14 10:30:00' or field name, e.g. '"SiteTree"."Created"' - * @return string SQL datetime expression to query for the interval between $date1 and $date2 in seconds which - * is the result of the substraction - */ - abstract public function datetimeDifferenceClause($date1, $date2); - - /** - * Can the database override timezone as a connection setting, - * or does it use the system timezone exclusively? - * - * @return Boolean - */ - abstract public function supportsTimezoneOverride(); - - /* - * Does this database support transactions? - * - * @return boolean - */ - abstract public function supportsTransactions(); - - /* - * Start a prepared transaction - * See http://developer.postgresql.org/pgdocs/postgres/sql-set-transaction.html for details on - * transaction isolation options - */ - abstract public function transactionStart($transaction_mode=false, $session_characteristics=false); - - /* - * Create a savepoint that you can jump back to if you encounter problems - */ - abstract public function transactionSavepoint($savepoint); - - /* - * Rollback or revert to a savepoint if your queries encounter problems - * If you encounter a problem at any point during a transaction, you may - * need to rollback that particular query, or return to a savepoint - */ - abstract public function transactionRollback($savepoint=false); - - /* - * Commit everything inside this transaction so far - */ - abstract public function transactionEnd(); - - /** - * Determines if the used database supports application-level locks, - * which is different from table- or row-level locking. - * See {@link getLock()} for details. - * - * @return boolean - */ - public function supportsLocks() { - return false; -} - -/** - * Returns if the lock is available. - * See {@link supportsLocks()} to check if locking is generally supported. - * - * @return Boolean - */ - public function canLock($name) { - return false; - } - - /** - * Sets an application-level lock so that no two processes can run at the same time, - * also called a "cooperative advisory lock". - * - * Return FALSE if acquiring the lock fails; otherwise return TRUE, if lock was acquired successfully. - * Lock is automatically released if connection to the database is broken (either normally or abnormally), - * making it less prone to deadlocks than session- or file-based locks. - * Should be accompanied by a {@link releaseLock()} call after the logic requiring the lock has completed. - * Can be called multiple times, in which case locks "stack" (PostgreSQL, SQL Server), - * or auto-releases the previous lock (MySQL). - * - * Note that this might trigger the database to wait for the lock to be released, delaying further execution. - * - * @param String - * @param Int Timeout in seconds - * @return Boolean - */ - public function getLock($name, $timeout = 5) { - return false; - } - - /** - * Remove an application-level lock file to allow another process to run - * (if the execution aborts (e.g. due to an error) all locks are automatically released). - * - * @param String - * @return Boolean - */ - public function releaseLock($name) { - return false; - } -} diff --git a/model/DatabaseAdmin.php b/model/DatabaseAdmin.php index 94b0c37a9..302dcb832 100644 --- a/model/DatabaseAdmin.php +++ b/model/DatabaseAdmin.php @@ -155,11 +155,11 @@ class DatabaseAdmin extends Controller { if($quiet) { DB::quiet(); } else { - $conn = DB::getConn(); + $conn = DB::get_conn(); // Assumes database class is like "MySQLDatabase" or "MSSQLDatabase" (suffixed with "Database") $dbType = substr(get_class($conn), 0, -8); $dbVersion = $conn->getVersion(); - $databaseName = (method_exists($conn, 'currentDatabase')) ? $conn->currentDatabase() : ""; + $databaseName = (method_exists($conn, 'currentDatabase')) ? $conn->getSelectedDatabase() : ""; if(Director::is_cli()) { echo sprintf("\n\nBuilding database %s using %s %s\n\n", $databaseName, $dbType, $dbVersion); @@ -169,23 +169,29 @@ class DatabaseAdmin extends Controller { } // Set up the initial database - if(!DB::isActive()) { + if(!DB::is_active()) { if(!$quiet) { echo '

    Creating database

    '; } + + // Load parameters from existing configuration global $databaseConfig; - $parameters = $databaseConfig ? $databaseConfig : $_REQUEST['db']; - $connect = DB::getConnect($parameters); - $username = $parameters['username']; - $password = $parameters['password']; - $database = $parameters['database']; - - if(!$database) { - user_error("No database name given; please give a value for \$databaseConfig['database']", - E_USER_ERROR); + if(empty($databaseConfig) && empty($_REQUEST['db'])) { + user_error("No database configuration available", E_USER_ERROR); } - - DB::createDatabase($connect, $username, $password, $database); + $parameters = (!empty($databaseConfig)) ? $databaseConfig : $_REQUEST['db']; + + // Check database name is given + if(empty($parameters['database'])) { + user_error("No database name given; please give a value for \$databaseConfig['database']", + E_USER_ERROR); + } + $database = $parameters['database']; + + // Establish connection and create database in two steps + unset($parameters['database']); + DB::connect($parameters); + DB::create_database($database); } // Build the database. Most of the hard work is handled by DataObject @@ -197,22 +203,27 @@ class DatabaseAdmin extends Controller { else echo "\n

    Creating database tables

    \n\n"; } - $conn = DB::getConn(); - $conn->beginSchemaUpdate(); - foreach($dataClasses as $dataClass) { - // Check if class exists before trying to instantiate - this sidesteps any manifest weirdness - if(class_exists($dataClass)) { + // Initiate schema update + $dbSchema = DB::get_schema(); + $dbSchema->schemaUpdate(function() use($dataClasses, $testMode, $quiet){ + foreach($dataClasses as $dataClass) { + // Check if class exists before trying to instantiate - this sidesteps any manifest weirdness + if(!class_exists($dataClass)) continue; + + // Check if this class should be excluded as per testing conventions $SNG = singleton($dataClass); - if($testMode || !($SNG instanceof TestOnly)) { - if(!$quiet) { - if(Director::is_cli()) echo " * $dataClass\n"; - else echo "
  • $dataClass
  • \n"; - } - $SNG->requireTable(); + if(!$testMode && $SNG instanceof TestOnly) continue; + + // Log data + if(!$quiet) { + if(Director::is_cli()) echo " * $dataClass\n"; + else echo "
  • $dataClass
  • \n"; } + + // Instruct the class to apply its schema to the database + $SNG->requireTable(); } - } - $conn->endSchemaUpdate(); + }); ClassInfo::reset_db_cache(); if($populate) { @@ -235,8 +246,10 @@ class DatabaseAdmin extends Controller { } } - touch(TEMP_FOLDER . '/database-last-generated-' . - str_replace(array('\\', '/', ':'), '.', Director::baseFolder())); + touch(TEMP_FOLDER + . '/database-last-generated-' + . str_replace(array('\\', '/', ':'), '.', Director::baseFolder()) + ); if(isset($_REQUEST['from_installer'])) { echo "OK"; @@ -251,14 +264,12 @@ class DatabaseAdmin extends Controller { /** * Clear all data out of the database - * @todo Move this code into SS_Database class, for DB abstraction + * + * @deprecated since version 3.2 */ public function clearAllData() { - $tables = DB::getConn()->tableList(); - foreach($tables as $table) { - if(method_exists(DB::getConn(), 'clearTable')) DB::getConn()->clearTable($table); - else DB::query("TRUNCATE \"$table\""); - } + Deprecation::notice('3.2', 'Use DB::get_conn()->clearAllData() instead'); + DB::get_conn()->clearAllData(); } /** @@ -278,7 +289,7 @@ class DatabaseAdmin extends Controller { $subclasses = ClassInfo::subclassesFor($baseClass); unset($subclasses[0]); foreach($subclasses as $k => $subclass) { - if(DataObject::database_fields($subclass)) { + if(DataObject::has_own_table($subclass)) { unset($subclasses[$k]); } } @@ -309,5 +320,3 @@ class DatabaseAdmin extends Controller { } } - - diff --git a/model/HasManyList.php b/model/HasManyList.php index 9dc193b7b..5cc60bd9f 100644 --- a/model/HasManyList.php +++ b/model/HasManyList.php @@ -38,12 +38,11 @@ class HasManyList extends RelationList { if ($id === null) $id = $this->getForeignID(); // Apply relation filter + $key = "\"$this->foreignKey\""; if(is_array($id)) { - return "\"$this->foreignKey\" IN ('" . - implode("', '", array_map('Convert::raw2sql', $id)) . "')"; + return array("$key IN (".DB::placeholders($id).")" => $id); } else if($id !== null){ - return "\"$this->foreignKey\" = '" . - Convert::raw2sql($id) . "'"; + return array($key => $id); } } @@ -73,8 +72,8 @@ class HasManyList extends RelationList { return; } - $fk = $this->foreignKey; - $item->$fk = $foreignID; + $foreignKey = $this->foreignKey; + $item->$foreignKey = $foreignID; $item->write(); } diff --git a/model/Hierarchy.php b/model/Hierarchy.php index 3f20c1de9..72b56da38 100644 --- a/model/Hierarchy.php +++ b/model/Hierarchy.php @@ -38,7 +38,7 @@ class Hierarchy extends DataExtension { */ private static $node_threshold_leaf = 250; - public function augmentSQL(SQLQuery &$query) { + public function augmentSQL(SQLSelect $query) { } public function augmentDatabase() { @@ -658,12 +658,14 @@ class Hierarchy extends DataExtension { * Get the parent of this class. * @return DataObject */ - public function getParent($filter = '') { + public function getParent($filter = null) { if($p = $this->owner->__get("ParentID")) { $tableClasses = ClassInfo::dataClassesFor($this->owner->class); $baseClass = array_shift($tableClasses); - $filter .= ($filter) ? " AND " : ""."\"$baseClass\".\"ID\" = $p"; - return DataObject::get_one($this->owner->class, $filter); + return DataObject::get_one($this->owner->class, array( + array("\"$baseClass\".\"ID\"" => $p), + $filter + )); } } @@ -777,4 +779,3 @@ class Hierarchy extends DataExtension { } } - diff --git a/model/Image.php b/model/Image.php index 40c0c351a..4ba07c428 100644 --- a/model/Image.php +++ b/model/Image.php @@ -253,7 +253,7 @@ class Image extends File { * Resize this Image by width, keeping aspect ratio. Use in templates with $SetWidth. * * @param Image_Backend $backend - * @param type $width The width to set + * @param int $width The width to set * @return Image_Backend */ public function generateSetWidth(Image_Backend $backend, $width) { diff --git a/model/ManyManyList.php b/model/ManyManyList.php index 0f3b789ce..6cf6b7f65 100644 --- a/model/ManyManyList.php +++ b/model/ManyManyList.php @@ -50,23 +50,20 @@ class ManyManyList extends RelationList { if($extraFields) $this->dataQuery->selectFromTable($joinTable, array_keys($extraFields)); } - /** - * Return a filter expression for when getting the contents of the relationship for some foreign ID - * @return string - */ protected function foreignIDFilter($id = null) { - if ($id === null) $id = $this->getForeignID(); + if ($id === null) { + $id = $this->getForeignID(); + } // Apply relation filter + $key = "\"$this->joinTable\".\"$this->foreignKey\""; if(is_array($id)) { - return "\"$this->joinTable\".\"$this->foreignKey\" IN ('" . - implode("', '", array_map('Convert::raw2sql', $id)) . "')"; + return array("$key IN (".DB::placeholders($id).")" => $id); } else if($id !== null){ - return "\"$this->joinTable\".\"$this->foreignKey\" = '" . - Convert::raw2sql($id) . "'"; + return array($key => $id); } } - + /** * Return a filter expression for the join table when writing to the join table * @@ -74,7 +71,9 @@ class ManyManyList extends RelationList { * entries. However some subclasses of ManyManyList (Member_GroupSet) modify foreignIDFilter to * include additional calculated entries, so we need different filters when reading and when writing * - * @return string + * @param array|integer $id (optional) An ID or an array of IDs - if not provided, will use the current ids + * as per getForeignID + * @return array Condition In array(SQL => parameters format) */ protected function foreignIDWriteFilter($id = null) { return $this->foreignIDFilter($id); @@ -83,53 +82,68 @@ class ManyManyList extends RelationList { /** * Add an item to this many_many relationship * Does so by adding an entry to the joinTable. + * * @param $extraFields A map of additional columns to insert into the joinTable */ - public function add($item, $extraFields = null) { - if(is_numeric($item)) $itemID = $item; - else if($item instanceof $this->dataClass) $itemID = $item->ID; - else { + public function add($item, $extraFields = array()) { + // Ensure nulls or empty strings are correctly treated as empty arrays + if(empty($extraFields)) $extraFields = array(); + + // Determine ID of new record + if(is_numeric($item)) { + $itemID = $item; + } elseif($item instanceof $this->dataClass) { + $itemID = $item->ID; + } else { throw new InvalidArgumentException("ManyManyList::add() expecting a $this->dataClass object, or ID value", E_USER_ERROR); } - $foreignIDs = $this->getForeignID(); - $foreignFilter = $this->foreignIDWriteFilter(); - // Validate foreignID - if(!$foreignIDs) { + $foreignIDs = $this->getForeignID(); + if(empty($foreignIDs)) { throw new Exception("ManyManyList::add() can't be called until a foreign ID is set", E_USER_WARNING); } - if($foreignFilter) { - $query = new SQLQuery("*", array("\"$this->joinTable\"")); - $query->setWhere($foreignFilter); - $hasExisting = ($query->count() > 0); - } else { - $hasExisting = false; - } - - // Insert or update - foreach((array)$foreignIDs as $foreignID) { - $manipulation = array(); - if($hasExisting) { - $manipulation[$this->joinTable]['command'] = 'update'; - $manipulation[$this->joinTable]['where'] = "\"$this->joinTable\".\"$this->foreignKey\" = " . - "'" . Convert::raw2sql($foreignID) . "'" . - " AND \"$this->localKey\" = {$itemID}"; + // Apply this item to each given foreign ID record + if(!is_array($foreignIDs)) $foreignIDs = array($foreignIDs); + $baseClass = ClassInfo::baseDataClass($this->dataClass); + foreach($foreignIDs as $foreignID) { + + // Check for existing records for this item + if($foreignFilter = $this->foreignIDFilter($foreignID)) { + // With the current query, simply add the foreign and local conditions + // The query can be a bit odd, especially if custom relation classes + // don't join expected tables (@see Member_GroupSet for example). + $query = $this->dataQuery->query()->toSelect(); + $query->addWhere($foreignFilter); + $query->addWhere(array( + "\"$baseClass\".\"ID\"" => $itemID + )); + $hasExisting = ($query->count() > 0); } else { - $manipulation[$this->joinTable]['command'] = 'insert'; + $hasExisting = false; } - - if($extraFields) foreach($extraFields as $k => $v) { - if(is_null($v)) $manipulation[$this->joinTable]['fields'][$k] = 'NULL'; - else $manipulation[$this->joinTable]['fields'][$k] = "'" . Convert::raw2sql($v) . "'"; + + // Determine entry type + if(!$hasExisting) { + // Field values for new record + $fieldValues = array_merge($extraFields, array( + "\"{$this->foreignKey}\"" => $foreignID, + "\"{$this->localKey}\"" => $itemID + )); + // Create new record + $insert = new SQLInsert("\"{$this->joinTable}\"", $fieldValues); + $insert->execute(); + } elseif(!empty($extraFields)) { + // For existing records, simply update any extra data supplied + $foreignWriteFilter = $this->foreignIDWriteFilter($foreignID); + $update = new SQLUpdate("\"{$this->joinTable}\"", $extraFields, $foreignWriteFilter); + $update->addWhere(array( + "\"{$this->joinTable}\".\"{$this->localKey}\"" => $itemID + )); + $update->execute(); } - - $manipulation[$this->joinTable]['fields'][$this->localKey] = $itemID; - $manipulation[$this->joinTable]['fields'][$this->foreignKey] = $foreignID; - - DB::manipulate($manipulation); } } @@ -154,8 +168,7 @@ class ManyManyList extends RelationList { public function removeByID($itemID) { if(!is_numeric($itemID)) throw new InvalidArgumentException("ManyManyList::removeById() expecting an ID"); - $query = new SQLQuery("*", array("\"$this->joinTable\"")); - $query->setDelete(true); + $query = new SQLDelete("\"$this->joinTable\""); if($filter = $this->foreignIDWriteFilter($this->getForeignID())) { $query->setWhere($filter); @@ -163,7 +176,7 @@ class ManyManyList extends RelationList { user_error("Can't call ManyManyList::remove() until a foreign ID is set", E_USER_WARNING); } - $query->addWhere("\"$this->localKey\" = {$itemID}"); + $query->addWhere(array("\"$this->localKey\"" => $itemID)); $query->execute(); } @@ -175,24 +188,27 @@ class ManyManyList extends RelationList { // Remove the join to the join table to avoid MySQL row locking issues. $query = $this->dataQuery(); - $query->removeFilterOn($query->getQueryParam('Foreign.Filter')); + $foreignFilter = $query->getQueryParam('Foreign.Filter'); + $query->removeFilterOn($foreignFilter); - $query = $query->query(); - $query->setSelect("\"$base\".\"ID\""); + $selectQuery = $query->query(); + $selectQuery->setSelect("\"$base\".\"ID\""); - $from = $query->getFrom(); + $from = $selectQuery->getFrom(); unset($from[$this->joinTable]); - $query->setFrom($from); - $query->setDistinct(false); - $query->setOrderBy(null, null); // ensure any default sorting is removed, ORDER BY can break DELETE clauses + $selectQuery->setFrom($from); + $selectQuery->setOrderBy(); // ORDER BY in subselects breaks MS SQL Server and is not necessary here + $selectQuery->setDistinct(false); // Use a sub-query as SQLite does not support setting delete targets in // joined queries. - $delete = new SQLQuery(); - $delete->setDelete(true); + $delete = new SQLDelete(); $delete->setFrom("\"$this->joinTable\""); $delete->addWhere($this->foreignIDFilter()); - $delete->addWhere("\"$this->joinTable\".\"$this->localKey\" IN ({$query->sql()})"); + $subSelect = $selectQuery->sql($parameters); + $delete->addWhere(array( + "\"$this->joinTable\".\"$this->localKey\" IN ($subSelect)" => $parameters + )); $delete->execute(); } @@ -216,7 +232,7 @@ class ManyManyList extends RelationList { // @todo Optimize into a single query instead of one per extra field if($this->extraFields) { foreach($this->extraFields as $fieldName => $dbFieldSpec) { - $query = new SQLQuery("\"$fieldName\"", array("\"$this->joinTable\"")); + $query = new SQLSelect("\"$fieldName\"", "\"$this->joinTable\""); if($filter = $this->foreignIDWriteFilter($this->getForeignID())) { $query->setWhere($filter); } else { diff --git a/model/MySQLDatabase.php b/model/MySQLDatabase.php deleted file mode 100644 index 720d8d513..000000000 --- a/model/MySQLDatabase.php +++ /dev/null @@ -1,1218 +0,0 @@ -update('MySQLDatabase', 'connection_charset', $charset); - } - - /** - * Connect to a MySQL database. - * @param array $parameters An map of parameters, which should include: - * - server: The server, eg, localhost - * - username: The username to log on with - * - password: The password to log on with - * - database: The database to connect to - * - timezone: (optional) The timezone offset. For example: +12:00, "Pacific/Auckland", or "SYSTEM" - */ - public function __construct($parameters) { - if(!empty($parameters['port'])) { - $this->dbConn = new MySQLi($parameters['server'], $parameters['username'], $parameters['password'], - '', $parameters['port']); - } else { - $this->dbConn = new MySQLi($parameters['server'], $parameters['username'], $parameters['password']); - } - - if($this->dbConn->connect_error) { - $this->databaseError("Couldn't connect to MySQL database | " . $this->dbConn->connect_error); - } - - $this->query("SET sql_mode = 'ANSI'"); - - if(Config::inst()->get('MySQLDatabase', 'connection_charset')) { - $this->dbConn->set_charset(Config::inst()->get('MySQLDatabase', 'connection_charset')); - } - - $this->active = $this->dbConn->select_db($parameters['database']); - $this->database = $parameters['database']; - - if(isset($parameters['timezone'])) { - $this->query(sprintf("SET SESSION time_zone = '%s'", $parameters['timezone'])); - } - } - - public function __destruct() { - if($this->dbConn) { - mysqli_close($this->dbConn); - } - } - - /** - * Not implemented, needed for PDO - */ - public function getConnect($parameters) { - return null; - } - - /** - * Returns true if this database supports collations - * @return boolean - */ - public function supportsCollations() { - return true; - } - - public function supportsTimezoneOverride() { - return true; - } - - /** - * Get the version of MySQL. - * @return string - */ - public function getVersion() { - return $this->dbConn->server_info; - } - - /** - * Get the database server, namely mysql. - * @return string - */ - public function getDatabaseServer() { - return "mysql"; - } - - public function query($sql, $errorLevel = E_USER_ERROR) { - if(isset($_REQUEST['previewwrite']) && in_array(strtolower(substr($sql,0,strpos($sql,' '))), - array('insert','update','delete','replace'))) { - - Debug::message("Will execute: $sql"); - return; - } - - if(isset($_REQUEST['showqueries']) && Director::isDev(true)) { - $starttime = microtime(true); - } - - $handle = $this->dbConn->query($sql); - - if(isset($_REQUEST['showqueries']) && Director::isDev(true)) { - $endtime = round(microtime(true) - $starttime,4); - Debug::message("\n$sql\n{$endtime}s\n", false); - } - - if(!$handle && $errorLevel) { - $this->databaseError("Couldn't run query: $sql | " . $this->dbConn->error, $errorLevel); - } - return new MySQLQuery($this, $handle); - } - - public function getGeneratedID($table) { - return $this->dbConn->insert_id; - } - - public function isActive() { - return $this->active ? true : false; - } - - public function createDatabase() { - $this->query("CREATE DATABASE \"$this->database\" DEFAULT CHARACTER SET utf8 DEFAULT COLLATE utf8_general_ci"); - $this->query("USE \"$this->database\""); - - $this->tableList = $this->fieldList = $this->indexList = null; - - $this->active = $this->dbConn->select_db($this->database); - return $this->active; - } - - /** - * Drop the database that this object is currently connected to. - * Use with caution. - */ - public function dropDatabase() { - $this->dropDatabaseByName($this->database); - } - - /** - * Drop the database that this object is currently connected to. - * Use with caution. - */ - public function dropDatabaseByName($dbName) { - $this->query("DROP DATABASE \"$dbName\""); - } - - /** - * Returns the name of the currently selected database - */ - public function currentDatabase() { - return $this->database; - } - - /** - * Switches to the given database. - * If the database doesn't exist, you should call createDatabase() after calling selectDatabase() - */ - public function selectDatabase($dbname) { - $this->database = $dbname; - $this->tableList = $this->fieldList = $this->indexList = null; - $this->active = false; - if($this->databaseExists($this->database)) { - $this->active = $this->dbConn->select_db($this->database); - } - return $this->active; - } - - /** - * Returns true if the named database exists. - */ - public function databaseExists($name) { - $SQL_name = Convert::raw2sql($name); - return $this->query("SHOW DATABASES LIKE '$SQL_name'")->value() ? true : false; - } - - /** - * Returns a column - */ - public function allDatabaseNames() { - return $this->query("SHOW DATABASES")->column(); - } - - /** - * Create a new table. - * @param $tableName The name of the table - * @param $fields A map of field names to field types - * @param $indexes A map of indexes - * @param $options An map of additional options. The available keys are as follows: - * - 'MSSQLDatabase'/'MySQLDatabase'/'PostgreSQLDatabase' - database-specific options such as "engine" for MySQL. - * - 'temporary' - If true, then a temporary table will be created - * @return The table name generated. This may be different from the table name, for example with temporary tables. - */ - public function createTable($table, $fields = null, $indexes = null, $options = null, $advancedOptions = null) { - $fieldSchemas = $indexSchemas = ""; - - if(!empty($options[get_class($this)])) { - $addOptions = $options[get_class($this)]; - } elseif(!empty($options[get_parent_class($this)])) { - $addOptions = $options[get_parent_class($this)]; - } else { - $addOptions = "ENGINE=InnoDB"; - } - - if(!isset($fields['ID'])) $fields['ID'] = "int(11) not null auto_increment"; - if($fields) foreach($fields as $k => $v) $fieldSchemas .= "\"$k\" $v,\n"; - if($indexes) foreach($indexes as $k => $v) $indexSchemas .= $this->getIndexSqlDefinition($k, $v) . ",\n"; - - // Switch to "CREATE TEMPORARY TABLE" for temporary tables - $temporary = empty($options['temporary']) ? "" : "TEMPORARY"; - - $this->query("CREATE $temporary TABLE \"$table\" ( - $fieldSchemas - $indexSchemas - primary key (ID) - ) {$addOptions}"); - - return $table; - } - - /** - * Alter a table's schema. - * @param $table The name of the table to alter - * @param $newFields New fields, a map of field name => field schema - * @param $newIndexes New indexes, a map of index name => index type - * @param $alteredFields Updated fields, a map of field name => field schema - * @param $alteredIndexes Updated indexes, a map of index name => index type - * @param $alteredOptions - */ - public function alterTable($tableName, $newFields = null, $newIndexes = null, $alteredFields = null, - $alteredIndexes = null, $alteredOptions = null, $advancedOptions = null) { - - if($this->isView($tableName)) { - DB::alteration_message( - sprintf("Table %s not changed as it is a view", $tableName), - "changed" - ); - return; - } - $fieldSchemas = $indexSchemas = ""; - $alterList = array(); - - if($newFields) foreach($newFields as $k => $v) $alterList[] .= "ADD \"$k\" $v"; - if($newIndexes) foreach($newIndexes as $k => $v) $alterList[] .= "ADD " . $this->getIndexSqlDefinition($k, $v); - if($alteredFields) foreach($alteredFields as $k => $v) $alterList[] .= "CHANGE \"$k\" \"$k\" $v"; - if($alteredIndexes) foreach($alteredIndexes as $k => $v) { - $alterList[] .= "DROP INDEX \"$k\""; - $alterList[] .= "ADD ". $this->getIndexSqlDefinition($k, $v); - } - - if($alteredOptions && isset($alteredOptions[get_class($this)])) { - if(!isset($this->indexList[$tableName])) { - $this->indexList[$tableName] = $this->indexList($tableName); - } - - $skip = false; - foreach($this->indexList[$tableName] as $index) { - if(strpos($index, 'fulltext ') === 0) { - $skip = true; - break; - } - } - if($skip) { - DB::alteration_message( - sprintf("Table %s options not changed to %s due to fulltextsearch index", - $tableName, $alteredOptions[get_class($this)]), - "changed" - ); - } else { - $this->query(sprintf("ALTER TABLE \"%s\" %s", $tableName, $alteredOptions[get_class($this)])); - DB::alteration_message( - sprintf("Table %s options changed: %s", $tableName, $alteredOptions[get_class($this)]), - "changed" - ); - } - } - - $alterations = implode(",\n", $alterList); - $this->query("ALTER TABLE \"$tableName\" $alterations"); - } - - public function isView($tableName) { - $info = $this->query("SHOW /*!50002 FULL*/ TABLES LIKE '$tableName'")->record(); - return $info && strtoupper($info['Table_type']) == 'VIEW'; - } - - public function renameTable($oldTableName, $newTableName) { - $this->query("ALTER TABLE \"$oldTableName\" RENAME \"$newTableName\""); - } - - - - /** - * Checks a table's integrity and repairs it if necessary. - * @var string $tableName The name of the table. - * @return boolean Return true if the table has integrity after the method is complete. - */ - public function checkAndRepairTable($tableName) { - if(!$this->runTableCheckCommand("CHECK TABLE \"$tableName\"")) { - if($this->runTableCheckCommand("CHECK TABLE \"".strtolower($tableName)."\"")){ - DB::alteration_message("Table $tableName: renamed from lowercase","repaired"); - return $this->renameTable(strtolower($tableName),$tableName); - } - - DB::alteration_message("Table $tableName: repaired","repaired"); - return $this->runTableCheckCommand("REPAIR TABLE \"$tableName\" USE_FRM"); - } else { - return true; - } - } - - /** - * Helper function used by checkAndRepairTable. - * @param string $sql Query to run. - * @return boolean Returns if the query returns a successful result. - */ - protected function runTableCheckCommand($sql) { - $testResults = $this->query($sql); - foreach($testResults as $testRecord) { - if(strtolower($testRecord['Msg_text']) != 'ok') { - return false; - } - } - return true; - } - - public function createField($tableName, $fieldName, $fieldSpec) { - $this->query("ALTER TABLE \"$tableName\" ADD \"$fieldName\" $fieldSpec"); - } - - /** - * Change the database type of the given field. - * @param string $tableName The name of the tbale the field is in. - * @param string $fieldName The name of the field to change. - * @param string $fieldSpec The new field specification - */ - public function alterField($tableName, $fieldName, $fieldSpec) { - $this->query("ALTER TABLE \"$tableName\" CHANGE \"$fieldName\" \"$fieldName\" $fieldSpec"); - } - - /** - * Change the database column name of the given field. - * - * @param string $tableName The name of the tbale the field is in. - * @param string $oldName The name of the field to change. - * @param string $newName The new name of the field - */ - public function renameField($tableName, $oldName, $newName) { - $fieldList = $this->fieldList($tableName); - if(array_key_exists($oldName, $fieldList)) { - $this->query("ALTER TABLE \"$tableName\" CHANGE \"$oldName\" \"$newName\" " . $fieldList[$oldName]); - } - } - - private static $_cache_collation_info = array(); - - public function fieldList($table) { - $fields = DB::query("SHOW FULL FIELDS IN \"$table\""); - foreach($fields as $field) { - - // ensure that '' is converted to \' in field specification (mostly for the benefit of ENUM values) - $fieldSpec = str_replace('\'\'', '\\\'', $field['Type']); - if(!$field['Null'] || $field['Null'] == 'NO') { - $fieldSpec .= ' not null'; - } - - if($field['Collation'] && $field['Collation'] != 'NULL') { - // Cache collation info to cut down on database traffic - if(!isset(self::$_cache_collation_info[$field['Collation']])) { - self::$_cache_collation_info[$field['Collation']] - = DB::query("SHOW COLLATION LIKE '$field[Collation]'")->record(); - } - $collInfo = self::$_cache_collation_info[$field['Collation']]; - $fieldSpec .= " character set $collInfo[Charset] collate $field[Collation]"; - } - - if($field['Default'] || $field['Default'] === "0") { - if(is_numeric($field['Default'])) - $fieldSpec .= " default " . Convert::raw2sql($field['Default']); - else - $fieldSpec .= " default '" . Convert::raw2sql($field['Default']) . "'"; - } - if($field['Extra']) $fieldSpec .= " $field[Extra]"; - - $fieldList[$field['Field']] = $fieldSpec; - } - return $fieldList; - } - - /** - * Create an index on a table. - * - * @param string $tableName The name of the table. - * @param string $indexName The name of the index. - * @param string $indexSpec The specification of the index, see {@link SS_Database::requireIndex()} for more - * details. - */ - public function createIndex($tableName, $indexName, $indexSpec) { - $this->query("ALTER TABLE \"$tableName\" ADD " . $this->getIndexSqlDefinition($indexName, $indexSpec)); - } - - /** - * This takes the index spec which has been provided by a class (ie static $indexes = blah blah) - * and turns it into a proper string. - * Some indexes may be arrays, such as fulltext and unique indexes, and this allows database-specific - * arrays to be created. See {@link requireTable()} for details on the index format. - * - * @see http://dev.mysql.com/doc/refman/5.0/en/create-index.html - * - * @param string|array $indexSpec - * @return string MySQL compatible ALTER TABLE syntax - */ - public function convertIndexSpec($indexSpec){ - if(is_array($indexSpec)){ - //Here we create a db-specific version of whatever index we need to create. - switch($indexSpec['type']){ - case 'fulltext': - $indexSpec='fulltext (' . str_replace(' ', '', $indexSpec['value']) . ')'; - break; - case 'unique': - $indexSpec='unique (' . $indexSpec['value'] . ')'; - break; - case 'btree': - case 'index': - $indexSpec='using btree (' . $indexSpec['value'] . ')'; - break; - case 'hash': - $indexSpec='using hash (' . $indexSpec['value'] . ')'; - break; - } - } - - return $indexSpec; - } - - /** - * @param string $indexName - * @param string|array $indexSpec See {@link requireTable()} for details - * @return string MySQL compatible ALTER TABLE syntax - */ - protected function getIndexSqlDefinition($indexName, $indexSpec=null) { - - $indexSpec=$this->convertIndexSpec($indexSpec); - - $indexSpec = trim($indexSpec); - if($indexSpec[0] != '(') list($indexType, $indexFields) = explode(' ',$indexSpec,2); - else $indexFields = $indexSpec; - - if(!isset($indexType)) - $indexType = "index"; - - if($indexType=='using') - return "index \"$indexName\" using $indexFields"; - else { - return "$indexType \"$indexName\" $indexFields"; - } - - } - - /** - * MySQL does not need any transformations done on the index that's created, so we can just return it as-is - */ - public function getDbSqlDefinition($tableName, $indexName, $indexSpec){ - return $indexName; - } - - /** - * Alter an index on a table. - * @param string $tableName The name of the table. - * @param string $indexName The name of the index. - * @param string $indexSpec The specification of the index, see {@link SS_Database::requireIndex()} - * for more details. - */ - public function alterIndex($tableName, $indexName, $indexSpec) { - - $indexSpec=$this->convertIndexSpec($indexSpec); - - $indexSpec = trim($indexSpec); - if($indexSpec[0] != '(') { - list($indexType, $indexFields) = explode(' ',$indexSpec,2); - } else { - $indexFields = $indexSpec; - } - - if(!$indexType) { - $indexType = "index"; - } - - $this->query("ALTER TABLE \"$tableName\" DROP INDEX \"$indexName\""); - $this->query("ALTER TABLE \"$tableName\" ADD $indexType \"$indexName\" $indexFields"); - } - - /** - * Return the list of indexes in a table. - * @param string $table The table name. - * @return array - */ - public function indexList($table) { - $indexes = DB::query("SHOW INDEXES IN \"$table\""); - $groupedIndexes = array(); - $indexList = array(); - - foreach($indexes as $index) { - $groupedIndexes[$index['Key_name']]['fields'][$index['Seq_in_index']] = $index['Column_name']; - - if($index['Index_type'] == 'FULLTEXT') { - $groupedIndexes[$index['Key_name']]['type'] = 'fulltext '; - } else if(!$index['Non_unique']) { - $groupedIndexes[$index['Key_name']]['type'] = 'unique '; - } else if($index['Index_type'] =='HASH') { - $groupedIndexes[$index['Key_name']]['type'] = 'hash '; - } else if($index['Index_type'] =='RTREE') { - $groupedIndexes[$index['Key_name']]['type'] = 'rtree '; - } else { - $groupedIndexes[$index['Key_name']]['type'] = ''; - } - } - - if($groupedIndexes) { - foreach($groupedIndexes as $index => $details) { - ksort($details['fields']); - $indexList[$index] = $details['type'] . '("' . implode('","',$details['fields']) . '")'; - } - } - - return $indexList; - } - - /** - * Returns a list of all the tables in the database. - * @return array - */ - public function tableList() { - $tables = array(); - foreach($this->query("SHOW TABLES") as $record) { - $table = reset($record); - $tables[strtolower($table)] = $table; - } - return $tables; - } - - /** - * Return the number of rows affected by the previous operation. - * @return int - */ - public function affectedRows() { - return $this->dbConn->affected_rows; - } - - public function databaseError($msg, $errorLevel = E_USER_ERROR) { - // try to extract and format query - if(preg_match('/Couldn\'t run query: ([^\|]*)\|\s*(.*)/', $msg, $matches)) { - $formatter = new SQLFormatter(); - $msg = "Couldn't run query: \n" . $formatter->formatPlain($matches[1]) . "\n\n" . $matches[2]; - } - - user_error($msg, $errorLevel); - } - - /** - * Return a boolean type-formatted string - * - * @param array $values Contains a tokenised list of info about this data type - * @return string - */ - public function boolean($values){ - //For reference, this is what typically gets passed to this function: - //$parts=Array('datatype'=>'tinyint', 'precision'=>1, 'sign'=>'unsigned', 'null'=>'not null', - //'default'=>$this->default); - //DB::requireField($this->tableName, $this->name, "tinyint(1) unsigned not null default - //'{$this->defaultVal}'"); - - return 'tinyint(1) unsigned not null default ' . (int)$values['default']; - } - - /** - * Return a date type-formatted string - * For MySQL, we simply return the word 'date', no other parameters are necessary - * - * @param array $values Contains a tokenised list of info about this data type - * @return string - */ - public function date($values){ - //For reference, this is what typically gets passed to this function: - //$parts=Array('datatype'=>'date'); - //DB::requireField($this->tableName, $this->name, "date"); - - return 'date'; - } - - /** - * Return a decimal type-formatted string - * - * @param array $values Contains a tokenised list of info about this data type - * @return string - */ - public function decimal($values){ - //For reference, this is what typically gets passed to this function: - //$parts=Array('datatype'=>'decimal', 'precision'=>"$this->wholeSize,$this->decimalSize"); - //DB::requireField($this->tableName, $this->name, "decimal($this->wholeSize,$this->decimalSize)"); - - // Avoid empty strings being put in the db - if($values['precision'] == '') { - $precision = 1; - } else { - $precision = $values['precision']; - } - - $defaultValue = ''; - if(isset($values['default']) && is_numeric($values['default'])) { - $decs = strpos($precision, ',') !== false ? (int)substr($precision, strpos($precision, ',')+1) : 0; - $defaultValue = ' default ' . number_format($values['default'], $decs, '.', ''); - } - - return 'decimal(' . $precision . ') not null' . $defaultValue; - } - - /** - * Return a enum type-formatted string - * - * @param array $values Contains a tokenised list of info about this data type - * @return string - */ - public function enum($values){ - //For reference, this is what typically gets passed to this function: - //$parts=Array('datatype'=>'enum', 'enums'=>$this->enum, 'character set'=>'utf8', 'collate'=> - // 'utf8_general_ci', 'default'=>$this->default); - //DB::requireField($this->tableName, $this->name, "enum('" . implode("','", $this->enum) . "') character set - // utf8 collate utf8_general_ci default '{$this->default}'"); - - return 'enum(\'' . implode('\',\'', $values['enums']) . '\')' - . ' character set utf8 collate utf8_general_ci default \'' . $values['default'] . '\''; - } - - /** - * Return a set type-formatted string - * - * @param array $values Contains a tokenised list of info about this data type - * @return string - */ - public function set($values){ - //For reference, this is what typically gets passed to this function: - //$parts=Array('datatype'=>'enum', 'enums'=>$this->enum, 'character set'=>'utf8', 'collate'=> - // 'utf8_general_ci', 'default'=>$this->default); - //DB::requireField($this->tableName, $this->name, "enum('" . implode("','", $this->enum) . "') character set - //utf8 collate utf8_general_ci default '{$this->default}'"); - $default = empty($values['default']) ? '' : " default '$values[default]'"; - return 'set(\'' . implode('\',\'', $values['enums']) . '\') character set utf8 collate utf8_general_ci' - . $default; - } - - /** - * Return a float type-formatted string - * For MySQL, we simply return the word 'date', no other parameters are necessary - * - * @param array $values Contains a tokenised list of info about this data type - * @return string - */ - public function float($values){ - //For reference, this is what typically gets passed to this function: - //$parts=Array('datatype'=>'float'); - //DB::requireField($this->tableName, $this->name, "float"); - - return 'float not null default ' . $values['default']; - } - - /** - * Return a int type-formatted string - * - * @param array $values Contains a tokenised list of info about this data type - * @return string - */ - public function int($values){ - //For reference, this is what typically gets passed to this function: - //$parts=Array('datatype'=>'int', 'precision'=>11, 'null'=>'not null', 'default'=>(int)$this->default); - //DB::requireField($this->tableName, $this->name, "int(11) not null default '{$this->defaultVal}'"); - - return 'int(11) not null default ' . (int)$values['default']; - } - - /** - * Return a datetime type-formatted string - * For MySQL, we simply return the word 'datetime', no other parameters are necessary - * - * @param array $values Contains a tokenised list of info about this data type - * @return string - */ - public function ss_datetime($values){ - //For reference, this is what typically gets passed to this function: - //$parts=Array('datatype'=>'datetime'); - //DB::requireField($this->tableName, $this->name, $values); - - return 'datetime'; - } - - /** - * Return a text type-formatted string - * - * @param array $values Contains a tokenised list of info about this data type - * @return string - */ - public function text($values){ - //For reference, this is what typically gets passed to this function: - //$parts=Array('datatype'=>'mediumtext', 'character set'=>'utf8', 'collate'=>'utf8_general_ci'); - //DB::requireField($this->tableName, $this->name, "mediumtext character set utf8 collate utf8_general_ci"); - - return 'mediumtext character set utf8 collate utf8_general_ci'; - } - - /** - * Return a time type-formatted string - * For MySQL, we simply return the word 'time', no other parameters are necessary - * - * @param array $values Contains a tokenised list of info about this data type - * @return string - */ - public function time($values){ - //For reference, this is what typically gets passed to this function: - //$parts=Array('datatype'=>'time'); - //DB::requireField($this->tableName, $this->name, "time"); - - return 'time'; - } - - /** - * Return a varchar type-formatted string - * - * @param array $values Contains a tokenised list of info about this data type - * @return string - */ - public function varchar($values){ - //For reference, this is what typically gets passed to this function: - //$parts=Array('datatype'=>'varchar', 'precision'=>$this->size, 'character set'=>'utf8', 'collate'=> - //'utf8_general_ci'); - //DB::requireField($this->tableName, $this->name, "varchar($this->size) character set utf8 collate - // utf8_general_ci"); - - return 'varchar(' . $values['precision'] . ') character set utf8 collate utf8_general_ci'; - } - - /* - * Return the MySQL-proprietary 'Year' datatype - */ - public function year($values){ - return 'year(4)'; - } - /** - * This returns the column which is the primary key for each table - * In Postgres, it is a SERIAL8, which is the equivalent of an auto_increment - * - * @return string - */ - public function IdColumn(){ - return 'int(11) not null auto_increment'; - } - - /** - * Returns the SQL command to get all the tables in this database - */ - public function allTablesSQL(){ - return "SHOW TABLES;"; - } - - /** - * Returns true if the given table is exists in the current database - * NOTE: Experimental; introduced for db-abstraction and may changed before 2.4 is released. - */ - public function hasTable($table) { - $SQL_table = Convert::raw2sql($table); - return (bool)($this->query("SHOW TABLES LIKE '$SQL_table'")->value()); - } - - /** - * Returns the values of the given enum field - * NOTE: Experimental; introduced for db-abstraction and may changed before 2.4 is released. - */ - public function enumValuesForField($tableName, $fieldName) { - // Get the enum of all page types from the SiteTree table - $classnameinfo = DB::query("DESCRIBE \"$tableName\" \"$fieldName\"")->first(); - preg_match_all("/'[^,]+'/", $classnameinfo["Type"], $matches); - - $classes = array(); - foreach($matches[0] as $value) { - $classes[] = stripslashes(trim($value, "'")); - } - return $classes; - } - - /** - * The core search engine, used by this class and its subclasses to do fun stuff. - * Searches both SiteTree and File. - * - * @param string $keywords Keywords as a string. - */ - public function searchEngine($classesToSearch, $keywords, $start, $pageLength, $sortBy = "Relevance DESC", - $extraFilter = "", $booleanSearch = false, $alternativeFileFilter = "", $invertedMatch = false) { - - if(!class_exists('SiteTree')) throw new Exception('MySQLDatabase->searchEngine() requires "SiteTree" class'); - if(!class_exists('File')) throw new Exception('MySQLDatabase->searchEngine() requires "File" class'); - - $fileFilter = ''; - $keywords = Convert::raw2sql($keywords); - $htmlEntityKeywords = htmlentities($keywords, ENT_NOQUOTES, 'UTF-8'); - - $extraFilters = array('SiteTree' => '', 'File' => ''); - - if($booleanSearch) $boolean = "IN BOOLEAN MODE"; - - if($extraFilter) { - $extraFilters['SiteTree'] = " AND $extraFilter"; - - if($alternativeFileFilter) $extraFilters['File'] = " AND $alternativeFileFilter"; - else $extraFilters['File'] = $extraFilters['SiteTree']; - } - - // Always ensure that only pages with ShowInSearch = 1 can be searched - $extraFilters['SiteTree'] .= " AND ShowInSearch <> 0"; - - // File.ShowInSearch was added later, keep the database driver backwards compatible - // by checking for its existence first - $fields = $this->fieldList('File'); - if(array_key_exists('ShowInSearch', $fields)) $extraFilters['File'] .= " AND ShowInSearch <> 0"; - - $limit = $start . ", " . (int) $pageLength; - - $notMatch = $invertedMatch ? "NOT " : ""; - if($keywords) { - $match['SiteTree'] = " - MATCH (Title, MenuTitle, Content, MetaDescription) AGAINST ('$keywords' $boolean) - + MATCH (Title, MenuTitle, Content, MetaDescription) AGAINST ('$htmlEntityKeywords' $boolean) - "; - $match['File'] = "MATCH (Filename, Title, Content) AGAINST ('$keywords' $boolean) AND ClassName = 'File'"; - - // We make the relevance search by converting a boolean mode search into a normal one - $relevanceKeywords = str_replace(array('*','+','-'),'',$keywords); - $htmlEntityRelevanceKeywords = str_replace(array('*','+','-'),'',$htmlEntityKeywords); - $relevance['SiteTree'] = "MATCH (Title, MenuTitle, Content, MetaDescription) " - . "AGAINST ('$relevanceKeywords') " - . "+ MATCH (Title, MenuTitle, Content, MetaDescription) AGAINST ('$htmlEntityRelevanceKeywords')"; - $relevance['File'] = "MATCH (Filename, Title, Content) AGAINST ('$relevanceKeywords')"; - } else { - $relevance['SiteTree'] = $relevance['File'] = 1; - $match['SiteTree'] = $match['File'] = "1 = 1"; - } - - // Generate initial DataLists and base table names - $lists = array(); - $baseClasses = array('SiteTree' => '', 'File' => ''); - foreach($classesToSearch as $class) { - $lists[$class] = DataList::create($class)->where($notMatch . $match[$class] . $extraFilters[$class], ""); - $baseClasses[$class] = '"'.$class.'"'; - } - - // Make column selection lists - $select = array( - 'SiteTree' => array( - "ClassName", "$baseClasses[SiteTree].\"ID\"", "ParentID", - "Title", "MenuTitle", "URLSegment", "Content", - "LastEdited", "Created", - "Filename" => "_utf8''", "Name" => "_utf8''", - "Relevance" => $relevance['SiteTree'], "CanViewType" - ), - 'File' => array( - "ClassName", "$baseClasses[File].\"ID\"", "ParentID" => "_utf8''", - "Title", "MenuTitle" => "_utf8''", "URLSegment" => "_utf8''", "Content", - "LastEdited", "Created", - "Filename", "Name", - "Relevance" => $relevance['File'], "CanViewType" => "NULL" - ), - ); - - // Process and combine queries - $querySQLs = array(); - $totalCount = 0; - foreach($lists as $class => $list) { - $query = $list->dataQuery()->query(); - - // There's no need to do all that joining - $query->setFrom(array(str_replace(array('"','`'), '', $baseClasses[$class]) => $baseClasses[$class])); - $query->setSelect($select[$class]); - $query->setOrderBy(array()); - - $querySQLs[] = $query->sql(); - $totalCount += $query->unlimitedRowCount(); - } - $fullQuery = implode(" UNION ", $querySQLs) . " ORDER BY $sortBy LIMIT $limit"; - - // Get records - $records = DB::query($fullQuery); - - $objects = array(); - - foreach($records as $record) { - $objects[] = new $record['ClassName']($record); - } - - $list = new PaginatedList(new ArrayList($objects)); - $list->setPageStart($start); - $list->setPageLength($pageLength); - $list->setTotalItems($totalCount); - - // The list has already been limited by the query above - $list->setLimitItems(false); - - return $list; - } - - /** - * MySQL uses NOW() to return the current date/time. - */ - public function now(){ - return 'NOW()'; - } - - /* - * Returns the database-specific version of the random() function - */ - public function random(){ - return 'RAND()'; - } - - /* - * This is a lookup table for data types. - * For instance, Postgres uses 'INT', while MySQL uses 'UNSIGNED' - * So this is a DB-specific list of equivilents. - */ - public function dbDataType($type){ - $values=Array( - 'unsigned integer'=>'UNSIGNED' - ); - - if(isset($values[$type])) - return $values[$type]; - else return ''; - } - - /* - * This will return text which has been escaped in a database-friendly manner. - */ - public function addslashes($value){ - return $this->dbConn->real_escape_string($value); - } - - /* - * This changes the index name depending on database requirements. - * MySQL doesn't need any changes. - */ - public function modifyIndex($index){ - return $index; - } - - /** - * Returns a SQL fragment for querying a fulltext search index - * @param $fields array The list of field names to search on - * @param $keywords string The search query - * @param $booleanSearch A MySQL-specific flag to switch to boolean search - */ - public function fullTextSearchSQL($fields, $keywords, $booleanSearch = false) { - $boolean = $booleanSearch ? "IN BOOLEAN MODE" : ""; - $fieldNames = '"' . implode('", "', $fields) . '"'; - - $SQL_keywords = Convert::raw2sql($keywords); - $SQL_htmlEntityKeywords = Convert::raw2sql(htmlentities($keywords, ENT_NOQUOTES, 'UTF-8')); - - return "(MATCH ($fieldNames) AGAINST ('$SQL_keywords' $boolean) + MATCH ($fieldNames)" - . " AGAINST ('$SQL_htmlEntityKeywords' $boolean))"; - } - - /* - * Does this database support transactions? - */ - public function supportsTransactions(){ - return $this->supportsTransactions; - } - - /* - * This is a quick lookup to discover if the database supports particular extensions - * Currently, MySQL supports no extensions - */ - public function supportsExtensions($extensions=Array('partitions', 'tablespaces', 'clustering')){ - if(isset($extensions['partitions'])) - return false; - elseif(isset($extensions['tablespaces'])) - return false; - elseif(isset($extensions['clustering'])) - return false; - else - return false; - } - - /* - * Start a prepared transaction - * See http://developer.postgresql.org/pgdocs/postgres/sql-set-transaction.html for details on transaction - * isolation options - */ - public function transactionStart($transaction_mode=false, $session_characteristics=false){ - // This sets the isolation level for the NEXT transaction, not the current one. - if($transaction_mode) { - $this->query('SET TRANSACTION ' . $transaction_mode . ';'); - } - - $this->query('START TRANSACTION;'); - - if($session_characteristics) { - $this->query('SET SESSION TRANSACTION ' . $session_characteristics . ';'); - } - } - - /* - * Create a savepoint that you can jump back to if you encounter problems - */ - public function transactionSavepoint($savepoint){ - $this->query("SAVEPOINT $savepoint;"); - } - - /* - * Rollback or revert to a savepoint if your queries encounter problems - * If you encounter a problem at any point during a transaction, you may - * need to rollback that particular query, or return to a savepoint - */ - public function transactionRollback($savepoint = false){ - if($savepoint) { - $this->query('ROLLBACK TO ' . $savepoint . ';'); - } else { - $this->query('ROLLBACK'); - } - } - - /* - * Commit everything inside this transaction so far - */ - public function transactionEnd($chain = false){ - $this->query('COMMIT AND ' . ($chain ? '' : 'NO ') . 'CHAIN;'); - } - - /** - * Generate a WHERE clause for text matching. - * - * @param String $field Quoted field name - * @param String $value Escaped search. Can include percentage wildcards. - * @param boolean $exact Exact matches or wildcard support. - * @param boolean $negate Negate the clause. - * @param boolean $caseSensitive Enforce case sensitivity if TRUE or FALSE. - * Stick with default collation if set to NULL. - * @return String SQL - */ - public function comparisonClause($field, $value, $exact = false, $negate = false, $caseSensitive = null) { - if($exact && $caseSensitive === null) { - $comp = ($negate) ? '!=' : '='; - } else { - $comp = ($caseSensitive) ? 'LIKE BINARY' : 'LIKE'; - if($negate) $comp = 'NOT ' . $comp; - } - - return sprintf("%s %s '%s'", $field, $comp, $value); - } - - /** - * function to return an SQL datetime expression that can be used with MySQL - * used for querying a datetime in a certain format - * @param string $date to be formated, can be either 'now', literal datetime like '1973-10-14 10:30:00' or - * field name, e.g. '"SiteTree"."Created"' - * @param string $format to be used, supported specifiers: - * %Y = Year (four digits) - * %m = Month (01..12) - * %d = Day (01..31) - * %H = Hour (00..23) - * %i = Minutes (00..59) - * %s = Seconds (00..59) - * %U = unix timestamp, can only be used on it's own - * @return string SQL datetime expression to query for a formatted datetime - */ - public function formattedDatetimeClause($date, $format) { - - preg_match_all('/%(.)/', $format, $matches); - foreach($matches[1] as $match) if(array_search($match, array('Y','m','d','H','i','s','U')) === false) { - user_error('formattedDatetimeClause(): unsupported format character %' . $match, E_USER_WARNING); - } - - if(preg_match('/^now$/i', $date)) { - $date = "NOW()"; - } else if(preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/i', $date)) { - $date = "'$date'"; - } - - if($format == '%U') return "UNIX_TIMESTAMP($date)"; - - return "DATE_FORMAT($date, '$format')"; - - } - - /** - * function to return an SQL datetime expression that can be used with MySQL - * used for querying a datetime addition - * @param string $date, can be either 'now', literal datetime like '1973-10-14 10:30:00' or field name, - * e.g. '"SiteTree"."Created"' - * @param string $interval to be added, use the format [sign][integer] [qualifier], - * e.g. -1 Day, +15 minutes, +1 YEAR - * supported qualifiers: - * - years - * - months - * - days - * - hours - * - minutes - * - seconds - * This includes the singular forms as well - * @return string SQL datetime expression to query for a datetime (YYYY-MM-DD hh:mm:ss) which is the result of - * the addition - */ - public function datetimeIntervalClause($date, $interval) { - - $interval = preg_replace('/(year|month|day|hour|minute|second)s/i', '$1', $interval); - - if(preg_match('/^now$/i', $date)) { - $date = "NOW()"; - } else if(preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/i', $date)) { - $date = "'$date'"; - } - - return "$date + INTERVAL $interval"; - } - - /** - * function to return an SQL datetime expression that can be used with MySQL - * used for querying a datetime substraction - * @param string $date1, can be either 'now', literal datetime like '1973-10-14 10:30:00' or field name, - * e.g. '"SiteTree"."Created"' - * @param string $date2 to be substracted of $date1, can be either 'now', literal datetime like - * '1973-10-14 10:30:00' or field name, e.g. '"SiteTree"."Created"' - * @return string SQL datetime expression to query for the interval between $date1 and $date2 in seconds which - * is the result of the substraction - */ - public function datetimeDifferenceClause($date1, $date2) { - - if(preg_match('/^now$/i', $date1)) { - $date1 = "NOW()"; - } else if(preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/i', $date1)) { - $date1 = "'$date1'"; - } - - if(preg_match('/^now$/i', $date2)) { - $date2 = "NOW()"; - } else if(preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/i', $date2)) { - $date2 = "'$date2'"; - } - - return "UNIX_TIMESTAMP($date1) - UNIX_TIMESTAMP($date2)"; - } - - public function supportsLocks() { - return true; - } - - public function canLock($name) { - $id = $this->getLockIdentifier($name); - return (bool)DB::query(sprintf("SELECT IS_FREE_LOCK('%s')", $id))->value(); - } - - public function getLock($name, $timeout = 5) { - $id = $this->getLockIdentifier($name); - - // MySQL auto-releases existing locks on subsequent GET_LOCK() calls, - // in contrast to PostgreSQL and SQL Server who stack the locks. - - return (bool)DB::query(sprintf("SELECT GET_LOCK('%s', %d)", $id, $timeout))->value(); - } - - public function releaseLock($name) { - $id = $this->getLockIdentifier($name); - return (bool)DB::query(sprintf("SELECT RELEASE_LOCK('%s')", $id))->value(); - } - - protected function getLockIdentifier($name) { - // Prefix with database name - return Convert::raw2sql($this->database . '_' . Convert::raw2sql($name)); - } -} diff --git a/model/MySQLQuery.php b/model/MySQLQuery.php deleted file mode 100644 index b1366233f..000000000 --- a/model/MySQLQuery.php +++ /dev/null @@ -1,69 +0,0 @@ -database = $database; - $this->handle = $handle; - } - - public function __destruct() { - if(is_object($this->handle)) { - $this->handle->free(); - } - } - - /** - * {@inheritdoc} - */ - public function seek($row) { - if(is_object($this->handle)) { - return $this->handle->data_seek($row); - } - } - - /** - * {@inheritdoc} - */ - public function numRecords() { - if(is_object($this->handle)) { - return $this->handle->num_rows; - } - } - - /** - * {@inheritdoc} - */ - public function nextRecord() { - if(is_object($this->handle) && ($data = $this->handle->fetch_assoc())) { - return $data; - } else { - return false; - } - } -} diff --git a/model/RelationList.php b/model/RelationList.php index 4e1390d17..54b912893 100644 --- a/model/RelationList.php +++ b/model/RelationList.php @@ -50,7 +50,10 @@ abstract class RelationList extends DataList { * Returns a where clause that filters the members of this relationship to * just the related items. * - * @param $id (optional) An ID or an array of IDs - if not provided, will use the current ids as per getForeignID + * + * @param array|integer $id (optional) An ID or an array of IDs - if not provided, will use the current ids as + * per getForeignID + * @return array Condition In array(SQL => parameters format) */ abstract protected function foreignIDFilter($id = null); } diff --git a/model/SQLMap.php b/model/SQLMap.php index ee9bb5ebe..c9f1be2ac 100644 --- a/model/SQLMap.php +++ b/model/SQLMap.php @@ -9,16 +9,17 @@ class SQLMap extends Object implements IteratorAggregate { /** * The query used to generate the map. - * @var SQLQuery + * @var SQLSelect */ protected $query; protected $keyField, $titleField; /** * Construct a SQLMap. - * @param SQLQuery $query The query to generate this map. THis isn't executed until it's needed. + * + * @param SQLSelect $query The query to generate this map. THis isn't executed until it's needed. */ - public function __construct(SQLQuery $query, $keyField = "ID", $titleField = "Title") { + public function __construct(SQLSelect $query, $keyField = "ID", $titleField = "Title") { Deprecation::notice('3.0', 'Use SS_Map or DataList::map() instead.', Deprecation::SCOPE_CLASS); if(!$query) { @@ -40,10 +41,12 @@ class SQLMap extends Object implements IteratorAggregate { public function getItem($id) { if($id) { $baseTable = reset($this->query->from); - $where = "$baseTable.\"ID\" = $id"; - $this->query->where[sha1($where)] = $where; + $oldWhere = $this->query->getWhere(); + $this->query->where(array( + "\"$baseTable\".\"ID\" = ?" => $id + )); $record = $this->query->execute()->first(); - unset($this->query->where[sha1($where)]); + $this->query->setWhere($oldWhere); if($record) { $className = $record['ClassName']; $obj = new $className($record); diff --git a/model/SQLQuery.php b/model/SQLQuery.php deleted file mode 100644 index 79ddb574e..000000000 --- a/model/SQLQuery.php +++ /dev/null @@ -1,1200 +0,0 @@ -setSelect($select); - $this->setFrom($from); - $this->setWhere($where); - $this->setOrderBy($orderby); - $this->setGroupBy($groupby); - $this->setHaving($having); - $this->setLimit($limit); - } - - public function __get($field) { - if(strtolower($field) == 'select') Deprecation::notice('3.0', 'Please use getSelect() instead'); - if(strtolower($field) == 'from') Deprecation::notice('3.0', 'Please use getFrom() instead'); - if(strtolower($field) == 'groupby') Deprecation::notice('3.0', 'Please use getGroupBy() instead'); - if(strtolower($field) == 'orderby') Deprecation::notice('3.0', 'Please use getOrderBy() instead'); - if(strtolower($field) == 'having') Deprecation::notice('3.0', 'Please use getHaving() instead'); - if(strtolower($field) == 'limit') Deprecation::notice('3.0', 'Please use getLimit() instead'); - if(strtolower($field) == 'delete') Deprecation::notice('3.0', 'Please use getDelete() instead'); - if(strtolower($field) == 'connective') Deprecation::notice('3.0', 'Please use getConnective() instead'); - if(strtolower($field) == 'distinct') Deprecation::notice('3.0', 'Please use getDistinct() instead'); - - return $this->$field; - } - - public function __set($field, $value) { - if(strtolower($field) == 'select') Deprecation::notice('3.0', 'Please use setSelect() or addSelect() instead'); - if(strtolower($field) == 'from') Deprecation::notice('3.0', 'Please use setFrom() or addFrom() instead'); - if(strtolower($field) == 'groupby') { - Deprecation::notice('3.0', 'Please use setGroupBy() or addGroupBy() instead'); - } - if(strtolower($field) == 'orderby') { - Deprecation::notice('3.0', 'Please use setOrderBy() or addOrderBy() instead'); - } - if(strtolower($field) == 'having') Deprecation::notice('3.0', 'Please use setHaving() or addHaving() instead'); - if(strtolower($field) == 'limit') Deprecation::notice('3.0', 'Please use setLimit() instead'); - if(strtolower($field) == 'delete') Deprecation::notice('3.0', 'Please use setDelete() instead'); - if(strtolower($field) == 'connective') Deprecation::notice('3.0', 'Please use setConnective() instead'); - if(strtolower($field) == 'distinct') Deprecation::notice('3.0', 'Please use setDistinct() instead'); - - return $this->$field = $value; - } - - /** - * Set the list of columns to be selected by the query. - * - * - * // pass fields to select as single parameter array - * $query->setSelect(array("Col1","Col2"))->setFrom("MyTable"); - * - * // pass fields to select as multiple parameters - * $query->setSelect("Col1", "Col2")->setFrom("MyTable"); - * - * - * @param string|array $fields - * @param boolean $clear Clear existing select fields? - * @return SQLQuery - */ - public function setSelect($fields) { - $this->select = array(); - if (func_num_args() > 1) { - $fields = func_get_args(); - } else if(!is_array($fields)) { - $fields = array($fields); - } - return $this->addSelect($fields); - } - - /** - * Add to the list of columns to be selected by the query. - * - * - * // pass fields to select as single parameter array - * $query->addSelect(array("Col1","Col2"))->setFrom("MyTable"); - * - * // pass fields to select as multiple parameters - * $query->addSelect("Col1", "Col2")->setFrom("MyTable"); - * - * - * @param string|array $fields - * @param boolean $clear Clear existing select fields? - * @return SQLQuery - */ - public function addSelect($fields) { - if (func_num_args() > 1) { - $fields = func_get_args(); - } else if(!is_array($fields)) { - $fields = array($fields); - } - - foreach($fields as $idx => $field) { - if(preg_match('/^(.*) +AS +"?([^"]*)"?/i', $field, $matches)) { - Deprecation::notice("3.0", "Use selectField() to specify column aliases"); - $this->selectField($matches[1], $matches[2]); - } else { - $this->selectField($field, is_numeric($idx) ? null : $idx); - } - } - - return $this; - } - - public function select($fields) { - Deprecation::notice('3.0', 'Please use setSelect() or addSelect() instead!'); - $this->setSelect($fields); - } - - /** - * Select an additional field. - * - * @param $field String The field to select (escaped SQL statement) - * @param $alias String The alias of that field (escaped SQL statement). - * Defaults to the unquoted column name of the $field parameter. - * @return SQLQuery - */ - public function selectField($field, $alias = null) { - if(!$alias) { - if(preg_match('/"([^"]+)"$/', $field, $matches)) $alias = $matches[1]; - else $alias = $field; - } - $this->select[$alias] = $field; - return $this; - } - - /** - * Return the SQL expression for the given field alias. - * Returns null if the given alias doesn't exist. - * See {@link selectField()} for details on alias generation. - * - * @param String $field - * @return String - */ - public function expressionForField($field) { - return isset($this->select[$field]) ? $this->select[$field] : null; - } - - /** - * Set table for the SELECT clause. - * - * @example $query->setFrom("MyTable"); // SELECT * FROM MyTable - * - * @param string|array $from Escaped SQL statement, usually an unquoted table name - * @return SQLQuery - */ - public function setFrom($from) { - $this->from = array(); - return $this->addFrom($from); - } - - /** - * Add a table to the SELECT clause. - * - * @example $query->addFrom("MyTable"); // SELECT * FROM MyTable - * - * @param string|array $from Escaped SQL statement, usually an unquoted table name - * @return SQLQuery - */ - public function addFrom($from) { - if(is_array($from)) { - $this->from = array_merge($this->from, $from); - } elseif(!empty($from)) { - $this->from[str_replace(array('"','`'), '', $from)] = $from; - } - - return $this; - } - - public function from($from) { - Deprecation::notice('3.0', 'Please use setFrom() or addFrom() instead!'); - return $this->setFrom($from); - } - - /** - * Add a LEFT JOIN criteria to the FROM clause. - * - * @param string $table Unquoted table name - * @param string $onPredicate The "ON" SQL fragment in a "LEFT JOIN ... AS ... ON ..." statement, Needs to be valid - * (quoted) SQL. - * @param string $tableAlias Optional alias which makes it easier to identify and replace joins later on - * @param int $order A numerical index to control the order that joins are added to the query; lower order values - * will cause the query to appear first. The default is 20, and joins created automatically by the - * ORM have a value of 10. - * @return SQLQuery - */ - public function addLeftJoin($table, $onPredicate, $tableAlias = '', $order = 20) { - if(!$tableAlias) { - $tableAlias = $table; - } - $this->from[$tableAlias] = array( - 'type' => 'LEFT', - 'table' => $table, - 'filter' => array($onPredicate), - 'order' => $order - ); - return $this; - } - - public function leftjoin($table, $onPredicate, $tableAlias = null, $order = 20) { - Deprecation::notice('3.0', 'Please use addLeftJoin() instead!'); - $this->addLeftJoin($table, $onPredicate, $tableAlias); - } - - /** - * Add an INNER JOIN criteria to the FROM clause. - * - * @param string $table Unquoted table name - * @param string $onPredicate The "ON" SQL fragment in an "INNER JOIN ... AS ... ON ..." statement. Needs to be - * valid (quoted) SQL. - * @param string $tableAlias Optional alias which makes it easier to identify and replace joins later on - * @param int $order A numerical index to control the order that joins are added to the query; lower order values - * will cause the query to appear first. The default is 20, and joins created automatically by the - * ORM have a value of 10. - * @return SQLQuery - */ - public function addInnerJoin($table, $onPredicate, $tableAlias = null, $order = 20) { - if(!$tableAlias) $tableAlias = $table; - $this->from[$tableAlias] = array( - 'type' => 'INNER', - 'table' => $table, - 'filter' => array($onPredicate), - 'order' => $order - ); - return $this; - } - - public function innerjoin($table, $onPredicate, $tableAlias = null, $order = 20) { - Deprecation::notice('3.0', 'Please use addInnerJoin() instead!'); - return $this->addInnerJoin($table, $onPredicate, $tableAlias, $order); - } - - /** - * Add an additional filter (part of the ON clause) on a join. - * - * @param string $table Table to join on from the original join - * @param string $filter The "ON" SQL fragment (escaped) - * @return SQLQuery - */ - public function addFilterToJoin($table, $filter) { - $this->from[$table]['filter'][] = $filter; - return $this; - } - - /** - * Set the filter (part of the ON clause) on a join. - * - * @param string $table Table to join on from the original join - * @param string $filter The "ON" SQL fragment (escaped) - * @return SQLQuery - */ - public function setJoinFilter($table, $filter) { - $this->from[$table]['filter'] = array($filter); - return $this; - } - - /** - * Returns true if we are already joining to the given table alias - * - * @return boolean - */ - public function isJoinedTo($tableAlias) { - return isset($this->from[$tableAlias]); - } - - /** - * Return a list of tables that this query is selecting from. - * - * @return array Unquoted table names - */ - public function queriedTables() { - $tables = array(); - - foreach($this->from as $key => $tableClause) { - if(is_array($tableClause)) { - $table = '"'.$tableClause['table'].'"'; - } else if(is_string($tableClause) && preg_match('/JOIN +("[^"]+") +(AS|ON) +/i', $tableClause, $matches)) { - $table = $matches[1]; - } else { - $table = $tableClause; - } - - // Handle string replacements - if($this->replacementsOld) $table = str_replace($this->replacementsOld, $this->replacementsNew, $table); - - $tables[] = preg_replace('/^"|"$/','',$table); - } - - return $tables; - } - - /** - * Set distinct property. - * @param boolean $value - */ - public function setDistinct($value) { - $this->distinct = $value; - } - - /** - * Get the distinct property. - * @return boolean - */ - public function getDistinct() { - return $this->distinct; - } - - /** - * Set the delete property. - * @param boolean $value - */ - public function setDelete($value) { - $this->delete = $value; - } - - /** - * Get the delete property. - * @return boolean - */ - public function getDelete() { - return $this->delete; - } - - /** - * Set the connective property. - * @param boolean $value - */ - public function setConnective($value) { - $this->connective = $value; - } - - /** - * Get the connective property. - * @return string - */ - public function getConnective() { - return $this->connective; - } - - /** - * Get the limit property. - * @return array - */ - public function getLimit() { - return $this->limit; - } - - /** - * Pass LIMIT clause either as SQL snippet or in array format. - * Internally, limit will always be stored as a map containing the keys 'start' and 'limit' - * - * @param int|string|array $limit If passed as a string or array, assumes SQL escaped data. - * Only applies for positive values, or if an $offset is set as well. - * @param int $offset - * - * @throws InvalidArgumentException - * - * @return SQLQuery This instance - */ - public function setLimit($limit, $offset = 0) { - if((is_numeric($limit) && $limit < 0) || (is_numeric($offset) && $offset < 0)) { - throw new InvalidArgumentException("SQLQuery::setLimit() only takes positive values"); - } - - if(is_numeric($limit) && ($limit || $offset)) { - $this->limit = array( - 'start' => $offset, - 'limit' => $limit, - ); - } else if($limit && is_string($limit)) { - if(strpos($limit, ',') !== false) { - list($start, $innerLimit) = explode(',', $limit, 2); - } - else { - list($innerLimit, $start) = explode(' OFFSET ', strtoupper($limit), 2); - } - - $this->limit = array( - 'start' => trim($start), - 'limit' => trim($innerLimit), - ); - } else { - $this->limit = $limit; - } - - return $this; - } - - public function limit($limit, $offset = 0) { - Deprecation::notice('3.0', 'Please use setLimit() instead!'); - return $this->setLimit($limit, $offset); - } - - /** - * Set ORDER BY clause either as SQL snippet or in array format. - * - * @example $sql->setOrderBy("Column"); - * @example $sql->setOrderBy("Column DESC"); - * @example $sql->setOrderBy("Column DESC, ColumnTwo ASC"); - * @example $sql->setOrderBy("Column", "DESC"); - * @example $sql->setOrderBy(array("Column" => "ASC", "ColumnTwo" => "DESC")); - * - * @param string|array $orderby Clauses to add (escaped SQL statement) - * @param string $dir Sort direction, ASC or DESC - * - * @return SQLQuery - */ - public function setOrderBy($clauses = null, $direction = null) { - $this->orderby = array(); - return $this->addOrderBy($clauses, $direction); - } - - /** - * Add ORDER BY clause either as SQL snippet or in array format. - * - * @example $sql->addOrderBy("Column"); - * @example $sql->addOrderBy("Column DESC"); - * @example $sql->addOrderBy("Column DESC, ColumnTwo ASC"); - * @example $sql->addOrderBy("Column", "DESC"); - * @example $sql->addOrderBy(array("Column" => "ASC", "ColumnTwo" => "DESC")); - * - * @param string|array $clauses Clauses to add (escaped SQL statements) - * @param string $dir Sort direction, ASC or DESC - * - * @return SQLQuery - */ - public function addOrderBy($clauses = null, $direction = null) { - if(!$clauses) { - return $this; - } - - if(is_string($clauses)) { - if(strpos($clauses, "(") !== false) { - $sort = preg_split("/,(?![^()]*+\\))/", $clauses); - } else { - $sort = explode(",", $clauses); - } - - $clauses = array(); - - foreach($sort as $clause) { - list($column, $direction) = $this->getDirectionFromString($clause, $direction); - $clauses[$column] = $direction; - } - } - - if(is_array($clauses)) { - foreach($clauses as $key => $value) { - if(!is_numeric($key)) { - $column = trim($key); - $columnDir = strtoupper(trim($value)); - } else { - list($column, $columnDir) = $this->getDirectionFromString($value); - } - - $this->orderby[$column] = $columnDir; - } - } else { - user_error('SQLQuery::orderby() incorrect format for $orderby', E_USER_WARNING); - } - - // If sort contains a public function call, let's move the sort clause into a - // separate selected field. - // - // Some versions of MySQL choke if you have a group public function referenced - // directly in the ORDER BY - if($this->orderby) { - $i = 0; - $orderby = array(); - foreach($this->orderby as $clause => $dir) { - - // public function calls and multi-word columns like "CASE WHEN ..." - if(strpos($clause, '(') !== false || strpos($clause, " ") !== false ) { - - // Move the clause to the select fragment, substituting a placeholder column in the sort fragment. - $clause = trim($clause); - $column = "_SortColumn{$i}"; - $this->selectField($clause, $column); - $clause = '"' . $column . '"'; - $i++; - } - - $orderby[$clause] = $dir; - } - $this->orderby = $orderby; - } - - return $this; - } - - public function orderby($clauses = null, $direction = null) { - Deprecation::notice('3.0', 'Please use setOrderBy() instead!'); - return $this->setOrderBy($clauses, $direction); - } - - /** - * Extract the direction part of a single-column order by clause. - * - * @param String - * @param String - * @return Array A two element array: array($column, $direction) - */ - private function getDirectionFromString($value, $defaultDirection = null) { - if(preg_match('/^(.*)(asc|desc)$/i', $value, $matches)) { - $column = trim($matches[1]); - $direction = strtoupper($matches[2]); - } else { - $column = $value; - $direction = $defaultDirection ? $defaultDirection : "ASC"; - } - return array($column, $direction); - } - - /** - * Returns the current order by as array if not already. To handle legacy - * statements which are stored as strings. Without clauses and directions, - * convert the orderby clause to something readable. - * - * @return array - */ - public function getOrderBy() { - $orderby = $this->orderby; - if(!$orderby) $orderby = array(); - - if(!is_array($orderby)) { - // spilt by any commas not within brackets - $orderby = preg_split('/,(?![^()]*+\\))/', $orderby); - } - - foreach($orderby as $k => $v) { - if(strpos($v, ' ') !== false) { - unset($orderby[$k]); - - $rule = explode(' ', trim($v)); - $clause = $rule[0]; - $dir = (isset($rule[1])) ? $rule[1] : 'ASC'; - - $orderby[$clause] = $dir; - } - } - - return $orderby; - } - - /** - * Reverses the order by clause by replacing ASC or DESC references in the - * current order by with it's corollary. - * - * @return SQLQuery - */ - public function reverseOrderBy() { - $order = $this->getOrderBy(); - $this->orderby = array(); - - foreach($order as $clause => $dir) { - $dir = (strtoupper($dir) == 'DESC') ? 'ASC' : 'DESC'; - $this->addOrderBy($clause, $dir); - } - - return $this; - } - - /** - * Set a GROUP BY clause. - * - * @param string|array $groupby Escaped SQL statement - * @return SQLQuery - */ - public function setGroupBy($groupby) { - $this->groupby = array(); - return $this->addGroupBy($groupby); - } - - /** - * Add a GROUP BY clause. - * - * @param string|array $groupby Escaped SQL statement - * @return SQLQuery - */ - public function addGroupBy($groupby) { - if(is_array($groupby)) { - $this->groupby = array_merge($this->groupby, $groupby); - } elseif(!empty($groupby)) { - $this->groupby[] = $groupby; - } - - return $this; - } - - public function groupby($where) { - Deprecation::notice('3.0', 'Please use setGroupBy() or addHaving() instead!'); - return $this->setGroupBy($where); - } - - /** - * Set a HAVING clause. - * - * @param string|array $having - * @return SQLQuery - */ - public function setHaving($having) { - $this->having = array(); - return $this->addHaving($having); - } - - /** - * Add a HAVING clause - * - * @param string|array $having Escaped SQL statement - * @return SQLQuery - */ - public function addHaving($having) { - if(is_array($having)) { - $this->having = array_merge($this->having, $having); - } elseif(!empty($having)) { - $this->having[] = $having; - } - - return $this; - } - - public function having($having) { - Deprecation::notice('3.0', 'Please use setHaving() or addHaving() instead!'); - return $this->setHaving($having); - } - - /** - * Set a WHERE clause. - * - * There are two different ways of doing this: - * - * - * // the entire predicate as a single string - * $query->where("Column = 'Value'"); - * - * // multiple predicates as an array - * $query->where(array("Column = 'Value'", "Column != 'Value'")); - * - * - * @param string|array $where Predicate(s) to set, as escaped SQL statements. - * @return SQLQuery - */ - public function setWhere($where) { - $this->where = array(); - return $this->addWhere($where); - } - - /** - * Add a WHERE predicate. - * - * There are two different ways of doing this: - * - * - * // the entire predicate as a single string - * $query->where("Column = 'Value'"); - * - * // multiple predicates as an array - * $query->where(array("Column = 'Value'", "Column != 'Value'")); - * - * - * @param string|array $where Predicate(s) to set, as escaped SQL statements. - * @return SQLQuery - */ - public function addWhere($where) { - if(is_array($where)) { - $this->where = array_merge($this->where, $where); - } elseif(!empty($where)) { - $this->where[] = $where; - } - - return $this; - } - - public function where($where) { - Deprecation::notice('3.0', 'Please use setWhere() or addWhere() instead!'); - return $this->setWhere($where); - } - - public function whereAny($where) { - Deprecation::notice('3.0', 'Please use setWhereAny() or setWhereAny() instead!'); - return $this->setWhereAny($where); - } - - /** - * @param String|array $filters Predicate(s) to set, as escaped SQL statements. - */ - public function setWhereAny($filters) { - if(is_string($filters)) $filters = func_get_args(); - $clause = implode(" OR ", $filters); - return $this->setWhere($clause); - } - - /** - * @param String|array $filters Predicate(s) to set, as escaped SQL statements. - */ - public function addWhereAny($filters) { - if(is_string($filters)) $filters = func_get_args(); - $clause = implode(" OR ", $filters); - return $this->addWhere($clause); - } - - /** - * Use the disjunctive operator 'OR' to join filter expressions in the WHERE clause. - */ - public function useDisjunction() { - $this->connective = 'OR'; - } - - /** - * Use the conjunctive operator 'AND' to join filter expressions in the WHERE clause. - */ - public function useConjunction() { - $this->connective = 'AND'; - } - - /** - * Swap the use of one table with another. - * - * @param string $old Name of the old table (unquoted, escaped) - * @param string $new Name of the new table (unquoted, escaped) - */ - public function renameTable($old, $new) { - $this->replaceText("`$old`", "`$new`"); - $this->replaceText("\"$old\"", "\"$new\""); - } - - /** - * Swap some text in the SQL query with another. - * - * @param string $old The old text (escaped) - * @param string $new The new text (escaped) - */ - public function replaceText($old, $new) { - $this->replacementsOld[] = $old; - $this->replacementsNew[] = $new; - } - - public function getFilter() { - Deprecation::notice('3.0', 'Please use itemized filters in getWhere() instead of getFilter()'); - return DB::getConn()->sqlWhereToString($this->getWhere(), $this->getConnective()); - } - - /** - * Return a list of FROM clauses used internally. - * @return array - */ - public function getFrom() { - return $this->from; - } - - /** - * Return a list of HAVING clauses used internally. - * @return array - */ - public function getHaving() { - return $this->having; - } - - /** - * Return a list of GROUP BY clauses used internally. - * @return array - */ - public function getGroupBy() { - return $this->groupby; - } - - /** - * Return a list of WHERE clauses used internally. - * @return array - */ - public function getWhere() { - return $this->where; - } - - /** - * Return an itemised select list as a map, where keys are the aliases, and values are the column sources. - * Aliases will always be provided (if the alias is implicit, the alias value will be inferred), and won't be - * quoted. - * E.g., 'Title' => '"SiteTree"."Title"'. - */ - public function getSelect() { - return $this->select; - } - - /** - * Generate the SQL statement for this query. - * - * @return string - */ - public function sql() { - // TODO: Don't require this internal-state manipulate-and-preserve - let sqlQueryToString() handle the new - // syntax - $origFrom = $this->from; - // Sort the joins - $this->from = $this->getOrderedJoins($this->from); - // Build from clauses - foreach($this->from as $alias => $join) { - // $join can be something like this array structure - // array('type' => 'inner', 'table' => 'SiteTree', 'filter' => array("SiteTree.ID = 1", - // "Status = 'approved'", 'order' => 20)) - if(is_array($join)) { - if(is_string($join['filter'])) $filter = $join['filter']; - else if(sizeof($join['filter']) == 1) $filter = $join['filter'][0]; - else $filter = "(" . implode(") AND (", $join['filter']) . ")"; - - $aliasClause = ($alias != $join['table']) ? " AS \"" . Convert::raw2sql($alias) . "\"" : ""; - $this->from[$alias] = strtoupper($join['type']) . " JOIN \"" - . $join['table'] . "\"$aliasClause ON $filter"; - } - } - - $sql = DB::getConn()->sqlQueryToString($this); - - if($this->replacementsOld) { - $sql = str_replace($this->replacementsOld, $this->replacementsNew, $sql); - } - - $this->from = $origFrom; - - // The query was most likely just created and then exectued. - if(trim($sql) === 'SELECT * FROM') { - return ''; - } - return $sql; - } - - /** - * Return the generated SQL string for this query - * - * @return string - */ - public function __toString() { - try { - return $this->sql(); - } catch(Exception $e) { - return ""; - } - } - - /** - * Execute this query. - * @return SS_Query - */ - public function execute() { - return DB::query($this->sql(), E_USER_ERROR); - } - - /** - * Checks whether this query is for a specific ID in a table - * - * @todo Doesn't work with combined statements (e.g. "Foo='bar' AND ID=5") - * - * @return boolean - */ - public function filtersOnID() { - $regexp = '/^(.*\.)?("|`)?ID("|`)?\s?=/'; - - // Sometimes the ID filter will be the 2nd element, if there's a ClasssName filter first. - if(isset($this->where[0]) && preg_match($regexp, $this->where[0])) return true; - if(isset($this->where[1]) && preg_match($regexp, $this->where[1])) return true; - - return false; - } - - /** - * Checks whether this query is filtering on a foreign key, ie finding a has_many relationship - * - * @todo Doesn't work with combined statements (e.g. "Foo='bar' AND ParentID=5") - * - * @return boolean - */ - public function filtersOnFK() { - return ( - $this->where - && preg_match('/^(.*\.)?("|`)?[a-zA-Z]+ID("|`)?\s?=/', $this->where[0]) - ); - } - - /// VARIOUS TRANSFORMATIONS BELOW - - /** - * Return the number of rows in this query if the limit were removed. Useful in paged data sets. - * @return int - */ - public function unlimitedRowCount($column = null) { - // we can't clear the select if we're relying on its output by a HAVING clause - if(count($this->having)) { - $records = $this->execute(); - return $records->numRecords(); - } - - $clone = clone $this; - $clone->limit = null; - $clone->orderby = null; - - // Choose a default column - if($column == null) { - if($this->groupby) { - $countQuery = new SQLQuery(); - $countQuery->select("count(*)"); - $countQuery->from = array('(' . $clone->sql() . ') all_distinct'); - - return $countQuery->execute()->value(); - - } else { - $clone->setSelect(array("count(*)")); - } - } else { - $clone->setSelect(array("count($column)")); - } - - $clone->setGroupBy(array());; - return $clone->execute()->value(); - } - - /** - * Returns true if this query can be sorted by the given field. - */ - public function canSortBy($fieldName) { - $fieldName = preg_replace('/(\s+?)(A|DE)SC$/', '', $fieldName); - - return isset($this->select[$fieldName]); - } - - - /** - * Return the number of rows in this query if the limit were removed. Useful in paged data sets. - * - * @todo Respect HAVING and GROUPBY, which can affect the result-count - * - * @param String $column Quoted, escaped column name - * @return int - */ - public function count( $column = null) { - // Choose a default column - if($column == null) { - if($this->groupby) { - $column = 'DISTINCT ' . implode(", ", $this->groupby); - } else { - $column = '*'; - } - } - - $clone = clone $this; - $clone->select = array("count($column)"); - $clone->limit = null; - $clone->orderby = null; - $clone->groupby = null; - - $count = $clone->execute()->value(); - // If there's a limit set, then that limit is going to heavily affect the count - if($this->limit) { - if($count >= ($this->limit['start'] + $this->limit['limit'])) - return $this->limit['limit']; - else - return max(0, $count - $this->limit['start']); - - // Otherwise, the count is going to be the output of the SQL query - } else { - return $count; - } - } - - /** - * Return a new SQLQuery that calls the given aggregate functions on this data. - * - * @param $column An aggregate expression, such as 'MAX("Balance")', or a set of them (as an escaped SQL statement) - * @param $alias An optional alias for the aggregate column. - */ - public function aggregate($column, $alias = null) { - $clone = clone $this; - - // don't set an ORDER BY clause if no limit has been set. It doesn't make - // sense to add an ORDER BY if there is no limit, and it will break - // queries to databases like MSSQL if you do so. Note that the reason - // this came up is because DataQuery::initialiseQuery() introduces - // a default sort. - if($this->limit) { - $clone->setLimit($this->limit); - $clone->setOrderBy($this->orderby); - } else { - $clone->setOrderBy(array()); - } - - $clone->setGroupBy($this->groupby); - if($alias) { - $clone->setSelect(array()); - $clone->selectField($column, $alias); - } else { - $clone->setSelect($column); - } - - return $clone; - } - - /** - * Returns a query that returns only the first row of this query - */ - public function firstRow() { - $query = clone $this; - $offset = $this->limit ? $this->limit['start'] : 0; - $query->setLimit(1, $offset); - return $query; - } - - /** - * Returns a query that returns only the last row of this query - */ - public function lastRow() { - $query = clone $this; - $offset = $this->limit ? $this->limit['start'] : 0; - - // Limit index to start in case of empty results - $index = max($this->count() + $offset - 1, 0); - $query->setLimit(1, $index); - return $query; - } - - /** - * Ensure that framework "auto-generated" table JOINs are first in the finalised SQL query. - * This prevents issues where developer-initiated JOINs attempt to JOIN using relations that haven't actually - * yet been scaffolded by the framework. Demonstrated by PostGres in errors like: - *"...ERROR: missing FROM-clause..." - * - * @param $from array - in the format of $this->select - * @return array - and reorderded list of selects - */ - protected function getOrderedJoins($from) { - // shift the first FROM table out from so we only deal with the JOINs - $baseFrom = array_shift($from); - $this->mergesort($from, function($firstJoin, $secondJoin) { - if( - !is_array($firstJoin) - || !is_array($secondJoin) - || $firstJoin['order'] == $secondJoin['order'] - ) { - return 0; - } else { - return ($firstJoin['order'] < $secondJoin['order']) ? -1 : 1; - } - }); - - // Put the first FROM table back into the results - array_unshift($from, $baseFrom); - return $from; - } - - /** - * Since uasort don't preserve the order of an array if the comparison is equal - * we have to resort to a merge sort. It's quick and stable: O(n*log(n)). - * - * @see http://stackoverflow.com/q/4353739/139301 - * - * @param array &$array - the array to sort - * @param callable $cmpFunction - the function to use for comparison - */ - protected function mergesort(&$array, $cmpFunction = 'strcmp') { - // Arrays of size < 2 require no action. - if (count($array) < 2) { - return; - } - // Split the array in half - $halfway = count($array) / 2; - $array1 = array_slice($array, 0, $halfway); - $array2 = array_slice($array, $halfway); - // Recurse to sort the two halves - $this->mergesort($array1, $cmpFunction); - $this->mergesort($array2, $cmpFunction); - // If all of $array1 is <= all of $array2, just append them. - if(call_user_func($cmpFunction, end($array1), reset($array2)) < 1) { - $array = array_merge($array1, $array2); - return; - } - // Merge the two sorted arrays into a single sorted array - $array = array(); - $val1 = reset($array1); - $val2 = reset($array2); - do { - if (call_user_func($cmpFunction, $val1, $val2) < 1) { - $array[key($array1)] = $val1; - $val1 = next($array1); - } else { - $array[key($array2)] = $val2; - $val2 = next($array2); - } - } while($val1 && $val2); - - // Merge the remainder - while($val1) { - $array[key($array1)] = $val1; - $val1 = next($array1); - } - while($val2) { - $array[key($array2)] = $val2; - $val2 = next($array2); - } - return; - } -} - diff --git a/model/Versioned.php b/model/Versioned.php index 5c5ad7383..78c3cdda4 100644 --- a/model/Versioned.php +++ b/model/Versioned.php @@ -162,10 +162,10 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { * Amend freshly created DataQuery objects with versioned-specific * information. * - * @param SQLQuery + * @param SQLSelect * @param DataQuery */ - public function augmentDataQueryCreation(SQLQuery &$query, DataQuery &$dataQuery) { + public function augmentDataQueryCreation(SQLSelect &$query, DataQuery &$dataQuery) { $parts = explode('.', Versioned::get_reading_mode()); if($parts[0] == 'Archive') { @@ -182,22 +182,22 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { } /** - * Augment the the SQLQuery that is created by the DataQuery + * Augment the the SQLSelect that is created by the DataQuery * @todo Should this all go into VersionedDataQuery? */ - public function augmentSQL(SQLQuery &$query, DataQuery &$dataQuery = null) { + public function augmentSQL(SQLSelect $query, DataQuery $dataQuery = null) { if(!$dataQuery || !$dataQuery->getQueryParam('Versioned.mode')) { return; } $baseTable = ClassInfo::baseDataClass($dataQuery->dataClass()); - + switch($dataQuery->getQueryParam('Versioned.mode')) { // Reading a specific data from the archive case 'archive': $date = $dataQuery->getQueryParam('Versioned.date'); foreach($query->getFrom() as $table => $dummy) { - if(!DB::getConn()->hasTable($table . '_versions')) { + if(!DB::get_schema()->hasTable($table . '_versions')) { continue; } @@ -216,19 +216,19 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { } } // Link to the version archived on that date - $safeDate = Convert::raw2sql($date); - $query->addWhere( - "\"{$baseTable}_versions\".\"Version\" IN - (SELECT LatestVersion FROM - (SELECT - \"{$baseTable}_versions\".\"RecordID\", - MAX(\"{$baseTable}_versions\".\"Version\") AS LatestVersion - FROM \"{$baseTable}_versions\" - WHERE \"{$baseTable}_versions\".\"LastEdited\" <= '$safeDate' - GROUP BY \"{$baseTable}_versions\".\"RecordID\" - ) AS \"{$baseTable}_versions_latest\" - WHERE \"{$baseTable}_versions_latest\".\"RecordID\" = \"{$baseTable}_versions\".\"RecordID\" - )"); + $query->addWhere(array( + "\"{$baseTable}_versions\".\"Version\" IN + (SELECT LatestVersion FROM + (SELECT + \"{$baseTable}_versions\".\"RecordID\", + MAX(\"{$baseTable}_versions\".\"Version\") AS LatestVersion + FROM \"{$baseTable}_versions\" + WHERE \"{$baseTable}_versions\".\"LastEdited\" <= ? + GROUP BY \"{$baseTable}_versions\".\"RecordID\" + ) AS \"{$baseTable}_versions_latest\" + WHERE \"{$baseTable}_versions_latest\".\"RecordID\" = \"{$baseTable}_versions\".\"RecordID\" + )" => $date + )); break; // Reading a specific stage (Stage or Live) @@ -329,11 +329,11 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { /** * For lazy loaded fields requiring extra sql manipulation, ie versioning. * - * @param SQLQuery $query + * @param SQLSelect $query * @param DataQuery $dataQuery * @param DataObject $dataObject */ - public function augmentLoadLazyFields(SQLQuery &$query, DataQuery &$dataQuery = null, $dataObject) { + public function augmentLoadLazyFields(SQLSelect &$query, DataQuery &$dataQuery = null, $dataObject) { // The VersionedMode local variable ensures that this decorator only applies to // queries that have originated from the Versioned object, and have the Versioned // metadata set on the query object. This prevents regular queries from @@ -362,7 +362,7 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { */ public static function on_db_reset() { // Drop all temporary tables - $db = DB::getConn(); + $db = DB::get_conn(); foreach(self::$archive_tables as $tableName) { if(method_exists($db, 'dropTable')) $db->dropTable($tableName); else $db->query("DROP TABLE \"$tableName\""); @@ -417,7 +417,7 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { // otherwise. $indexes = $this->uniqueToIndex($indexes); if($stage != $this->defaultStage) { - DB::requireTable("{$table}_$stage", $fields, $indexes, false, $options); + DB::require_table("{$table}_$stage", $fields, $indexes, false, $options); } // Version fields on each root table (including Stage) @@ -464,7 +464,7 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { ); } - if(DB::getConn()->hasTable("{$table}_versions")) { + if(DB::get_schema()->hasTable("{$table}_versions")) { // Fix data that lacks the uniqueness constraint (since this was added later and // bugs meant that the constraint was validated) $duplications = DB::query("SELECT MIN(\"ID\") AS \"ID\", \"RecordID\", \"Version\" @@ -474,8 +474,11 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { foreach($duplications as $dup) { DB::alteration_message("Removing {$table}_versions duplicate data for " ."{$dup['RecordID']}/{$dup['Version']}" ,"deleted"); - DB::query("DELETE FROM \"{$table}_versions\" WHERE \"RecordID\" = {$dup['RecordID']} - AND \"Version\" = {$dup['Version']} AND \"ID\" != {$dup['ID']}"); + DB::prepared_query( + "DELETE FROM \"{$table}_versions\" WHERE \"RecordID\" = ? + AND \"Version\" = ? AND \"ID\" != ?", + array($dup['RecordID'], $dup['Version'], $dup['ID']) + ); } // Remove junk which has no data in parent classes. Only needs to run the following @@ -496,7 +499,7 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { if($count > 0) { DB::alteration_message("Removing orphaned versioned records", "deleted"); - $effectedIDs = DB::query(" + $affectedIDs = DB::query(" SELECT \"{$table}_versions\".\"ID\" FROM \"{$table}_versions\" LEFT JOIN \"{$child}_versions\" ON \"{$child}_versions\".\"RecordID\" = \"{$table}_versions\".\"RecordID\" @@ -504,10 +507,12 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { WHERE \"{$child}_versions\".\"ID\" IS NULL ")->column(); - if(is_array($effectedIDs)) { - foreach($effectedIDs as $key => $value) { - DB::query("DELETE FROM \"{$table}_versions\"" - . " WHERE \"{$table}_versions\".\"ID\" = '$value'"); + if(is_array($affectedIDs)) { + foreach($affectedIDs as $key => $value) { + DB::prepared_query( + "DELETE FROM \"{$table}_versions\" WHERE \"ID\" = ?", + array($value) + ); } } } @@ -515,11 +520,11 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { } } - DB::requireTable("{$table}_versions", $versionFields, $versionIndexes, true, $options); + DB::require_table("{$table}_versions", $versionFields, $versionIndexes, true, $options); } else { - DB::dontRequireTable("{$table}_versions"); + DB::dont_require_table("{$table}_versions"); foreach($this->stages as $stage) { - if($stage != $this->defaultStage) DB::dontrequireTable("{$table}_$stage"); + if($stage != $this->defaultStage) DB::dont_require_table("{$table}_$stage"); } } } @@ -554,11 +559,6 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { return $results; } - /** - * Augment a write-record request. - * - * @param SQLQuery $manipulation Query to augment. - */ public function augmentWrite(&$manipulation) { $tables = array_keys($manipulation); $version_table = array(); @@ -590,10 +590,10 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { // Otherwise, we're just copying a version to another table if(!isset($manipulation[$table]['fields']['Version'])) { // Add any extra, unchanged fields to the version record. - $data = DB::query("SELECT * FROM \"$table\" WHERE \"ID\" = $id")->record(); + $data = DB::prepared_query("SELECT * FROM \"$table\" WHERE \"ID\" = ?", array($id))->record(); if($data) foreach($data as $k => $v) { if (!isset($newManipulation['fields'][$k])) { - $newManipulation['fields'][$k] = "'" . Convert::raw2sql($v) . "'"; + $newManipulation['fields'][$k] = $v; } } @@ -606,8 +606,10 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { else unset($nextVersion); if($rid && !isset($nextVersion)) { - $nextVersion = DB::query("SELECT MAX(\"Version\") + 1 FROM \"{$baseDataClass}_versions\"" - . " WHERE \"RecordID\" = $rid")->value(); + $nextVersion = DB::prepared_query("SELECT MAX(\"Version\") + 1 + FROM \"{$baseDataClass}_versions\" WHERE \"RecordID\" = ?", + array($rid) + )->value(); } $newManipulation['fields']['Version'] = $nextVersion ? $nextVersion : 1; @@ -646,7 +648,10 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { ) { // If the record has already been inserted in the (table), get rid of it. if($manipulation[$table]['command']=='insert') { - DB::query("DELETE FROM \"{$table}\" WHERE \"ID\"='$id'"); + DB::prepared_query( + "DELETE FROM \"{$table}\" WHERE \"ID\" = ?", + array($id) + ); } $newTable = $table . '_' . Versioned::current_stage(); @@ -758,9 +763,11 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { $table2 = $table1 . "_$this->liveStage"; - return DB::query("SELECT \"$table1\".\"Version\" = \"$table2\".\"Version\" FROM \"$table1\"" - . " INNER JOIN \"$table2\" ON \"$table1\".\"ID\" = \"$table2\".\"ID\"" - . " WHERE \"$table1\".\"ID\" = ". $this->owner->ID)->value(); + return DB::prepared_query("SELECT \"$table1\".\"Version\" = \"$table2\".\"Version\" FROM \"$table1\" + INNER JOIN \"$table2\" ON \"$table1\".\"ID\" = \"$table2\".\"ID\" + WHERE \"$table1\".\"ID\" = ?", + array($this->owner->ID) + )->value(); } /** @@ -796,13 +803,16 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { } // Mark this version as having been published at some stage - DB::query("UPDATE \"{$extTable}_versions\" SET \"WasPublished\" = '1', \"PublisherID\" = $publisherID" - . " WHERE \"RecordID\" = $from->ID AND \"Version\" = $from->Version"); + DB::prepared_query("UPDATE \"{$extTable}_versions\" + SET \"WasPublished\" = ?, \"PublisherID\" = ? + WHERE \"RecordID\" = ? AND \"Version\" = ?", + array(1, $publisherID, $from->ID, $from->Version) + ); $oldMode = Versioned::get_reading_mode(); Versioned::reading_stage($toStage); - $conn = DB::getConn(); + $conn = DB::get_conn(); if(method_exists($conn, 'allowPrimaryKeyEditing')) $conn->allowPrimaryKeyEditing($baseClass, true); $from->write(); if(method_exists($conn, 'allowPrimaryKeyEditing')) $conn->allowPrimaryKeyEditing($baseClass, false); @@ -844,9 +854,12 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { // will be false. // TODO: DB Abstraction: if statement here: - $stagesAreEqual = DB::query("SELECT CASE WHEN \"$table1\".\"Version\"=\"$table2\".\"Version\"" - . " THEN 1 ELSE 0 END FROM \"$table1\" INNER JOIN \"$table2\" ON \"$table1\".\"ID\" = \"$table2\".\"ID\"" - . " AND \"$table1\".\"ID\" = {$this->owner->ID}")->value(); + $stagesAreEqual = DB::prepared_query( + "SELECT CASE WHEN \"$table1\".\"Version\"=\"$table2\".\"Version\" THEN 1 ELSE 0 END + FROM \"$table1\" INNER JOIN \"$table2\" ON \"$table1\".\"ID\" = \"$table2\".\"ID\" + AND \"$table1\".\"ID\" = ?", + array($this->owner->ID) + )->value(); return !$stagesAreEqual; } @@ -898,7 +911,9 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { $query->selectField(sprintf('"%s_versions"."%s"', $baseTable, $name), $name); } - $query->addWhere("\"{$baseTable}_versions\".\"RecordID\" = '{$this->owner->ID}'"); + $query->addWhere(array( + "\"{$baseTable}_versions\".\"RecordID\" = ?" => $this->owner->ID + )); $query->setOrderBy(($sort) ? $sort : "\"{$baseTable}_versions\".\"LastEdited\" DESC, \"{$baseTable}_versions\".\"Version\" DESC"); @@ -1120,7 +1135,10 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { } // get version as performance-optimized SQL query (gets called for each page in the sitetree) - $version = DB::query("SELECT \"Version\" FROM \"$stageTable\" WHERE \"ID\" = $id")->value(); + $version = DB::prepared_query( + "SELECT \"Version\" FROM \"$stageTable\" WHERE \"ID\" = ?", + array($id) + )->value(); // cache value (if required) if($cache) { @@ -1152,7 +1170,7 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { return; } $filter = ""; - + $parameters = array(); if($idList) { // Validate the ID list foreach($idList as $id) { @@ -1161,14 +1179,14 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { E_USER_ERROR); } } - - $filter = "WHERE \"ID\" IN(" .implode(", ", $idList) . ")"; + $filter = 'WHERE "ID" IN ('.DB::placeholders($idList).')'; + $parameters = $idList; } $baseClass = ClassInfo::baseDataClass($class); $stageTable = ($stage == 'Stage') ? $baseClass : "{$baseClass}_{$stage}"; - $versions = DB::query("SELECT \"ID\", \"Version\" FROM \"$stageTable\" $filter")->map(); + $versions = DB::prepared_query("SELECT \"ID\", \"Version\" FROM \"$stageTable\" $filter", $parameters)->map(); foreach($versions as $id => $version) { self::$cache_versionnumber[$baseClass][$stage][$id] = $version; @@ -1186,7 +1204,7 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { * @param int $limit A limit on the number of records returned from the database. * @param string $containerClass The container class for the result set (default is DataList) * - * @return SS_List + * @return DataList A modified DataList designated to the specified stage */ public static function get_by_stage($class, $stage, $filter = '', $sort = '', $join = '', $limit = '', $containerClass = 'DataList') { diff --git a/model/connect/DBConnector.php b/model/connect/DBConnector.php new file mode 100644 index 000000000..b36d7ea76 --- /dev/null +++ b/model/connect/DBConnector.php @@ -0,0 +1,248 @@ +formatPlain($sql); + $msg = "Couldn't run query:\n\n{$formattedSQL}\n\n{$msg}"; + } + + if($errorLevel === E_USER_ERROR) { + // Treating errors as exceptions better allows for responding to errors + // in code, such as credential checking during installation + throw new SS_DatabaseException($msg, 0, null, $sql, $parameters); + } else { + user_error($msg, $errorLevel); + } + } + + /** + * Determines if the query should be previewed, and thus interrupted silently. + * If so, this function also displays the query via the debuging system. + * Subclasess should respect the results of this call for each query, and not + * execute any queries that generate a true response. + * + * @param string $sql The query to be executed + * @return boolean Flag indicating that the query was previewed + */ + protected function previewWrite($sql) { + // Break if not requested + if (!isset($_REQUEST['previewwrite'])) return false; + + // Break if non-write operation + $operation = strtolower(substr($sql, 0, strpos($sql, ' '))); + $writeOperations = Config::inst()->get(get_class($this), 'write_operations'); + if (!in_array($operation, $writeOperations)) { + return false; + } + + // output preview message + Debug::message("Will execute: $sql"); + return true; + } + + /** + * Allows the display and benchmarking of queries as they are being run + * + * @param string $sql Query to run, and single parameter to callback + * @param callable $callback Callback to execute code + * @return mixed Result of query + */ + protected function benchmarkQuery($sql, $callback) { + if (isset($_REQUEST['showqueries']) && Director::isDev(true)) { + $starttime = microtime(true); + $result = $callback($sql); + $endtime = round(microtime(true) - $starttime, 4); + Debug::message("\n$sql\n{$endtime}ms\n", false); + return $result; + } else { + return $callback($sql); + } + } + + /** + * Extracts only the parameter values for error reporting + * + * @param array $parameters + * @return array List of parameter values + */ + protected function parameterValues($parameters) { + $values = array(); + foreach($parameters as $value) { + $values[] = is_array($value) ? $value['value'] : $value; + } + return $values; + } + + /** + * Link this connector to the database given the specified parameters + * Will throw an exception rather than return a success state. + * The connector should not select the database once connected until + * explicitly called by selectDatabase() + * + * @param array $parameters List of parameters such as + *
      + *
    • type
    • + *
    • server
    • + *
    • username
    • + *
    • password
    • + *
    • database
    • + *
    • path
    • + *
    + * @param boolean $selectDB By default database selection should be + * handled by the database controller (to enable database creation on the + * fly if necessary), but some interfaces require that the database is + * specified during connection (SQLite, Azure, etc). + */ + abstract public function connect($parameters, $selectDB = false); + + /** + * Query for the version of the currently connected database + * + * @return string Version of this database + */ + abstract public function getVersion(); + + /** + * Given a value escape this for use in a query for the current database + * connector. Note that this does not quote the value. + * + * @param string $value The value to be escaped + * @return string The appropritaely escaped string for value + */ + abstract public function escapeString($value); + + /** + * Given a value escape and quote this appropriately for the current + * database connector. + * + * @param string $value The value to be injected into a query + * @return string The appropriately escaped and quoted string for $value + */ + abstract public function quoteString($value); + + /** + * Escapes an identifier (table / database name). Typically the value + * is simply double quoted. Don't pass in already escaped identifiers in, + * as this will double escape the value! + * + * @param string $value The identifier to escape + * @param string $separator optional identifier splitter + */ + public function escapeIdentifier($value, $separator = '.') { + // ANSI standard id escape is to surround with double quotes + if(empty($separator)) return '"'.trim($value).'"'; + + // Split, escape, and glue back multiple identifiers + $segments = array(); + foreach(explode($separator, $value) as $item) { + $segments[] = $this->escapeIdentifier($item, null); + } + return implode($separator, $segments); + } + + /** + * Executes the following query with the specified error level. + * Implementations of this function should respect previewWrite and benchmarkQuery + * + * @see http://php.net/manual/en/errorfunc.constants.php + * @param string $sql The SQL query to execute + * @param integer $errorLevel For errors to this query, raise PHP errors + * using this error level. + */ + abstract public function query($sql, $errorLevel = E_USER_ERROR); + + /** + * Execute the given SQL parameterised query with the specified arguments + * + * @param string $sql The SQL query to execute. The ? character will denote parameters. + * @param array $parameters An ordered list of arguments. + * @param int $errorLevel The level of error reporting to enable for the query + * @return SS_Query + */ + abstract public function preparedQuery($sql, $parameters, $errorLevel = E_USER_ERROR); + + /** + * Select a database by name + * + * @param string $name Name of database + * @return boolean Flag indicating success + */ + abstract public function selectDatabase($name); + + /** + * Retrieves the name of the currently selected database + * + * @return string Name of the database, or null if none selected + */ + abstract public function getSelectedDatabase(); + + /** + * De-selects the currently selected database + */ + abstract public function unloadDatabase(); + + /** + * Retrieves the last error generated from the database connection + * + * @return string The error message + */ + abstract public function getLastError(); + + /** + * Determines the last ID generated from the specified table. + * Note that some connectors may not be able to return $table specific responses, + * and this parameter may be ignored. + * + * @param string $table The target table to return the last generated ID for + * @return integer ID value + */ + abstract public function getGeneratedID($table); + + /** + * Determines the number of affected rows from the last SQL query + * + * @return integer Number of affected rows + */ + abstract public function affectedRows(); + + /** + * Determines if we are connected to a server AND have a valid database + * selected. + * + * @return boolean Flag indicating that a valid database is connected + */ + abstract public function isActive(); +} diff --git a/model/connect/DBQueryBuilder.php b/model/connect/DBQueryBuilder.php new file mode 100644 index 000000000..ebcb05b69 --- /dev/null +++ b/model/connect/DBQueryBuilder.php @@ -0,0 +1,332 @@ +isEmpty()) return null; + + if($query instanceof SQLSelect) { + $sql = $this->buildSelectQuery($query, $parameters); + } elseif($query instanceof SQLDelete) { + $sql = $this->buildDeleteQuery($query, $parameters); + } elseif($query instanceof SQLInsert) { + $sql = $this->buildInsertQuery($query, $parameters); + } elseif($query instanceof SQLUpdate) { + $sql = $this->buildUpdateQuery($query, $parameters); + } else { + user_error("Not implemented: query generation for type " . $query->getType()); + } + return $sql; + } + + /** + * Builds a query from a SQLSelect expression + * + * @param SQLSelect $query The expression object to build from + * @param array $parameters Out parameter for the resulting query parameters + * @return string Completed SQL string + */ + protected function buildSelectQuery(SQLSelect $query, array &$parameters) { + $sql = $this->buildSelectFragment($query, $parameters); + $sql .= $this->buildFromFragment($query, $parameters); + $sql .= $this->buildWhereFragment($query, $parameters); + $sql .= $this->buildGroupByFragment($query, $parameters); + $sql .= $this->buildHavingFragment($query, $parameters); + $sql .= $this->buildOrderByFragment($query, $parameters); + $sql .= $this->buildLimitFragment($query, $parameters); + return $sql; + } + + /** + * Builds a query from a SQLDelete expression + * + * @param SQLDelete $query The expression object to build from + * @param array $parameters Out parameter for the resulting query parameters + * @return string Completed SQL string + */ + protected function buildDeleteQuery(SQLDelete $query, array &$parameters) { + $sql = $this->buildDeleteFragment($query, $parameters); + $sql .= $this->buildFromFragment($query, $parameters); + $sql .= $this->buildWhereFragment($query, $parameters); + return $sql; + } + + /** + * Builds a query from a SQLInsert expression + * + * @param SQLInsert $query The expression object to build from + * @param array $parameters Out parameter for the resulting query parameters + * @return string Completed SQL string + */ + protected function buildInsertQuery(SQLInsert $query, array &$parameters) { + $nl = $this->getSeparator(); + $into = $query->getInto(); + + // Column identifiers + $columns = $query->getColumns(); + $sql = "INSERT INTO {$into}{$nl}(" . implode(', ', $columns) . ")"; + + // Values + $sql .= "{$nl}VALUES"; + + // Build all rows + $rowParts = array(); + foreach($query->getRows() as $row) { + // Build all columns in this row + $assignments = $row->getAssignments(); + // Join SET components together, considering parameters + $parts = array(); + foreach($columns as $column) { + // Check if this column has a value for this row + if(isset($assignments[$column])) { + // Assigment is a single item array, expand with a loop here + foreach($assignments[$column] as $assignmentSQL => $assignmentParameters) { + $parts[] = $assignmentSQL; + $parameters = array_merge($parameters, $assignmentParameters); + break; + } + } else { + // This row is missing a value for a column used by another row + $parts[] = '?'; + $parameters[] = null; + } + } + $rowParts[] = '(' . implode(', ', $parts) . ')'; + } + $sql .= $nl . implode(",$nl", $rowParts); + + return $sql; + } + + /** + * Builds a query from a SQLUpdate expression + * + * @param SQLUpdate $query The expression object to build from + * @param array $parameters Out parameter for the resulting query parameters + * @return string Completed SQL string + */ + protected function buildUpdateQuery(SQLUpdate $query, array &$parameters) { + $sql = $this->buildUpdateFragment($query, $parameters); + $sql .= $this->buildWhereFragment($query, $parameters); + return $sql; + } + + /** + * Returns the SELECT clauses ready for inserting into a query. + * + * @param SQLSelect $query The expression object to build from + * @param array $parameters Out parameter for the resulting query parameters + * @return string Completed select part of statement + */ + protected function buildSelectFragment(SQLSelect $query, array &$parameters) { + $distinct = $query->getDistinct(); + $select = $query->getSelect(); + $clauses = array(); + + foreach ($select as $alias => $field) { + // Don't include redundant aliases. + $fieldAlias = "\"{$alias}\""; + if ($alias === $field || substr($field, -strlen($fieldAlias)) === $fieldAlias) { + $clauses[] = $field; + } else { + $clauses[] = "$field AS $fieldAlias"; + } + } + + $text = 'SELECT '; + if ($distinct) $text .= 'DISTINCT '; + return $text .= implode(', ', $clauses); + } + + /** + * Return the DELETE clause ready for inserting into a query. + * + * @param SQLExpression $query The expression object to build from + * @param array $parameters Out parameter for the resulting query parameters + * @return string Completed delete part of statement + */ + public function buildDeleteFragment(SQLDelete $query, array &$parameters) { + $text = 'DELETE'; + + // If doing a multiple table delete then list the target deletion tables here + // Note that some schemas don't support multiple table deletion + $delete = $query->getDelete(); + if(!empty($delete)) { + $text .= ' ' . implode(', ', $delete); + } + return $text; + } + + /** + * Return the UPDATE clause ready for inserting into a query. + * + * @param SQLExpression $query The expression object to build from + * @param array $parameters Out parameter for the resulting query parameters + * @return string Completed from part of statement + */ + public function buildUpdateFragment(SQLUpdate $query, array &$parameters) { + $table = $query->getTable(); + $text = "UPDATE $table"; + + // Join SET components together, considering parameters + $parts = array(); + foreach($query->getAssignments() as $column => $assignment) { + // Assigment is a single item array, expand with a loop here + foreach($assignment as $assignmentSQL => $assignmentParameters) { + $parts[] = "$column = $assignmentSQL"; + $parameters = array_merge($parameters, $assignmentParameters); + break; + } + } + $nl = $this->getSeparator(); + $text .= "{$nl}SET " . implode(', ', $parts); + return $text; + } + + /** + * Return the FROM clause ready for inserting into a query. + * + * @param SQLExpression $query The expression object to build from + * @param array $parameters Out parameter for the resulting query parameters + * @return string Completed from part of statement + */ + public function buildFromFragment(SQLExpression $query, array &$parameters) { + $from = $query->getJoins(); + $nl = $this->getSeparator(); + return "{$nl}FROM " . implode(' ', $from); + } + + /** + * Returns the WHERE clauses ready for inserting into a query. + * + * @param SQLExpression $query The expression object to build from + * @param array $parameters Out parameter for the resulting query parameters + * @return string Completed where condition + */ + public function buildWhereFragment(SQLExpression $query, array &$parameters) { + + // Get parameterised elements + $where = $query->getWhereParameterised($whereParameters); + if(empty($where)) return ''; + + // Join conditions + $connective = $query->getConnective(); + $parameters = array_merge($parameters, $whereParameters); + $nl = $this->getSeparator(); + return "{$nl}WHERE (" . implode("){$nl}{$connective} (", $where) . ")"; + } + + /** + * Returns the ORDER BY clauses ready for inserting into a query. + * + * @param SQLSelect $query The expression object to build from + * @param array $parameters Out parameter for the resulting query parameters + * @return string Completed order by part of statement + */ + public function buildOrderByFragment(SQLSelect $query, array &$parameters) { + $orderBy = $query->getOrderBy(); + if(empty($orderBy)) return ''; + + // Build orders, each with direction considered + $statements = array(); + foreach ($orderBy as $clause => $dir) { + $statements[] = trim("$clause $dir"); + } + + $nl = $this->getSeparator(); + return "{$nl}ORDER BY " . implode(', ', $statements); + } + + /** + * Returns the GROUP BY clauses ready for inserting into a query. + * + * @param SQLSelect $query The expression object to build from + * @param array $parameters Out parameter for the resulting query parameters + * @return string Completed group part of statement + */ + public function buildGroupByFragment(SQLSelect $query, array &$parameters) { + $groupBy = $query->getGroupBy(); + if(empty($groupBy)) return ''; + + $nl = $this->getSeparator(); + return "{$nl}GROUP BY " . implode(', ', $groupBy); + } + + /** + * Returns the HAVING clauses ready for inserting into a query. + * + * @param SQLSelect $query The expression object to build from + * @param array $parameters Out parameter for the resulting query parameters + * @return string + */ + public function buildHavingFragment(SQLSelect $query, array &$parameters) { + $having = $query->getHavingParameterised($havingParameters); + if(empty($having)) return ''; + + // Generate having, considering parameters present + $connective = $query->getConnective(); + $parameters = array_merge($parameters, $havingParameters); + $nl = $this->getSeparator(); + return "{$nl}HAVING (" . implode("){$nl}{$connective} (", $having) . ")"; + } + + /** + * Return the LIMIT clause ready for inserting into a query. + * + * @param SQLSelect $query The expression object to build from + * @param array $parameters Out parameter for the resulting query parameters + * @return string The finalised limit SQL fragment + */ + public function buildLimitFragment(SQLSelect $query, array &$parameters) { + $nl = $this->getSeparator(); + + // Ensure limit is given + $limit = $query->getLimit(); + if(empty($limit)) return ''; + + // For literal values return this as the limit SQL + if (!is_array($limit)) { + return "{$nl}LIMIT $limit"; + } + + // Assert that the array version provides the 'limit' key + if (!isset($limit['limit']) || !is_numeric($limit['limit'])) { + throw new InvalidArgumentException( + 'DBQueryBuilder::buildLimitSQL(): Wrong format for $limit: '. var_export($limit, true) + ); + } + + // Format the array limit, given an optional start key + $clause = "{$nl}LIMIT {$limit['limit']}"; + if(isset($limit['start']) && is_numeric($limit['start']) && $limit['start'] !== 0) { + $clause .= " OFFSET {$limit['start']}"; + } + return $clause; + } +} diff --git a/model/connect/DBSchemaManager.php b/model/connect/DBSchemaManager.php new file mode 100644 index 000000000..760b43ec9 --- /dev/null +++ b/model/connect/DBSchemaManager.php @@ -0,0 +1,938 @@ +database = $database; + } + + /** + * The table list, generated by the tableList() function. + * Used by the requireTable() function. + * + * @var array + */ + protected $tableList; + + /** + * Keeps track whether we are currently updating the schema. + * + * @var boolean + */ + protected $schemaIsUpdating = false; + + /** + * Large array structure that represents a schema update transaction + * + * @var array + */ + protected $schemaUpdateTransaction; + + /** + * Enable supression of database messages. + */ + public function quiet() { + $this->supressOutput = true; + } + + /** + * Execute the given SQL query. + * This abstract function must be defined by subclasses as part of the actual implementation. + * It should return a subclass of SS_Query as the result. + * + * @param string $sql The SQL query to execute + * @param int $errorLevel The level of error reporting to enable for the query + * @return SS_Query + */ + public function query($sql, $errorLevel = E_USER_ERROR) { + return $this->database->query($sql, $errorLevel); + } + + + /** + * Execute the given SQL parameterised query with the specified arguments + * + * @param string $sql The SQL query to execute. The ? character will denote parameters. + * @param array $parameters An ordered list of arguments. + * @param int $errorLevel The level of error reporting to enable for the query + * @return SS_Query + */ + public function preparedQuery($sql, $parameters, $errorLevel = E_USER_ERROR) { + return $this->database->preparedQuery($sql, $parameters, $errorLevel); + } + + /** + * Initiates a schema update within a single callback + * + * @var callable $callback + * @throws Exception + */ + public function schemaUpdate($callback) { + // Begin schema update + $this->schemaIsUpdating = true; + + // Update table list + $this->tableList = array(); + $tables = $this->tableList(); + foreach ($tables as $table) { + $this->tableList[strtolower($table)] = $table; + } + + // Clear update list for client code to mess around with + $this->schemaUpdateTransaction = array(); + + $error = null; + try { + + // Yield control to client code + $callback(); + + // If the client code has cancelled the update then abort + if(!$this->isSchemaUpdating()) return; + + // End schema update + foreach ($this->schemaUpdateTransaction as $tableName => $changes) { + switch ($changes['command']) { + case 'create': + $this->createTable($tableName, $changes['newFields'], $changes['newIndexes'], + $changes['options'], @$changes['advancedOptions']); + break; + + case 'alter': + $this->alterTable($tableName, $changes['newFields'], $changes['newIndexes'], + $changes['alteredFields'], $changes['alteredIndexes'], + $changes['alteredOptions'], @$changes['advancedOptions']); + break; + } + } + } catch(Exception $ex) { + $error = $ex; + } + // finally { + $this->schemaUpdateTransaction = null; + $this->schemaIsUpdating = false; + // } + + if($error) throw $error; + } + + /** + * Cancels the schema updates requested during (but not after) schemaUpdate() call. + */ + public function cancelSchemaUpdate() { + $this->schemaUpdateTransaction = null; + $this->schemaIsUpdating = false; + } + + /** + * Returns true if we are during a schema update. + * + * @return boolean + */ + function isSchemaUpdating() { + return $this->schemaIsUpdating; + } + + /** + * Returns true if schema modifications were requested during (but not after) schemaUpdate() call. + * + * @return boolean + */ + public function doesSchemaNeedUpdating() { + return (bool) $this->schemaUpdateTransaction; + } + + // Transactional schema altering functions - they don't do anyhting except for update schemaUpdateTransaction + + /** + * Instruct the schema manager to record a table creation to later execute + * + * @param string $table Name of the table + * @param array $options Create table options (ENGINE, etc.) + * @param array $advanced_options Advanced table creation options + */ + public function transCreateTable($table, $options = null, $advanced_options = null) { + $this->schemaUpdateTransaction[$table] = array( + 'command' => 'create', + 'newFields' => array(), + 'newIndexes' => array(), + 'options' => $options, + 'advancedOptions' => $advanced_options + ); + } + + /** + * Instruct the schema manager to record a table alteration to later execute + * + * @param string $table Name of the table + * @param array $options Create table options (ENGINE, etc.) + * @param array $advanced_options Advanced table creation options + */ + public function transAlterTable($table, $options, $advanced_options) { + $this->transInitTable($table); + $this->schemaUpdateTransaction[$table]['alteredOptions'] = $options; + $this->schemaUpdateTransaction[$table]['advancedOptions'] = $advanced_options; + } + + /** + * Instruct the schema manager to record a field to be later created + * + * @param string $table Name of the table to hold this field + * @param string $field Name of the field to create + * @param string $schema Field specification as a string + */ + public function transCreateField($table, $field, $schema) { + $this->transInitTable($table); + $this->schemaUpdateTransaction[$table]['newFields'][$field] = $schema; + } + + /** + * Instruct the schema manager to record an index to be later created + * + * @param string $table Name of the table to hold this index + * @param string $index Name of the index to create + * @param array $schema Already parsed index specification + */ + public function transCreateIndex($table, $index, $schema) { + $this->transInitTable($table); + $this->schemaUpdateTransaction[$table]['newIndexes'][$index] = $schema; + } + + /** + * Instruct the schema manager to record a field to be later updated + * + * @param string $table Name of the table to hold this field + * @param string $field Name of the field to update + * @param string $schema Field specification as a string + */ + public function transAlterField($table, $field, $schema) { + $this->transInitTable($table); + $this->schemaUpdateTransaction[$table]['alteredFields'][$field] = $schema; + } + + /** + * Instruct the schema manager to record an index to be later updated + * + * @param string $table Name of the table to hold this index + * @param string $index Name of the index to update + * @param array $schema Already parsed index specification + */ + public function transAlterIndex($table, $index, $schema) { + $this->transInitTable($table); + $this->schemaUpdateTransaction[$table]['alteredIndexes'][$index] = $schema; + } + + /** + * Handler for the other transXXX methods - mark the given table as being altered + * if it doesn't already exist + * + * @param string $table Name of the table to initialise + */ + protected function transInitTable($table) { + if (!isset($this->schemaUpdateTransaction[$table])) { + $this->schemaUpdateTransaction[$table] = array( + 'command' => 'alter', + 'newFields' => array(), + 'newIndexes' => array(), + 'alteredFields' => array(), + 'alteredIndexes' => array(), + 'alteredOptions' => '' + ); + } + } + + /** + * Generate the following table in the database, modifying whatever already exists + * as necessary. + * + * @todo Change detection for CREATE TABLE $options other than "Engine" + * + * @param string $table The name of the table + * @param array $fieldSchema A list of the fields to create, in the same form as DataObject::$db + * @param array $indexSchema A list of indexes to create. See {@link requireIndex()} + * The values of the array can be one of: + * - true: Create a single column index on the field named the same as the index. + * - array('fields' => array('A','B','C'), 'type' => 'index/unique/fulltext'): This gives you full + * control over the index. + * @param boolean $hasAutoIncPK A flag indicating that the primary key on this table is an autoincrement type + * @param string $options SQL statement to append to the CREATE TABLE call. + * @param array $extensions List of extensions + */ + public function requireTable($table, $fieldSchema = null, $indexSchema = null, $hasAutoIncPK = true, + $options = array(), $extensions = false + ) { + if (!isset($this->tableList[strtolower($table)])) { + $this->transCreateTable($table, $options, $extensions); + $this->alterationMessage("Table $table: created", "created"); + } else { + if (Config::inst()->get('DBSchemaManager', 'check_and_repair_on_build')) { + $this->checkAndRepairTable($table, $options); + } + + // Check if options changed + $tableOptionsChanged = false; + if (isset($options[get_class($this)]) || true) { + if (isset($options[get_class($this)])) { + if (preg_match('/ENGINE=([^\s]*)/', $options[get_class($this)], $alteredEngineMatches)) { + $alteredEngine = $alteredEngineMatches[1]; + $tableStatus = $this->query(sprintf( + 'SHOW TABLE STATUS LIKE \'%s\'', $table + ))->first(); + $tableOptionsChanged = ($tableStatus['Engine'] != $alteredEngine); + } + } + } + + if ($tableOptionsChanged || ($extensions && $this->database->supportsExtensions($extensions))) { + $this->transAlterTable($table, $options, $extensions); + } + } + + //DB ABSTRACTION: we need to convert this to a db-specific version: + $this->requireField($table, 'ID', $this->IdColumn(false, $hasAutoIncPK)); + + // Create custom fields + if ($fieldSchema) { + foreach ($fieldSchema as $fieldName => $fieldSpec) { + + //Is this an array field? + $arrayValue = ''; + if (strpos($fieldSpec, '[') !== false) { + //If so, remove it and store that info separately + $pos = strpos($fieldSpec, '['); + $arrayValue = substr($fieldSpec, $pos); + $fieldSpec = substr($fieldSpec, 0, $pos); + } + + $fieldObj = Object::create_from_string($fieldSpec, $fieldName); + $fieldObj->arrayValue = $arrayValue; + + $fieldObj->setTable($table); + $fieldObj->requireField(); + } + } + + // Create custom indexes + if ($indexSchema) { + foreach ($indexSchema as $indexName => $indexDetails) { + $this->requireIndex($table, $indexName, $indexDetails); + } + } + } + + /** + * If the given table exists, move it out of the way by renaming it to _obsolete_(tablename). + * @param string $table The table name. + */ + public function dontRequireTable($table) { + if (isset($this->tableList[strtolower($table)])) { + $suffix = ''; + while (isset($this->tableList[strtolower("_obsolete_{$table}$suffix")])) { + $suffix = $suffix + ? ($suffix + 1) + : 2; + } + $this->renameTable($table, "_obsolete_{$table}$suffix"); + $this->alterationMessage("Table $table: renamed to _obsolete_{$table}$suffix", "obsolete"); + } + } + + /** + * Generate the given index in the database, modifying whatever already exists as necessary. + * + * The keys of the array are the names of the index. + * The values of the array can be one of: + * - true: Create a single column index on the field named the same as the index. + * - array('type' => 'index|unique|fulltext', 'value' => 'FieldA, FieldB'): This gives you full + * control over the index. + * + * @param string $table The table name. + * @param string $index The index name. + * @param string|array|boolean $spec The specification of the index in any + * loose format. See requireTable() for more information. + */ + public function requireIndex($table, $index, $spec) { + // Detect if adding to a new table + $newTable = !isset($this->tableList[strtolower($table)]); + + // Force spec into standard array format + $spec = $this->parseIndexSpec($index, $spec); + $specString = $this->convertIndexSpec($spec); + + // Check existing index + if (!$newTable) { + $indexKey = $this->indexKey($table, $index, $spec); + $indexList = $this->indexList($table); + if (isset($indexList[$indexKey])) { + + // $oldSpec should be in standard array format + $oldSpec = $indexList[$indexKey]; + $oldSpecString = $this->convertIndexSpec($oldSpec); + } + } + + // Initiate either generation or modification of index + if ($newTable || !isset($indexList[$indexKey])) { + // New index + $this->transCreateIndex($table, $index, $spec); + $this->alterationMessage("Index $table.$index: created as $specString", "created"); + } else if ($oldSpecString != $specString) { + // Updated index + $this->transAlterIndex($table, $index, $spec); + $this->alterationMessage( + "Index $table.$index: changed to $specString (from $oldSpecString)", + "changed" + ); + } + } + + /** + * Splits a spec string safely, considering quoted columns, whitespace, + * and cleaning brackets + * + * @param string $spec The input index specification string + * @return array List of columns in the spec + */ + protected function explodeColumnString($spec) { + // Remove any leading/trailing brackets and outlying modifiers + // E.g. 'unique (Title, "QuotedColumn");' => 'Title, "QuotedColumn"' + $containedSpec = preg_replace('/(.*\(\s*)|(\s*\).*)/', '', $spec); + + // Split potentially quoted modifiers + // E.g. 'Title, "QuotedColumn"' => array('Title', 'QuotedColumn') + return preg_split('/"?\s*,\s*"?/', trim($containedSpec, '(") ')); + } + + /** + * Builds a properly quoted column list from an array + * + * @param array $columns List of columns to implode + * @return string A properly quoted list of column names + */ + protected function implodeColumnList($columns) { + if(empty($columns)) return ''; + return '"' . implode('","', $columns) . '"'; + } + + /** + * Given an index specification in the form of a string ensure that each + * column name is property quoted, stripping brackets and modifiers. + * This index may also be in the form of a "CREATE INDEX..." sql fragment + * + * @param string $spec The input specification or query. E.g. 'unique (Column1, Column2)' + * @return string The properly quoted column list. E.g. '"Column1", "Column2"' + */ + protected function quoteColumnSpecString($spec) { + $bits = $this->explodeColumnString($spec); + return $this->implodeColumnList($bits); + } + + /** + * Given an index spec determines the index type + * + * @param array|string $spec + * @return string + */ + protected function determineIndexType($spec) { + // check array spec + if(is_array($spec) && isset($spec['type'])) { + return $spec['type']; + } elseif (!is_array($spec) && preg_match('/(?\w+)\s*\(/', $spec, $matchType)) { + return strtolower($matchType['type']); + } else { + return 'index'; + } + } + + /** + * Converts an array or string index spec into a universally useful array + * + * @see convertIndexSpec() for approximate inverse + * @param string|array $spec + * @return array The resulting spec array with the required fields name, type, and value + */ + protected function parseIndexSpec($name, $spec) { + // Support $indexes = array('ColumnName' => true) for quick indexes + if ($spec === true) { + return array( + 'name' => $name, + 'value' => $this->quoteColumnSpecString($name), + 'type' => 'index' + ); + } + + // Do minimal cleanup on any already parsed spec + if(is_array($spec)) { + $spec['value'] = $this->quoteColumnSpecString($spec['value']); + $spec['type'] = empty($spec['type']) ? 'index' : trim($spec['type']); + return $spec; + } + + // Nicely formatted spec! + return array( + 'name' => $name, + 'value' => $this->quoteColumnSpecString($spec), + 'type' => $this->determineIndexType($spec) + ); + } + + /** + * This takes the index spec which has been provided by a class (ie static $indexes = blah blah) + * and turns it into a proper string. + * Some indexes may be arrays, such as fulltext and unique indexes, and this allows database-specific + * arrays to be created. See {@link requireTable()} for details on the index format. + * + * @see http://dev.mysql.com/doc/refman/5.0/en/create-index.html + * @see parseIndexSpec() for approximate inverse + * + * @param string|array $indexSpec + */ + protected function convertIndexSpec($indexSpec) { + // Return already converted spec + if (!is_array($indexSpec)) return $indexSpec; + + // Combine elements into standard string format + return "{$indexSpec['type']} ({$indexSpec['value']})"; + } + + /** + * Returns true if the given table is exists in the current database + * + * @param string $table Name of table to check + * @return boolean Flag indicating existence of table + */ + abstract public function hasTable($tableName); + + /** + * Return true if the table exists and already has a the field specified + * + * @param string $tableName - The table to check + * @param string $fieldName - The field to check + * @return bool - True if the table exists and the field exists on the table + */ + public function hasField($tableName, $fieldName) { + if (!$this->hasTable($tableName)) return false; + $fields = $this->fieldList($tableName); + return array_key_exists($fieldName, $fields); + } + + /** + * Generate the given field on the table, modifying whatever already exists as necessary. + * + * @param string $table The table name. + * @param string $field The field name. + * @param array|string $spec The field specification. If passed in array syntax, the specific database + * driver takes care of the ALTER TABLE syntax. If passed as a string, its assumed to + * be prepared as a direct SQL framgment ready for insertion into ALTER TABLE. In this case you'll + * need to take care of database abstraction in your DBField subclass. + */ + public function requireField($table, $field, $spec) { + //TODO: this is starting to get extremely fragmented. + //There are two different versions of $spec floating around, and their content changes depending + //on how they are structured. This needs to be tidied up. + $fieldValue = null; + $newTable = false; + + // backwards compatibility patch for pre 2.4 requireField() calls + $spec_orig = $spec; + + if (!is_string($spec)) { + $spec['parts']['name'] = $field; + $spec_orig['parts']['name'] = $field; + //Convert the $spec array into a database-specific string + $spec = $this->$spec['type']($spec['parts'], true); + } + + // Collations didn't come in until MySQL 4.1. Anything earlier will throw a syntax error if you try and use + // collations. + // TODO: move this to the MySQLDatabase file, or drop it altogether? + if (!$this->database->supportsCollations()) { + $spec = preg_replace('/ *character set [^ ]+( collate [^ ]+)?( |$)/', '\\2', $spec); + } + + if (!isset($this->tableList[strtolower($table)])) $newTable = true; + + if (is_array($spec)) { + $specValue = $this->$spec_orig['type']($spec_orig['parts']); + } else { + $specValue = $spec; + } + + // We need to get db-specific versions of the ID column: + if ($spec_orig == $this->IdColumn() || $spec_orig == $this->IdColumn(true)) { + $specValue = $this->IdColumn(true); + } + + if (!$newTable) { + $fieldList = $this->fieldList($table); + if (isset($fieldList[$field])) { + if (is_array($fieldList[$field])) { + $fieldValue = $fieldList[$field]['data_type']; + } else { + $fieldValue = $fieldList[$field]; + } + } + } + + // Get the version of the field as we would create it. This is used for comparison purposes to see if the + // existing field is different to what we now want + if (is_array($spec_orig)) { + $spec_orig = $this->$spec_orig['type']($spec_orig['parts']); + } + + if ($newTable || $fieldValue == '') { + $this->transCreateField($table, $field, $spec_orig); + $this->alterationMessage("Field $table.$field: created as $spec_orig", "created"); + } else if ($fieldValue != $specValue) { + // If enums/sets are being modified, then we need to fix existing data in the table. + // Update any records where the enum is set to a legacy value to be set to the default. + // One hard-coded exception is SiteTree - the default for this is Page. + foreach (array('enum', 'set') as $enumtype) { + if (preg_match("/^$enumtype/i", $specValue)) { + $newStr = preg_replace("/(^$enumtype\s*\(')|('$\).*)/i", "", $spec_orig); + $new = preg_split("/'\s*,\s*'/", $newStr); + + $oldStr = preg_replace("/(^$enumtype\s*\(')|('$\).*)/i", "", $fieldValue); + $old = preg_split("/'\s*,\s*'/", $newStr); + + $holder = array(); + foreach ($old as $check) { + if (!in_array($check, $new)) { + $holder[] = $check; + } + } + if (count($holder)) { + $default = explode('default ', $spec_orig); + $default = $default[1]; + if ($default == "'SiteTree'") $default = "'Page'"; + $query = "UPDATE \"$table\" SET $field=$default WHERE $field IN ("; + for ($i = 0; $i + 1 < count($holder); $i++) { + $query .= "'{$holder[$i]}', "; + } + $query .= "'{$holder[$i]}')"; + $this->query($query); + $amount = $this->database->affectedRows(); + $this->alterationMessage("Changed $amount rows to default value of field $field" + . " (Value: $default)"); + } + } + } + $this->transAlterField($table, $field, $spec_orig); + $this->alterationMessage( + "Field $table.$field: changed to $specValue (from {$fieldValue})", + "changed" + ); + } + } + + /** + * If the given field exists, move it out of the way by renaming it to _obsolete_(fieldname). + * + * @param string $table + * @param string $fieldName + */ + public function dontRequireField($table, $fieldName) { + $fieldList = $this->fieldList($table); + if (array_key_exists($fieldName, $fieldList)) { + $suffix = ''; + while (isset($fieldList[strtolower("_obsolete_{$fieldName}$suffix")])) { + $suffix = $suffix + ? ($suffix + 1) + : 2; + } + $this->renameField($table, $fieldName, "_obsolete_{$fieldName}$suffix"); + $this->alterationMessage( + "Field $table.$fieldName: renamed to $table._obsolete_{$fieldName}$suffix", + "obsolete" + ); + } + } + + /** + * Show a message about database alteration + * + * @param string $message to display + * @param string $type one of [created|changed|repaired|obsolete|deleted|error] + */ + public function alterationMessage($message, $type = "") { + if (!$this->supressOutput) { + if (Director::is_cli()) { + switch ($type) { + case "created": + case "changed": + case "repaired": + $sign = "+"; + break; + case "obsolete": + case "deleted": + $sign = '-'; + break; + case "notice": + $sign = '*'; + break; + case "error": + $sign = "!"; + break; + default: + $sign = " "; + } + $message = strip_tags($message); + echo " $sign $message\n"; + } else { + switch ($type) { + case "created": + $color = "green"; + break; + case "obsolete": + $color = "red"; + break; + case "notice": + $color = "orange"; + break; + case "error": + $color = "red"; + break; + case "deleted": + $color = "red"; + break; + case "changed": + $color = "blue"; + break; + case "repaired": + $color = "blue"; + break; + default: + $color = ""; + } + echo "
  • $message
  • "; + } + } + } + + /** + * This returns the data type for the id column which is the primary key for each table + * + * @param boolean $asDbValue + * @param boolean $hasAutoIncPK + * @return string + */ + abstract public function IdColumn($asDbValue = false, $hasAutoIncPK = true); + + /** + * Checks a table's integrity and repairs it if necessary. + * + * @param string $tableName The name of the table. + * @return boolean Return true if the table has integrity after the method is complete. + */ + abstract public function checkAndRepairTable($tableName); + + /** + * Returns the values of the given enum field + * + * @param string $tableName Name of table to check + * @param string $fieldName name of enum field to check + * @return array List of enum values + */ + abstract public function enumValuesForField($tableName, $fieldName); + + + /* + * This is a lookup table for data types. + * For instance, Postgres uses 'INT', while MySQL uses 'UNSIGNED' + * So this is a DB-specific list of equivilents. + * + * @param string $type + * @return string + */ + abstract public function dbDataType($type); + + /** + * Retrieves the list of all databases the user has access to + * + * @return array List of database names + */ + abstract public function databaseList(); + + /** + * Determine if the database with the specified name exists + * + * @param string $name Name of the database to check for + * @return boolean Flag indicating whether this database exists + */ + abstract public function databaseExists($name); + + /** + * Create a database with the specified name + * + * @param string $name Name of the database to create + * @return boolean True if successful + */ + abstract public function createDatabase($name); + + /** + * Drops a database with the specified name + * + * @param string $name Name of the database to drop + */ + abstract public function dropDatabase($name); + + /** + * Alter an index on a table. + * + * @param string $tableName The name of the table. + * @param string $indexName The name of the index. + * @param string $indexSpec The specification of the index, see {@link SS_Database::requireIndex()} + * for more details. + * @todo Find out where this is called from - Is it even used? Aren't indexes always dropped and re-added? + */ + abstract public function alterIndex($tableName, $indexName, $indexSpec); + + /** + * Determines the key that should be used to identify this index + * when retrieved from DBSchemaManager->indexList. + * In some connectors this is the database-visible name, in others the + * usercode-visible name. + * + * @param string $table + * @param string $index + * @param array $spec + * @return string Key for this index + */ + abstract protected function indexKey($table, $index, $spec); + + /** + * Return the list of indexes in a table. + * + * @param string $table The table name. + * @return array[array] List of current indexes in the table, each in standard + * array form. The key for this array should be predictable using the indexKey + * method + */ + abstract public function indexList($table); + + /** + * Returns a list of all tables in the database. + * Keys are table names in lower case, values are table names in case that + * database expects. + * + * @return array + */ + abstract public function tableList(); + + /** + * Create a new table. + * + * @param string $table The name of the table + * @param array $fields A map of field names to field types + * @param array $indexes A map of indexes + * @param array $options An map of additional options. The available keys are as follows: + * - 'MSSQLDatabase'/'MySQLDatabase'/'PostgreSQLDatabase' - database-specific options such as "engine" for MySQL. + * - 'temporary' - If true, then a temporary table will be created + * @param $advancedOptions Advanced creation options + * @return string The table name generated. This may be different from the table name, for example with temporary + * tables. + */ + abstract public function createTable($table, $fields = null, $indexes = null, $options = null, + $advancedOptions = null); + + /** + * Alter a table's schema. + * + * @param string $table The name of the table to alter + * @param array $newFields New fields, a map of field name => field schema + * @param array $newIndexes New indexes, a map of index name => index type + * @param array $alteredFields Updated fields, a map of field name => field schema + * @param array $alteredIndexes Updated indexes, a map of index name => index type + * @param array $alteredOptions + * @param array $advancedOptions + */ + abstract public function alterTable($table, $newFields = null, $newIndexes = null, $alteredFields = null, + $alteredIndexes = null, $alteredOptions = null, $advancedOptions = null); + + /** + * Rename a table. + * + * @param string $oldTableName The old table name. + * @param string $newTableName The new table name. + */ + abstract public function renameTable($oldTableName, $newTableName); + + /** + * Create a new field on a table. + * + * @param string $table Name of the table. + * @param string $field Name of the field to add. + * @param string $spec The field specification, eg 'INTEGER NOT NULL' + */ + abstract public function createField($table, $field, $spec); + + /** + * Change the database column name of the given field. + * + * @param string $tableName The name of the tbale the field is in. + * @param string $oldName The name of the field to change. + * @param string $newName The new name of the field + */ + abstract public function renameField($tableName, $oldName, $newName); + + /** + * Get a list of all the fields for the given table. + * Returns a map of field name => field spec. + * + * @param string $table The table name. + * @return array + */ + abstract public function fieldList($table); + + /** + * + * This allows the cached values for a table's field list to be erased. + * If $tablename is empty, then the whole cache is erased. + * + * @param string $tableName + * + * @return boolean + */ + public function clearCachedFieldlist($tableName = false) { + return true; + } + +} diff --git a/model/connect/Database.php b/model/connect/Database.php new file mode 100644 index 000000000..d12896f8a --- /dev/null +++ b/model/connect/Database.php @@ -0,0 +1,908 @@ +connector; + } + + /** + * Injector injection point for connector dependency + * + * @param DBConnector $connector + */ + public function setConnector(DBConnector $connector) { + $this->connector = $connector; + } + + /** + * Database schema manager object + * + * @var DBSchemaManager + */ + protected $schemaManager = null; + + /** + * Returns the current schema manager + * + * @return DBSchemaManager + */ + public function getSchemaManager() { + return $this->schemaManager; + } + + /** + * Injector injection point for schema manager + * + * @param DBSchemaManager $schemaManager + */ + public function setSchemaManager(DBSchemaManager $schemaManager) { + $this->schemaManager = $schemaManager; + + if ($this->schemaManager) { + $this->schemaManager->setDatabase($this); + } + } + + /** + * Query builder object + * + * @var DBQueryBuilder + */ + protected $queryBuilder = null; + + /** + * Returns the current query builder + * + * @return DBQueryBuilder + */ + public function getQueryBuilder() { + return $this->queryBuilder; + } + + /** + * Injector injection point for schema manager + * + * @param DBQueryBuilder $queryBuilder + */ + public function setQueryBuilder(DBQueryBuilder $queryBuilder) { + $this->queryBuilder = $queryBuilder; + } + + /** + * Execute the given SQL query. + * + * @param string $sql The SQL query to execute + * @param int $errorLevel The level of error reporting to enable for the query + * @return SS_Query + */ + public function query($sql, $errorLevel = E_USER_ERROR) { + return $this->connector->query($sql, $errorLevel); + } + + + /** + * Execute the given SQL parameterised query with the specified arguments + * + * @param string $sql The SQL query to execute. The ? character will denote parameters. + * @param array $parameters An ordered list of arguments. + * @param int $errorLevel The level of error reporting to enable for the query + * @return SS_Query + */ + public function preparedQuery($sql, $parameters, $errorLevel = E_USER_ERROR) { + return $this->connector->preparedQuery($sql, $parameters, $errorLevel); + } + + /** + * Get the autogenerated ID from the previous INSERT query. + * + * @param string $table The name of the table to get the generated ID for + * @return integer the most recently generated ID for the specified table + */ + public function getGeneratedID($table) { + return $this->connector->getGeneratedID($table); + } + + /** + * Determines if we are connected to a server AND have a valid database + * selected. + * + * @return boolean Flag indicating that a valid database is connected + */ + public function isActive() { + return $this->connector->isActive(); + } + + /** + * Returns an escaped string. This string won't be quoted, so would be suitable + * for appending to other quoted strings. + * + * @param mixed $value Value to be prepared for database query + * @return string Prepared string + */ + public function escapeString($value) { + return $this->connector->escapeString($value); + } + + /** + * Wrap a string into DB-specific quotes. + * + * @param mixed $value Value to be prepared for database query + * @return string Prepared string + */ + public function quoteString($value) { + return $this->connector->quoteString($value); + } + + /** + * Escapes an identifier (table / database name). Typically the value + * is simply double quoted. Don't pass in already escaped identifiers in, + * as this will double escape the value! + * + * @param string $value The identifier to escape + * @param string $separator optional identifier splitter + */ + public function escapeIdentifier($value, $separator = '.') { + return $this->connector->escapeIdentifier($value, $separator); + } + + /** + * Escapes unquoted columns keys in an associative array + * + * @param array $fieldValues + * @return array List of field values with the keys as escaped column names + */ + protected function escapeColumnKeys($fieldValues) { + $out = array(); + foreach($fieldValues as $field => $value) { + $out[$this->escapeIdentifier($field)] = $value; + } + return $out; + } + + /** + * Execute a complex manipulation on the database. + * A manipulation is an array of insert / or update sequences. The keys of the array are table names, + * and the values are map containing 'command' and 'fields'. Command should be 'insert' or 'update', + * and fields should be a map of field names to field values, NOT including quotes. + * + * The field values could also be in paramaterised format, such as + * array('MAX(?,?)' => array(42, 69)), allowing the use of raw SQL values such as + * array('NOW()' => array()). + * + * @see SQLWriteExpression::addAssignments for syntax examples + * + * @param array $manipulation + */ + public function manipulate($manipulation) { + if (empty($manipulation)) return; + + foreach ($manipulation as $table => $writeInfo) { + if(empty($writeInfo['fields'])) continue; + // Note: keys of $fieldValues are not escaped + $fieldValues = $writeInfo['fields']; + + // Switch command type + switch ($writeInfo['command']) { + case "update": + + // Build update + $query = new SQLUpdate("\"$table\"", $this->escapeColumnKeys($fieldValues)); + + // Set best condition to use + if(!empty($writeInfo['where'])) { + $query->addWhere($writeInfo['where']); + } elseif(!empty($writeInfo['id'])) { + $query->addWhere(array('"ID"' => $writeInfo['id'])); + } + + // Test to see if this update query shouldn't, in fact, be an insert + if($query->toSelect()->count()) { + $query->execute(); + break; + } + // ...if not, we'll skip on to the insert code + + case "insert": + // Ensure that the ID clause is given if possible + if (!isset($fieldValues['ID']) && isset($writeInfo['id'])) { + $fieldValues['ID'] = $writeInfo['id']; + } + + // Build insert + $query = new SQLInsert("\"$table\"", $this->escapeColumnKeys($fieldValues)); + + $query->execute(); + break; + + default: + user_error("SS_Database::manipulate() Can't recognise command '{$writeInfo['command']}'", + E_USER_ERROR); + } + } + } + + /** + * Enable supression of database messages. + */ + public function quiet() { + $this->schemaManager->quiet(); + } + + /** + * Clear all data out of the database + */ + public function clearAllData() { + $tables = $this->getSchemaManager()->tableList(); + foreach ($tables as $table) { + $this->clearTable($table); + } + } + + /** + * Clear all data in a given table + * + * @param string $table Name of table + */ + public function clearTable($table) { + $this->query("TRUNCATE \"$table\""); + } + + /** + * Generate a WHERE clause for text matching. + * + * @param String $field Quoted field name + * @param String $value Escaped search. Can include percentage wildcards. + * Ignored if $parameterised is true. + * @param boolean $exact Exact matches or wildcard support. + * @param boolean $negate Negate the clause. + * @param boolean $caseSensitive Enforce case sensitivity if TRUE or FALSE. + * Fallback to default collation if set to NULL. + * @param boolean $parameterised Insert the ? placeholder rather than the + * given value. If this is true then $value is ignored. + * @return String SQL + */ + abstract public function comparisonClause($field, $value, $exact = false, $negate = false, $caseSensitive = null, + $parameterised = false); + + /** + * function to return an SQL datetime expression that can be used with the adapter in use + * used for querying a datetime in a certain format + * + * @param string $date to be formated, can be either 'now', literal datetime like '1973-10-14 10:30:00' or + * field name, e.g. '"SiteTree"."Created"' + * @param string $format to be used, supported specifiers: + * %Y = Year (four digits) + * %m = Month (01..12) + * %d = Day (01..31) + * %H = Hour (00..23) + * %i = Minutes (00..59) + * %s = Seconds (00..59) + * %U = unix timestamp, can only be used on it's own + * @return string SQL datetime expression to query for a formatted datetime + */ + abstract public function formattedDatetimeClause($date, $format); + + /** + * function to return an SQL datetime expression that can be used with the adapter in use + * used for querying a datetime addition + * + * @param string $date, can be either 'now', literal datetime like '1973-10-14 10:30:00' or field name, + * e.g. '"SiteTree"."Created"' + * @param string $interval to be added, use the format [sign][integer] [qualifier], e.g. -1 Day, +15 minutes, + * +1 YEAR + * supported qualifiers: + * - years + * - months + * - days + * - hours + * - minutes + * - seconds + * This includes the singular forms as well + * @return string SQL datetime expression to query for a datetime (YYYY-MM-DD hh:mm:ss) which is the result of + * the addition + */ + abstract public function datetimeIntervalClause($date, $interval); + + /** + * function to return an SQL datetime expression that can be used with the adapter in use + * used for querying a datetime substraction + * + * @param string $date1, can be either 'now', literal datetime like '1973-10-14 10:30:00' or field name + * e.g. '"SiteTree"."Created"' + * @param string $date2 to be substracted of $date1, can be either 'now', literal datetime + * like '1973-10-14 10:30:00' or field name, e.g. '"SiteTree"."Created"' + * @return string SQL datetime expression to query for the interval between $date1 and $date2 in seconds which + * is the result of the substraction + */ + abstract public function datetimeDifferenceClause($date1, $date2); + + /** + * Returns true if this database supports collations + * + * @return boolean + */ + abstract public function supportsCollations(); + + /** + * Can the database override timezone as a connection setting, + * or does it use the system timezone exclusively? + * + * @return Boolean + */ + abstract public function supportsTimezoneOverride(); + + /** + * Query for the version of the currently connected database + * @return string Version of this database + */ + public function getVersion() { + return $this->connector->getVersion(); + } + + /** + * Get the database server type (e.g. mysql, postgresql). + * This value is passed to the connector as the 'driver' argument when + * initiating a database connection + * + * @return string + */ + abstract public function getDatabaseServer(); + + /** + * Return the number of rows affected by the previous operation. + * @return int + */ + public function affectedRows() { + return $this->connector->affectedRows(); + } + + /** + * The core search engine, used by this class and its subclasses to do fun stuff. + * Searches both SiteTree and File. + * + * @param array $classesToSearch List of classes to search + * @param string $keywords Keywords as a string. + * @param integer $start Item to start returning results from + * @param integer $pageLength Number of items per page + * @param string $sortBy Sort order expression + * @param string $extraFilter Additional filter + * @param boolean $booleanSearch Flag for boolean search mode + * @param string $alternativeFileFilter + * @param boolean $invertedMatch + * @return PaginatedList Search results + */ + abstract public function searchEngine($classesToSearch, $keywords, $start, $pageLength, $sortBy = "Relevance DESC", + $extraFilter = "", $booleanSearch = false, $alternativeFileFilter = "", $invertedMatch = false); + + /** + * Determines if this database supports transactions + * + * @return boolean Flag indicating support for transactions + */ + abstract public function supportsTransactions(); + + /* + * Determines if the current database connection supports a given list of extensions + * + * @param array $extensions List of extensions to check for support of. The key of this array + * will be an extension name, and the value the configuration for that extension. This + * could be one of partitions, tablespaces, or clustering + * @return boolean Flag indicating support for all of the above + * @todo Write test cases + */ + protected function supportsExtensions($extensions) { + return false; + } + + /** + * Start a prepared transaction + * See http://developer.postgresql.org/pgdocs/postgres/sql-set-transaction.html for details on + * transaction isolation options + * + * @param string|boolean $transactionMode Transaction mode, or false to ignore + * @param string|boolean $sessionCharacteristics Session characteristics, or false to ignore + */ + abstract public function transactionStart($transactionMode = false, $sessionCharacteristics = false); + + /** + * Create a savepoint that you can jump back to if you encounter problems + * + * @param string $savepoint Name of savepoint + */ + abstract public function transactionSavepoint($savepoint); + + /** + * Rollback or revert to a savepoint if your queries encounter problems + * If you encounter a problem at any point during a transaction, you may + * need to rollback that particular query, or return to a savepoint + * + * @param string|boolean $savepoint Name of savepoint, or leave empty to rollback + * to last savepoint + */ + abstract public function transactionRollback($savepoint = false); + + /** + * Commit everything inside this transaction so far + * + * @param boolean $chain + */ + abstract public function transactionEnd($chain = false); + + /** + * Determines if the used database supports application-level locks, + * which is different from table- or row-level locking. + * See {@link getLock()} for details. + * + * @return boolean Flag indicating that locking is available + */ + public function supportsLocks() { + return false; + } + + /** + * Returns if the lock is available. + * See {@link supportsLocks()} to check if locking is generally supported. + * + * @param string $name Name of the lock + * @return boolean + */ + public function canLock($name) { + return false; + } + + /** + * Sets an application-level lock so that no two processes can run at the same time, + * also called a "cooperative advisory lock". + * + * Return FALSE if acquiring the lock fails; otherwise return TRUE, if lock was acquired successfully. + * Lock is automatically released if connection to the database is broken (either normally or abnormally), + * making it less prone to deadlocks than session- or file-based locks. + * Should be accompanied by a {@link releaseLock()} call after the logic requiring the lock has completed. + * Can be called multiple times, in which case locks "stack" (PostgreSQL, SQL Server), + * or auto-releases the previous lock (MySQL). + * + * Note that this might trigger the database to wait for the lock to be released, delaying further execution. + * + * @param string $name Name of lock + * @param integer $timeout Timeout in seconds + * @return boolean + */ + public function getLock($name, $timeout = 5) { + return false; + } + + /** + * Remove an application-level lock file to allow another process to run + * (if the execution aborts (e.g. due to an error) all locks are automatically released). + * + * @param string $name Name of the lock + * @return boolean Flag indicating whether the lock was successfully released + */ + public function releaseLock($name) { + return false; + } + + /** + * Instruct the database to generate a live connection + * + * @param array $parameters An map of parameters, which should include: + * - server: The server, eg, localhost + * - username: The username to log on with + * - password: The password to log on with + * - database: The database to connect to + * - charset: The character set to use. Defaults to utf8 + * - timezone: (optional) The timezone offset. For example: +12:00, "Pacific/Auckland", or "SYSTEM" + * - driver: (optional) Driver name + */ + public function connect($parameters) { + // Ensure that driver is available (required by PDO) + if(empty($parameters['driver'])) { + $parameters['driver'] = $this->getDatabaseServer(); + } + + // Notify connector of parameters + $this->connector->connect($parameters); + + // SS_Database subclass maintains responsibility for selecting database + // once connected in order to correctly handle schema queries about + // existence of database, error handling at the correct level, etc + if (!empty($parameters['database'])) { + $this->selectDatabase($parameters['database'], false, false); + } + } + + /** + * Determine if the database with the specified name exists + * + * @param string $name Name of the database to check for + * @return boolean Flag indicating whether this database exists + */ + public function databaseExists($name) { + return $this->schemaManager->databaseExists($name); + } + + /** + * Retrieves the list of all databases the user has access to + * + * @return array List of database names + */ + public function databaseList() { + return $this->schemaManager->databaseList(); + } + + /** + * Change the connection to the specified database, optionally creating the + * database if it doesn't exist in the current schema. + * + * @param string $name Name of the database + * @param boolean $create Flag indicating whether the database should be created + * if it doesn't exist. If $create is false and the database doesn't exist + * then an error will be raised + * @param int|boolean $errorLevel The level of error reporting to enable for the query, or false if no error + * should be raised + * @return boolean Flag indicating success + */ + public function selectDatabase($name, $create = false, $errorLevel = E_USER_ERROR) { + if (!$this->schemaManager->databaseExists($name)) { + // Check DB creation permisson + if (!$create) { + if ($errorLevel !== false) { + user_error("Attempted to connect to non-existing database \"$name\"", $errorLevel); + } + // Unselect database + $this->connector->unloadDatabase(); + return false; + } + $this->schemaManager->createDatabase($name); + } + return $this->connector->selectDatabase($name); + } + + /** + * Drop the database that this object is currently connected to. + * Use with caution. + */ + public function dropSelectedDatabase() { + $databaseName = $this->connector->getSelectedDatabase(); + if ($databaseName) { + $this->connector->unloadDatabase(); + $this->schemaManager->dropDatabase($databaseName); + } + } + + /** + * Returns the name of the currently selected database + * + * @return string|null Name of the selected database, or null if none selected + */ + public function getSelectedDatabase() { + return $this->connector->getSelectedDatabase(); + } + + /** + * Return SQL expression used to represent the current date/time + * + * @return string Expression for the current date/time + */ + abstract public function now(); + + /** + * Returns the database-specific version of the random() function + * + * @return string Expression for a random value + */ + abstract public function random(); + + /** + * @deprecated since version 3.3 Use DB::get_schema()->dbDataType($type) instead + */ + public function dbDataType($type){ + Deprecation::notice('3.3', 'Use DB::get_schema()->dbDataType($type) instead'); + return $this->getSchemaManager()->dbDataType($type); + } + + /** + * @deprecated since version 3.3 Use selectDatabase('dbname', true) instead + */ + public function createDatabase() { + Deprecation::notice('3.3', 'Use selectDatabase(\'dbname\',true) instead'); + $database = $this->connector->getSelectedDatabase(); + $this->selectDatabase($database, true); + return $this->isActive(); + } + + /** + * @deprecated since version 3.3 SS_Database::getConnect was never implemented and is obsolete + */ + public function getConnect($parameters) { + Deprecation::notice('3.3', 'SS_Database::getConnect was never implemented and is obsolete'); + } + + /** + * @deprecated since version 3.3 Use Convert::raw2sql($string, true) instead + */ + public function prepStringForDB($string) { + Deprecation::notice('3.3', 'Use Convert::raw2sql($string, true) instead'); + return $this->quoteString($string); + } + + /** + * @deprecated since version 3.3 Use dropSelectedDatabase instead + */ + public function dropDatabase() { + Deprecation::notice('3.3', 'Use dropSelectedDatabase instead'); + $this->dropSelectedDatabase(); + } + + /** + * @deprecated since version 3.3 Use databaseList instead + */ + public function allDatabaseNames() { + Deprecation::notice('3.3', 'Use databaseList instead'); + return $this->databaseList(); + } + + /** + * @deprecated since version 3.3 Use DB::create_table instead + */ + public function createTable($table, $fields = null, $indexes = null, $options = null, $advancedOptions = null) { + Deprecation::notice('3.3', 'Use DB::create_table instead'); + return $this->getSchemaManager()->createTable($table, $fields, $indexes, $options, $advancedOptions); + } + + /** + * @deprecated since version 3.3 Use DB::get_schema()->alterTable() instead + */ + public function alterTable($table, $newFields = null, $newIndexes = null, + $alteredFields = null, $alteredIndexes = null, $alteredOptions = null, + $advancedOptions = null + ) { + Deprecation::notice('3.3', 'Use DB::get_schema()->alterTable() instead'); + return $this->getSchemaManager()->alterTable( + $table, $newFields, $newIndexes, $alteredFields, + $alteredIndexes, $alteredOptions, $advancedOptions + ); + } + + /** + * @deprecated since version 3.3 Use DB::get_schema()->renameTable() instead + */ + public function renameTable($oldTableName, $newTableName) { + Deprecation::notice('3.3', 'Use DB::get_schema()->renameTable() instead'); + $this->getSchemaManager()->renameTable($oldTableName, $newTableName); + } + + /** + * @deprecated since version 3.3 Use DB::create_field() instead + */ + public function createField($table, $field, $spec) { + Deprecation::notice('3.3', 'Use DB::create_field() instead'); + $this->getSchemaManager()->createField($table, $field, $spec); + } + + /** + * @deprecated since version 3.3 Use DB::get_schema()->renameField() instead + */ + public function renameField($tableName, $oldName, $newName) { + Deprecation::notice('3.3', 'Use DB::get_schema()->renameField() instead'); + $this->getSchemaManager()->renameField($tableName, $oldName, $newName); + } + + /** + * @deprecated since version 3.3 Use getSelectedDatabase instead + */ + public function currentDatabase() { + Deprecation::notice('3.3', 'Use getSelectedDatabase instead'); + return $this->getSelectedDatabase(); + } + + /** + * @deprecated since version 3.3 Use DB::field_list instead + */ + public function fieldList($table) { + Deprecation::notice('3.3', 'Use DB::field_list instead'); + return $this->getSchemaManager()->fieldList($table); + } + + /** + * @deprecated since version 3.3 Use DB::table_list instead + */ + public function tableList() { + Deprecation::notice('3.3', 'Use DB::table_list instead'); + return $this->getSchemaManager()->tableList(); + } + + /** + * @deprecated since version 3.3 Use DB::get_schema()->hasTable() instead + */ + public function hasTable($tableName) { + Deprecation::notice('3.3', 'Use DB::get_schema()->hasTable() instead'); + return $this->getSchemaManager()->hasTable($tableName); + } + + /** + * @deprecated since version 3.3 Use DB::get_schema()->enumValuesForField() instead + */ + public function enumValuesForField($tableName, $fieldName) { + Deprecation::notice('3.3', 'Use DB::get_schema()->enumValuesForField() instead'); + return $this->getSchemaManager()->enumValuesForField($tableName, $fieldName); + } + + /** + * @deprecated since version 3.3 Use Convert::raw2sql instead + */ + public function addslashes($value) { + Deprecation::notice('3.3', 'Use Convert::raw2sql instead'); + return $this->escapeString($value); + } + + /** + * @deprecated since version 3.2 Use DB::get_schema()->schemaUpdate with a callback instead + */ + public function beginSchemaUpdate() { + Deprecation::notice('3.2', 'Use DB::get_schema()->schemaUpdate with a callback instead'); + // Unable to recover so throw appropriate exception + throw new BadMethodCallException('Use DB::get_schema()->schemaUpdate with a callback instead'); + } + + /** + * @deprecated since version 3.2 Use DB::get_schema()->schemaUpdate with a callback instead + */ + public function endSchemaUpdate() { + Deprecation::notice('3.2', 'Use DB::get_schema()->schemaUpdate with a callback instead'); + // Unable to recover so throw appropriate exception + throw new BadMethodCallException('Use DB::get_schema()->schemaUpdate with a callback instead'); + } + + /** + * @deprecated since version 3.3 Use DB::get_schema()->cancelSchemaUpdate instead + */ + public function cancelSchemaUpdate() { + Deprecation::notice('3.3', 'Use DB::get_schema()->cancelSchemaUpdate instead'); + $this->getSchemaManager()->cancelSchemaUpdate(); + } + + /** + * @deprecated since version 3.3 Use DB::get_schema()->isSchemaUpdating() instead + */ + public function isSchemaUpdating() { + Deprecation::notice('3.3', 'Use DB::get_schema()->isSchemaUpdating() instead'); + return $this->getSchemaManager()->isSchemaUpdating(); + } + + /** + * @deprecated since version 3.3 Use DB::get_schema()->doesSchemaNeedUpdating() instead + */ + public function doesSchemaNeedUpdating() { + Deprecation::notice('3.3', 'Use DB::get_schema()->doesSchemaNeedUpdating() instead'); + return $this->getSchemaManager()->doesSchemaNeedUpdating(); + } + + /** + * @deprecated since version 3.3 Use DB::get_schema()->transCreateTable() instead + */ + public function transCreateTable($table, $options = null, $advanced_options = null) { + Deprecation::notice('3.3', 'Use DB::get_schema()->transCreateTable() instead'); + $this->getSchemaManager()->transCreateTable($table, $options, $advanced_options); + } + + /** + * @deprecated since version 3.3 Use DB::get_schema()->transAlterTable() instead + */ + public function transAlterTable($table, $options, $advanced_options) { + Deprecation::notice('3.3', 'Use DB::get_schema()->transAlterTable() instead'); + $this->getSchemaManager()->transAlterTable($table, $index, $schema); + } + + /** + * @deprecated since version 3.3 Use DB::get_schema()->transCreateField() instead + */ + public function transCreateField($table, $field, $schema) { + Deprecation::notice('3.3', 'Use DB::get_schema()->transCreateField() instead'); + $this->getSchemaManager()->transCreateField($table, $index, $schema); + } + + /** + * @deprecated since version 3.3 Use DB::get_schema()->transCreateIndex() instead + */ + public function transCreateIndex($table, $index, $schema) { + Deprecation::notice('3.3', 'Use DB::get_schema()->transCreateIndex() instead'); + $this->getSchemaManager()->transCreateIndex($table, $index, $schema); + } + + /** + * @deprecated since version 3.3 Use DB::get_schema()->transAlterField() instead + */ + public function transAlterField($table, $field, $schema) { + Deprecation::notice('3.3', 'Use DB::get_schema()->transAlterField() instead'); + $this->getSchemaManager()->transAlterField($table, $index, $schema); + } + + /** + * @deprecated since version 3.3 Use DB::get_schema()->transAlterIndex() instead + */ + public function transAlterIndex($table, $index, $schema) { + Deprecation::notice('3.3', 'Use DB::get_schema()->transAlterIndex() instead'); + $this->getSchemaManager()->transAlterIndex($table, $index, $schema); + } + + /** + * @deprecated since version 3.3 Use DB::require_table() instead + */ + public function requireTable($table, $fieldSchema = null, $indexSchema = null, + $hasAutoIncPK = true, $options = array(), $extensions = false + ) { + Deprecation::notice('3.3', 'Use DB::require_table() instead'); + return $this->getSchemaManager()->requireTable( + $table, $fieldSchema, $indexSchema, $hasAutoIncPK, $options, $extensions + ); + } + + /** + * @deprecated since version 3.3 Use DB::dont_require_table() instead + */ + public function dontRequireTable($table) { + Deprecation::notice('3.3', 'Use DB::dont_require_table() instead'); + $this->getSchemaManager()->dontRequireTable($table); + } + + /** + * @deprecated since version 3.3 Use DB::require_index() instead + */ + public function requireIndex($table, $index, $spec) { + Deprecation::notice('3.3', 'Use DB::require_index() instead'); + $this->getSchemaManager()->requireIndex($table, $index, $spec); + } + + /** + * @deprecated since version 3.3 Use DB::get_schema()->hasField() instead + */ + public function hasField($tableName, $fieldName) { + Deprecation::notice('3.3', 'Use DB::get_schema()->hasField() instead'); + return $this->getSchemaManager()->hasField($tableName, $fieldName); + } + + /** + * @deprecated since version 3.3 Use DB::require_field() instead + */ + public function requireField($table, $field, $spec) { + Deprecation::notice('3.3', 'Use DB::require_field() instead'); + $this->getSchemaManager()->requireField($table, $field, $spec); + } + + /** + * @deprecated since version 3.3 Use DB::dont_require_field() instead + */ + public function dontRequireField($table, $fieldName) { + Deprecation::notice('3.3', 'Use DB::dont_require_field() instead'); + $this->getSchemaManager()->dontRequireField($table, $fieldName); + } + + /** + * @deprecated since version 3.3 Use DB::build_sql() instead + */ + public function sqlQueryToString(SQLExpression $query, &$parameters = array()) { + Deprecation::notice('3.3', 'Use DB::build_sql() instead'); + return $this->getQueryBuilder()->buildSQL($query, $parameters); + } +} diff --git a/model/connect/DatabaseException.php b/model/connect/DatabaseException.php new file mode 100644 index 000000000..2eb30787e --- /dev/null +++ b/model/connect/DatabaseException.php @@ -0,0 +1,57 @@ +sql; + } + + /** + * The parameters given for this query, if any + * + * @return array + */ + public function getParameters() { + return $this->parameters; + } + + /** + * Constructs the database exception + * + * @param string $message The Exception message to throw. + * @param integer $code The Exception code. + * @param Exception $previous The previous exception used for the exception chaining. + * @param string $sql The SQL executed for this query + * @param array $parameters The parameters given for this query, if any + */ + function __construct($message = '', $code = 0, $previous = null, $sql = null, $parameters = array()) { + parent::__construct($message, $code, $previous); + $this->sql = $sql; + $this->parameters = $parameters; + } +} diff --git a/model/connect/MySQLDatabase.php b/model/connect/MySQLDatabase.php new file mode 100644 index 000000000..01b34d42f --- /dev/null +++ b/model/connect/MySQLDatabase.php @@ -0,0 +1,362 @@ +getDatabaseServer(); + } + + // Set charset + if( empty($parameters['charset']) + && ($charset = Config::inst()->get('MySQLDatabase', 'connection_charset')) + ) { + $parameters['charset'] = $charset; + } + + // Notify connector of parameters + $this->connector->connect($parameters); + + // This is important! + $this->setSQLMode('ANSI'); + + if (isset($parameters['timezone'])) { + $this->selectTimezone($parameters['timezone']); + } + + // SS_Database subclass maintains responsibility for selecting database + // once connected in order to correctly handle schema queries about + // existence of database, error handling at the correct level, etc + if (!empty($parameters['database'])) { + $this->selectDatabase($parameters['database'], false, false); + } + } + + /** + * Sets the character set for the MySQL database connection. + * + * The character set connection should be set to 'utf8' for SilverStripe version 2.4.0 and + * later. + * + * However, sites created before version 2.4.0 should leave this unset or data that isn't 7-bit + * safe will be corrupted. As such, the installer comes with this set in mysite/_config.php by + * default in versions 2.4.0 and later. + * + * @deprecated 3.2 Use "MySQLDatabase.connection_charset" config setting instead + */ + public static function set_connection_charset($charset = 'utf8') { + Deprecation::notice('3.1', 'Use "MySQLDatabase.connection_charset" config setting instead'); + Config::inst()->update('MySQLDatabase', 'connection_charset', $charset); + } + + /** + * Sets the SQL mode + * + * @param string $mode Connection mode + */ + public function setSQLMode($mode) { + if (empty($mode)) return; + $this->preparedQuery("SET sql_mode = ?", array($mode)); + } + + /** + * Sets the system timezone for the database connection + * + * @param string $timezone + */ + public function selectTimezone($timezone) { + if (empty($timezone)) return; + $this->preparedQuery("SET SESSION time_zone = ?", array($timezone)); + } + + public function supportsCollations() { + return true; + } + + public function supportsTimezoneOverride() { + return true; + } + + public function getDatabaseServer() { + return "mysql"; + } + + /** + * The core search engine, used by this class and its subclasses to do fun stuff. + * Searches both SiteTree and File. + * + * @param string $keywords Keywords as a string. + */ + public function searchEngine($classesToSearch, $keywords, $start, $pageLength, $sortBy = "Relevance DESC", + $extraFilter = "", $booleanSearch = false, $alternativeFileFilter = "", $invertedMatch = false + ) { + if (!class_exists('SiteTree')) + throw new Exception('MySQLDatabase->searchEngine() requires "SiteTree" class'); + if (!class_exists('File')) + throw new Exception('MySQLDatabase->searchEngine() requires "File" class'); + + $fileFilter = ''; + $keywords = $this->escapeString($keywords); + $htmlEntityKeywords = htmlentities($keywords, ENT_NOQUOTES, 'UTF-8'); + + $extraFilters = array('SiteTree' => '', 'File' => ''); + + if ($booleanSearch) $boolean = "IN BOOLEAN MODE"; + + if ($extraFilter) { + $extraFilters['SiteTree'] = " AND $extraFilter"; + + if ($alternativeFileFilter) + $extraFilters['File'] = " AND $alternativeFileFilter"; + else $extraFilters['File'] = $extraFilters['SiteTree']; + } + + // Always ensure that only pages with ShowInSearch = 1 can be searched + $extraFilters['SiteTree'] .= " AND ShowInSearch <> 0"; + + // File.ShowInSearch was added later, keep the database driver backwards compatible + // by checking for its existence first + $fields = $this->fieldList('File'); + if (array_key_exists('ShowInSearch', $fields)) + $extraFilters['File'] .= " AND ShowInSearch <> 0"; + + $limit = $start . ", " . (int) $pageLength; + + $notMatch = $invertedMatch + ? "NOT " + : ""; + if ($keywords) { + $match['SiteTree'] = " + MATCH (Title, MenuTitle, Content, MetaDescription) AGAINST ('$keywords' $boolean) + + MATCH (Title, MenuTitle, Content, MetaDescription) AGAINST ('$htmlEntityKeywords' $boolean) + "; + $match['File'] = "MATCH (Filename, Title, Content) AGAINST ('$keywords' $boolean) AND ClassName = 'File'"; + + // We make the relevance search by converting a boolean mode search into a normal one + $relevanceKeywords = str_replace(array('*', '+', '-'), '', $keywords); + $htmlEntityRelevanceKeywords = str_replace(array('*', '+', '-'), '', $htmlEntityKeywords); + $relevance['SiteTree'] = "MATCH (Title, MenuTitle, Content, MetaDescription) " + . "AGAINST ('$relevanceKeywords') " + . "+ MATCH (Title, MenuTitle, Content, MetaDescription) AGAINST ('$htmlEntityRelevanceKeywords')"; + $relevance['File'] = "MATCH (Filename, Title, Content) AGAINST ('$relevanceKeywords')"; + } else { + $relevance['SiteTree'] = $relevance['File'] = 1; + $match['SiteTree'] = $match['File'] = "1 = 1"; + } + + // Generate initial DataLists and base table names + $lists = array(); + $baseClasses = array('SiteTree' => '', 'File' => ''); + foreach ($classesToSearch as $class) { + $lists[$class] = DataList::create($class)->where($notMatch . $match[$class] . $extraFilters[$class], ""); + $baseClasses[$class] = '"' . $class . '"'; + } + + // Make column selection lists + $select = array( + 'SiteTree' => array( + "ClassName", "$baseClasses[SiteTree].\"ID\"", "ParentID", + "Title", "MenuTitle", "URLSegment", "Content", + "LastEdited", "Created", + "Filename" => "_utf8''", "Name" => "_utf8''", + "Relevance" => $relevance['SiteTree'], "CanViewType" + ), + 'File' => array( + "ClassName", "$baseClasses[File].\"ID\"", "ParentID" => "_utf8''", + "Title", "MenuTitle" => "_utf8''", "URLSegment" => "_utf8''", "Content", + "LastEdited", "Created", + "Filename", "Name", + "Relevance" => $relevance['File'], "CanViewType" => "NULL" + ), + ); + + // Process and combine queries + $querySQLs = array(); + $queryParameters = array(); + $totalCount = 0; + foreach ($lists as $class => $list) { + $query = $list->dataQuery()->query(); + + // There's no need to do all that joining + $query->setFrom(array(str_replace(array('"', '`'), '', $baseClasses[$class]) => $baseClasses[$class])); + $query->setSelect($select[$class]); + $query->setOrderBy(array()); + + $querySQLs[] = $query->sql($parameters); + $queryParameters = array_merge($queryParameters, $parameters); + + $totalCount += $query->unlimitedRowCount(); + } + $fullQuery = implode(" UNION ", $querySQLs) . " ORDER BY $sortBy LIMIT $limit"; + + // Get records + $records = $this->preparedQuery($fullQuery, $queryParameters); + + $objects = array(); + + foreach ($records as $record) { + $objects[] = new $record['ClassName']($record); + } + + $list = new PaginatedList(new ArrayList($objects)); + $list->setPageStart($start); + $list->setPageLength($pageLength); + $list->setTotalItems($totalCount); + + // The list has already been limited by the query above + $list->setLimitItems(false); + + return $list; + } + + public function supportsTransactions() { + return true; + } + + public function transactionStart($transactionMode = false, $sessionCharacteristics = false) { + // This sets the isolation level for the NEXT transaction, not the current one. + if ($transactionMode) { + $this->query('SET TRANSACTION ' . $transactionMode . ';'); + } + + $this->query('START TRANSACTION;'); + + if ($sessionCharacteristics) { + $this->query('SET SESSION TRANSACTION ' . $sessionCharacteristics . ';'); + } + } + + public function transactionSavepoint($savepoint) { + $this->query("SAVEPOINT $savepoint;"); + } + + public function transactionRollback($savepoint = false) { + if ($savepoint) { + $this->query('ROLLBACK TO ' . $savepoint . ';'); + } else { + $this->query('ROLLBACK'); + } + } + + public function transactionEnd($chain = false) { + $this->query('COMMIT AND ' . ($chain ? '' : 'NO ') . 'CHAIN;'); + } + + public function comparisonClause($field, $value, $exact = false, $negate = false, $caseSensitive = null, + $parameterised = false + ) { + if ($exact && $caseSensitive === null) { + $comp = ($negate) ? '!=' : '='; + } else { + $comp = ($caseSensitive) ? 'LIKE BINARY' : 'LIKE'; + if ($negate) $comp = 'NOT ' . $comp; + } + + if($parameterised) { + return sprintf("%s %s ?", $field, $comp); + } else { + return sprintf("%s %s '%s'", $field, $comp, $value); + } + } + + public function formattedDatetimeClause($date, $format) { + preg_match_all('/%(.)/', $format, $matches); + foreach ($matches[1] as $match) + if (array_search($match, array('Y', 'm', 'd', 'H', 'i', 's', 'U')) === false) { + user_error('formattedDatetimeClause(): unsupported format character %' . $match, E_USER_WARNING); + } + + if (preg_match('/^now$/i', $date)) { + $date = "NOW()"; + } else if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/i', $date)) { + $date = "'$date'"; + } + + if ($format == '%U') return "UNIX_TIMESTAMP($date)"; + + return "DATE_FORMAT($date, '$format')"; + } + + public function datetimeIntervalClause($date, $interval) { + $interval = preg_replace('/(year|month|day|hour|minute|second)s/i', '$1', $interval); + + if (preg_match('/^now$/i', $date)) { + $date = "NOW()"; + } else if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/i', $date)) { + $date = "'$date'"; + } + + return "$date + INTERVAL $interval"; + } + + public function datetimeDifferenceClause($date1, $date2) { + // First date format + if (preg_match('/^now$/i', $date1)) { + $date1 = "NOW()"; + } else if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/i', $date1)) { + $date1 = "'$date1'"; + } + // Second date format + if (preg_match('/^now$/i', $date2)) { + $date2 = "NOW()"; + } else if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/i', $date2)) { + $date2 = "'$date2'"; + } + + return "UNIX_TIMESTAMP($date1) - UNIX_TIMESTAMP($date2)"; + } + + public function supportsLocks() { + return true; + } + + public function canLock($name) { + $id = $this->getLockIdentifier($name); + return (bool) $this->query(sprintf("SELECT IS_FREE_LOCK('%s')", $id))->value(); + } + + public function getLock($name, $timeout = 5) { + $id = $this->getLockIdentifier($name); + + // MySQL auto-releases existing locks on subsequent GET_LOCK() calls, + // in contrast to PostgreSQL and SQL Server who stack the locks. + return (bool) $this->query(sprintf("SELECT GET_LOCK('%s', %d)", $id, $timeout))->value(); + } + + public function releaseLock($name) { + $id = $this->getLockIdentifier($name); + return (bool) $this->query(sprintf("SELECT RELEASE_LOCK('%s')", $id))->value(); + } + + protected function getLockIdentifier($name) { + // Prefix with database name + $dbName = $this->connector->getSelectedDatabase() ; + return $this->escapeString("{$dbName}_{$name}"); + } + + public function now() { + // MySQL uses NOW() to return the current date/time. + return 'NOW()'; + } + + public function random() { + return 'RAND()'; + } +} diff --git a/model/connect/MySQLQuery.php b/model/connect/MySQLQuery.php new file mode 100644 index 000000000..7caec5aef --- /dev/null +++ b/model/connect/MySQLQuery.php @@ -0,0 +1,66 @@ +database = $database; + $this->handle = $handle; + $this->statement = $statement; + } + + public function __destruct() { + if (is_object($this->handle)) $this->handle->free(); + // Don't close statement as these may be re-used across the life of this request + // if (is_object($this->statement)) $this->statement->close(); + } + + public function seek($row) { + if (is_object($this->handle)) return $this->handle->data_seek($row); + } + + public function numRecords() { + if (is_object($this->handle)) return $this->handle->num_rows; + } + + public function nextRecord() { + if (is_object($this->handle) && ($data = $this->handle->fetch_assoc())) { + return $data; + } else { + return false; + } + } + +} diff --git a/model/connect/MySQLSchemaManager.php b/model/connect/MySQLSchemaManager.php new file mode 100644 index 000000000..b96f64267 --- /dev/null +++ b/model/connect/MySQLSchemaManager.php @@ -0,0 +1,569 @@ + $v) + $fieldSchemas .= "\"$k\" $v,\n"; + } + if ($indexes) { + foreach ($indexes as $k => $v) { + $indexSchemas .= $this->getIndexSqlDefinition($k, $v) . ",\n"; + } + } + + // Switch to "CREATE TEMPORARY TABLE" for temporary tables + $temporary = empty($options['temporary']) + ? "" + : "TEMPORARY"; + + $this->query("CREATE $temporary TABLE \"$table\" ( + $fieldSchemas + $indexSchemas + primary key (ID) + ) {$addOptions}"); + + return $table; + } + + public function alterTable($tableName, $newFields = null, $newIndexes = null, $alteredFields = null, + $alteredIndexes = null, $alteredOptions = null, $advancedOptions = null + ) { + if ($this->isView($tableName)) { + $this->alterationMessage( + sprintf("Table %s not changed as it is a view", $tableName), + "changed" + ); + return; + } + $alterList = array(); + + if ($newFields) { + foreach ($newFields as $k => $v) { + $alterList[] .= "ADD \"$k\" $v"; + } + } + if ($newIndexes) { + foreach ($newIndexes as $k => $v) { + $alterList[] .= "ADD " . $this->getIndexSqlDefinition($k, $v); + } + } + if ($alteredFields) { + foreach ($alteredFields as $k => $v) { + $alterList[] .= "CHANGE \"$k\" \"$k\" $v"; + } + } + if ($alteredIndexes) { + foreach ($alteredIndexes as $k => $v) { + $alterList[] .= "DROP INDEX \"$k\""; + $alterList[] .= "ADD " . $this->getIndexSqlDefinition($k, $v); + } + } + + if ($alteredOptions && isset($alteredOptions[get_class($this)])) { + $indexList = $this->indexList($tableName); + $skip = false; + foreach ($indexList as $index) { + if ($index['type'] === 'fulltext') { + $skip = true; + break; + } + } + if ($skip) { + $this->alterationMessage( + sprintf( + "Table %s options not changed to %s due to fulltextsearch index", + $tableName, + $alteredOptions[get_class($this)] + ), + "changed" + ); + } else { + $this->query(sprintf("ALTER TABLE \"%s\" %s", $tableName, $alteredOptions[get_class($this)])); + $this->alterationMessage( + sprintf("Table %s options changed: %s", $tableName, $alteredOptions[get_class($this)]), + "changed" + ); + } + } + + $alterations = implode(",\n", $alterList); + $this->query("ALTER TABLE \"$tableName\" $alterations"); + } + + public function isView($tableName) { + $info = $this->query("SHOW /*!50002 FULL*/ TABLES LIKE '$tableName'")->record(); + return $info && strtoupper($info['Table_type']) == 'VIEW'; + } + + public function renameTable($oldTableName, $newTableName) { + $this->query("ALTER TABLE \"$oldTableName\" RENAME \"$newTableName\""); + } + + public function checkAndRepairTable($tableName) { + // If running PDO and not in emulated mode, check table will fail + if($this->database->getConnector() instanceof PDOConnector && !PDOConnector::is_emulate_prepare()) { + $this->alterationMessage('CHECK TABLE command disabled for PDO in native mode', 'notice'); + return true; + } + + // Perform check + if (!$this->runTableCheckCommand("CHECK TABLE \"$tableName\"")) { + if ($this->runTableCheckCommand("CHECK TABLE \"" . strtolower($tableName) . "\"")) { + $this->alterationMessage( + "Table $tableName: renamed from lowercase", + "repaired" + ); + return $this->renameTable(strtolower($tableName), $tableName); + } + + $this->alterationMessage( + "Table $tableName: repaired", + "repaired" + ); + return $this->runTableCheckCommand("REPAIR TABLE \"$tableName\" USE_FRM"); + } else { + return true; + } + } + + /** + * Helper function used by checkAndRepairTable. + * @param string $sql Query to run. + * @return boolean Returns if the query returns a successful result. + */ + protected function runTableCheckCommand($sql) { + $testResults = $this->query($sql); + foreach ($testResults as $testRecord) { + if (strtolower($testRecord['Msg_text']) != 'ok') { + return false; + } + } + return true; + } + + public function hasTable($table) { + // MySQLi doesn't like parameterised queries for some queries + $sqlTable = $this->database->quoteString($table); + return (bool) ($this->query("SHOW TABLES LIKE $sqlTable")->value()); + } + + public function createField($tableName, $fieldName, $fieldSpec) { + $this->query("ALTER TABLE \"$tableName\" ADD \"$fieldName\" $fieldSpec"); + } + + public function databaseList() { + return $this->query("SHOW DATABASES")->column(); + } + + public function databaseExists($name) { + // MySQLi doesn't like parameterised queries for some queries + $sqlName = $this->database->quoteString($name); + return !!($this->query("SHOW DATABASES LIKE $sqlName")->value()); + } + + public function createDatabase($name) { + $this->query("CREATE DATABASE \"$name\" DEFAULT CHARACTER SET utf8 DEFAULT COLLATE utf8_general_ci"); + } + + public function dropDatabase($name) { + $this->query("DROP DATABASE \"$name\""); + } + + /** + * Change the database type of the given field. + * @param string $tableName The name of the tbale the field is in. + * @param string $fieldName The name of the field to change. + * @param string $fieldSpec The new field specification + */ + public function alterField($tableName, $fieldName, $fieldSpec) { + $this->query("ALTER TABLE \"$tableName\" CHANGE \"$fieldName\" \"$fieldName\" $fieldSpec"); + } + + /** + * Change the database column name of the given field. + * + * @param string $tableName The name of the tbale the field is in. + * @param string $oldName The name of the field to change. + * @param string $newName The new name of the field + */ + public function renameField($tableName, $oldName, $newName) { + $fieldList = $this->fieldList($tableName); + if (array_key_exists($oldName, $fieldList)) { + $this->query("ALTER TABLE \"$tableName\" CHANGE \"$oldName\" \"$newName\" " . $fieldList[$oldName]); + } + } + + protected static $_cache_collation_info = array(); + + public function fieldList($table) { + $fields = $this->query("SHOW FULL FIELDS IN \"$table\""); + foreach ($fields as $field) { + + // ensure that '' is converted to \' in field specification (mostly for the benefit of ENUM values) + $fieldSpec = str_replace('\'\'', '\\\'', $field['Type']); + if (!$field['Null'] || $field['Null'] == 'NO') { + $fieldSpec .= ' not null'; + } + + if ($field['Collation'] && $field['Collation'] != 'NULL') { + // Cache collation info to cut down on database traffic + if (!isset(self::$_cache_collation_info[$field['Collation']])) { + self::$_cache_collation_info[$field['Collation']] + = $this->query("SHOW COLLATION LIKE '{$field['Collation']}'")->record(); + } + $collInfo = self::$_cache_collation_info[$field['Collation']]; + $fieldSpec .= " character set $collInfo[Charset] collate $field[Collation]"; + } + + if ($field['Default'] || $field['Default'] === "0") { + $fieldSpec .= " default " . $this->database->quoteString($field['Default']); + } + if ($field['Extra']) $fieldSpec .= " " . $field['Extra']; + + $fieldList[$field['Field']] = $fieldSpec; + } + return $fieldList; + } + + /** + * Create an index on a table. + * + * @param string $tableName The name of the table. + * @param string $indexName The name of the index. + * @param string $indexSpec The specification of the index, see {@link SS_Database::requireIndex()} for more + * details. + */ + public function createIndex($tableName, $indexName, $indexSpec) { + $this->query("ALTER TABLE \"$tableName\" ADD " . $this->getIndexSqlDefinition($indexName, $indexSpec)); + } + + /** + * Generate SQL suitable for creating this index + * + * @param string $indexName + * @param string|array $indexSpec See {@link requireTable()} for details + * @return string MySQL compatible ALTER TABLE syntax + */ + protected function getIndexSqlDefinition($indexName, $indexSpec) { + $indexSpec = $this->parseIndexSpec($indexName, $indexSpec); + if ($indexSpec['type'] == 'using') { + return "index \"$indexName\" using ({$indexSpec['value']})"; + } else { + return "{$indexSpec['type']} \"$indexName\" ({$indexSpec['value']})"; + } + } + + public function alterIndex($tableName, $indexName, $indexSpec) { + $indexSpec = $this->parseIndexSpec($indexName, $indexSpec); + $this->query("ALTER TABLE \"$tableName\" DROP INDEX \"$indexName\""); + $this->query("ALTER TABLE \"$tableName\" ADD {$indexSpec['type']} \"$indexName\" {$indexSpec['value']}"); + } + + protected function indexKey($table, $index, $spec) { + // MySQL simply uses the same index name as SilverStripe does internally + return $index; + } + + public function indexList($table) { + $indexes = $this->query("SHOW INDEXES IN \"$table\""); + $groupedIndexes = array(); + $indexList = array(); + + foreach ($indexes as $index) { + $groupedIndexes[$index['Key_name']]['fields'][$index['Seq_in_index']] = $index['Column_name']; + + if ($index['Index_type'] == 'FULLTEXT') { + $groupedIndexes[$index['Key_name']]['type'] = 'fulltext'; + } else if (!$index['Non_unique']) { + $groupedIndexes[$index['Key_name']]['type'] = 'unique'; + } else if ($index['Index_type'] == 'HASH') { + $groupedIndexes[$index['Key_name']]['type'] = 'hash'; + } else if ($index['Index_type'] == 'RTREE') { + $groupedIndexes[$index['Key_name']]['type'] = 'rtree'; + } else { + $groupedIndexes[$index['Key_name']]['type'] = 'index'; + } + } + + if ($groupedIndexes) { + foreach ($groupedIndexes as $index => $details) { + ksort($details['fields']); + $indexList[$index] = $this->parseIndexSpec($index, array( + 'name' => $index, + 'value' => $this->implodeColumnList($details['fields']), + 'type' => $details['type'] + )); + } + } + + return $indexList; + } + + public function tableList() { + $tables = array(); + foreach ($this->query("SHOW TABLES") as $record) { + $table = reset($record); + $tables[strtolower($table)] = $table; + } + return $tables; + } + + public function enumValuesForField($tableName, $fieldName) { + // Get the enum of all page types from the SiteTree table + $classnameinfo = DB::query("DESCRIBE \"$tableName\" \"$fieldName\"")->first(); + preg_match_all("/'[^,]+'/", $classnameinfo["Type"], $matches); + + $classes = array(); + foreach ($matches[0] as $value) { + $classes[] = stripslashes(trim($value, "'")); + } + return $classes; + } + + public function dbDataType($type) { + $values = Array( + 'unsigned integer' => 'UNSIGNED' + ); + + if (isset($values[$type])) return $values[$type]; + else return ''; + } + + /** + * Return a boolean type-formatted string + * + * @param array $values Contains a tokenised list of info about this data type + * @return string + */ + public function boolean($values) { + //For reference, this is what typically gets passed to this function: + //$parts=Array('datatype'=>'tinyint', 'precision'=>1, 'sign'=>'unsigned', 'null'=>'not null', + //'default'=>$this->default); + //DB::requireField($this->tableName, $this->name, "tinyint(1) unsigned not null default + //'{$this->defaultVal}'"); + return 'tinyint(1) unsigned not null' . $this->defaultClause($values); + } + + /** + * Return a date type-formatted string + * For MySQL, we simply return the word 'date', no other parameters are necessary + * + * @param array $values Contains a tokenised list of info about this data type + * @return string + */ + public function date($values) { + //For reference, this is what typically gets passed to this function: + //$parts=Array('datatype'=>'date'); + //DB::requireField($this->tableName, $this->name, "date"); + return 'date'; + } + + /** + * Return a decimal type-formatted string + * + * @param array $values Contains a tokenised list of info about this data type + * @return string + */ + public function decimal($values) { + //For reference, this is what typically gets passed to this function: + //$parts=Array('datatype'=>'decimal', 'precision'=>"$this->wholeSize,$this->decimalSize"); + //DB::requireField($this->tableName, $this->name, "decimal($this->wholeSize,$this->decimalSize)"); + // Avoid empty strings being put in the db + if ($values['precision'] == '') { + $precision = 1; + } else { + $precision = $values['precision']; + } + + $defaultValue = ''; + if (isset($values['default']) && is_numeric($values['default'])) { + $decs = strpos($precision, ',') !== false + ? (int) substr($precision, strpos($precision, ',') + 1) + : 0; + $defaultValue = ' default ' . number_format($values['default'], $decs, '.', ''); + } + + return "decimal($precision) not null $defaultValue"; + } + + /** + * Return a enum type-formatted string + * + * @param array $values Contains a tokenised list of info about this data type + * @return string + */ + public function enum($values) { + //For reference, this is what typically gets passed to this function: + //$parts=Array('datatype'=>'enum', 'enums'=>$this->enum, 'character set'=>'utf8', 'collate'=> + // 'utf8_general_ci', 'default'=>$this->default); + //DB::requireField($this->tableName, $this->name, "enum('" . implode("','", $this->enum) . "') character set + // utf8 collate utf8_general_ci default '{$this->default}'"); + $valuesString = implode(",", Convert::raw2sql($values['enums'], true)); + return "enum($valuesString) character set utf8 collate utf8_general_ci" . $this->defaultClause($values); + } + + /** + * Return a set type-formatted string + * + * @param array $values Contains a tokenised list of info about this data type + * @return string + */ + public function set($values) { + //For reference, this is what typically gets passed to this function: + //$parts=Array('datatype'=>'enum', 'enums'=>$this->enum, 'character set'=>'utf8', 'collate'=> + // 'utf8_general_ci', 'default'=>$this->default); + //DB::requireField($this->tableName, $this->name, "enum('" . implode("','", $this->enum) . "') character set + //utf8 collate utf8_general_ci default '{$this->default}'"); + $valuesString = implode(",", Convert::raw2sql($values['enums'], true)); + return "set($valuesString) character set utf8 collate utf8_general_ci" . $this->defaultClause($values); + } + + /** + * Return a float type-formatted string + * For MySQL, we simply return the word 'date', no other parameters are necessary + * + * @param array $values Contains a tokenised list of info about this data type + * @return string + */ + public function float($values) { + //For reference, this is what typically gets passed to this function: + //$parts=Array('datatype'=>'float'); + //DB::requireField($this->tableName, $this->name, "float"); + return "float not null" . $this->defaultClause($values); + } + + /** + * Return a int type-formatted string + * + * @param array $values Contains a tokenised list of info about this data type + * @return string + */ + public function int($values) { + //For reference, this is what typically gets passed to this function: + //$parts=Array('datatype'=>'int', 'precision'=>11, 'null'=>'not null', 'default'=>(int)$this->default); + //DB::requireField($this->tableName, $this->name, "int(11) not null default '{$this->defaultVal}'"); + return "int(11) not null" . $this->defaultClause($values); + } + + /** + * Return a datetime type-formatted string + * For MySQL, we simply return the word 'datetime', no other parameters are necessary + * + * @param array $values Contains a tokenised list of info about this data type + * @return string + */ + public function ss_datetime($values) { + //For reference, this is what typically gets passed to this function: + //$parts=Array('datatype'=>'datetime'); + //DB::requireField($this->tableName, $this->name, $values); + return 'datetime'; + } + + /** + * Return a text type-formatted string + * + * @param array $values Contains a tokenised list of info about this data type + * @return string + */ + public function text($values) { + //For reference, this is what typically gets passed to this function: + //$parts=Array('datatype'=>'mediumtext', 'character set'=>'utf8', 'collate'=>'utf8_general_ci'); + //DB::requireField($this->tableName, $this->name, "mediumtext character set utf8 collate utf8_general_ci"); + return 'mediumtext character set utf8 collate utf8_general_ci' . $this->defaultClause($values); + } + + /** + * Return a time type-formatted string + * For MySQL, we simply return the word 'time', no other parameters are necessary + * + * @param array $values Contains a tokenised list of info about this data type + * @return string + */ + public function time($values) { + //For reference, this is what typically gets passed to this function: + //$parts=Array('datatype'=>'time'); + //DB::requireField($this->tableName, $this->name, "time"); + return 'time'; + } + + /** + * Return a varchar type-formatted string + * + * @param array $values Contains a tokenised list of info about this data type + * @return string + */ + public function varchar($values) { + //For reference, this is what typically gets passed to this function: + //$parts=Array('datatype'=>'varchar', 'precision'=>$this->size, 'character set'=>'utf8', 'collate'=> + //'utf8_general_ci'); + //DB::requireField($this->tableName, $this->name, "varchar($this->size) character set utf8 collate + // utf8_general_ci"); + $default = $this->defaultClause($values); + return "varchar({$values['precision']}) character set utf8 collate utf8_general_ci$default"; + } + + /* + * Return the MySQL-proprietary 'Year' datatype + * + * @param array $values Contains a tokenised list of info about this data type + * @return string + */ + public function year($values) { + return 'year(4)'; + } + + public function IdColumn($asDbValue = false, $hasAutoIncPK = true) { + return 'int(11) not null auto_increment'; + } + + /** + * Parses and escapes the default values for a specification + * + * @param array $values Contains a tokenised list of info about this data type + * @return string Default clause + */ + protected function defaultClause($values) { + if(isset($values['default'])) { + return ' default ' . $this->database->quoteString($values['default']); + } + return ''; + } + +} diff --git a/model/connect/MySQLiConnector.php b/model/connect/MySQLiConnector.php new file mode 100644 index 000000000..bd3a7944d --- /dev/null +++ b/model/connect/MySQLiConnector.php @@ -0,0 +1,317 @@ +preparedQuery + * + * @var mysqli_stmt + */ + protected $lastStatement = null; + + /** + * Store the most recent statement for later use + * + * @param mysqli_stmt $statement + */ + public function setLastStatement($statement) { + $this->lastStatement = $statement; + } + + /** + * List of prepared statements, cached by SQL string + * + * @var array + */ + protected $cachedStatements = array(); + + /** + * Flush all prepared statements + */ + public function flushStatements() { + $this->cachedStatements = array(); + } + + /** + * Retrieve a prepared statement for a given SQL string, or return an already prepared version if + * one exists for the given query + * + * @param string $sql + * @param boolean &$success + * @return mysqli_stmt + */ + public function getOrPrepareStatement($sql, &$success) { + // Check for cached statement + if(!empty($this->cachedStatements[$sql])) { + $success = true; + return $this->cachedStatements[$sql]; + } + + // Prepare statement with arguments + $statement = $this->dbConn->stmt_init(); + if($success = $statement->prepare($sql)) { + // Only cache prepared statement on success + $this->cachedStatements[$sql] = $statement; + } + + return $statement; + } + + public function connect($parameters, $selectDB = false) { + $this->flushStatements(); + + // Normally $selectDB is set to false by the MySQLDatabase controller, as per convention + $selectedDB = ($selectDB && !empty($parameters['database'])) ? $parameters['database'] : null; + + if(!empty($parameters['port'])) { + $this->dbConn = new MySQLi( + $parameters['server'], + $parameters['username'], + $parameters['password'], + $selectedDB, + $parameters['port'] + ); + } else { + $this->dbConn = new MySQLi( + $parameters['server'], + $parameters['username'], + $parameters['password'], + $selectedDB + ); + } + + if ($this->dbConn->connect_error) { + $this->databaseError("Couldn't connect to MySQL database | " . $this->dbConn->connect_error); + } + + // Set charset if given and not null. Can explicitly set to empty string to omit + $charset = isset($parameters['charset']) + ? $parameters['charset'] + : 'utf8'; + if (!empty($charset)) $this->dbConn->set_charset($charset); + } + + public function __destruct() { + if ($this->dbConn) { + mysqli_close($this->dbConn); + $this->dbConn = null; + } + } + + public function escapeString($value) { + return $this->dbConn->real_escape_string($value); + } + + public function quoteString($value) { + $value = $this->escapeString($value); + return "'$value'"; + } + + public function getVersion() { + return $this->dbConn->server_info; + } + + protected function benchmarkQuery($sql, $callback) { + // Clear the last statement + $this->setLastStatement(null); + return parent::benchmarkQuery($sql, $callback); + } + + public function query($sql, $errorLevel = E_USER_ERROR) { + // Check if we should only preview this query + if ($this->previewWrite($sql)) return; + + // Benchmark query + $conn = $this->dbConn; + $handle = $this->benchmarkQuery($sql, function($sql) use($conn) { + return $conn->query($sql); + }); + + if (!$handle || $this->dbConn->error) { + $this->databaseError($this->getLastError(), $errorLevel, $sql); + return null; + } + + if($handle !== true) { + // Some non-select queries return true on success + return new MySQLQuery($this, $handle); + } + } + + /** + * Prepares the list of parameters in preparation for passing to mysqli_stmt_bind_param + * + * @param array $parameters List of parameters + * @param array &$blobs Out parameter for list of blobs to bind separately + * @return array List of parameters appropriate for mysqli_stmt_bind_param function + */ + public function parsePreparedParameters($parameters, &$blobs) { + $types = ''; + $values = array(); + $blobs = array(); + for($index = 0; $index < count($parameters); $index++) { + $value = $parameters[$index]; + $phpType = gettype($value); + + // Allow overriding of parameter type using an associative array + if($phpType === 'array') { + $phpType = $value['type']; + $value = $value['value']; + } + + // Convert php variable type to one that makes mysqli_stmt_bind_param happy + // @see http://www.php.net/manual/en/mysqli-stmt.bind-param.php + switch($phpType) { + case 'boolean': + case 'integer': + $types .= 'i'; + break; + case 'float': // Not actually returnable from gettype + case 'double': + $types .= 'd'; + break; + case 'object': // Allowed if the object or resource has a __toString method + case 'resource': + case 'string': + case 'NULL': // Take care that a where clause should use "where XX is null" not "where XX = null" + $types .= 's'; + break; + case 'blob': + $types .= 'b'; + // Blobs must be sent via send_long_data and set to null here + $blobs[] = array( + 'index' => $index, + 'value' => $value + ); + $value = null; + break; + case 'array': + case 'unknown type': + default: + user_error("Cannot bind parameter \"$value\" as it is an unsupported type ($phpType)", + E_USER_ERROR); + break; + } + $values[] = $value; + } + return array_merge(array($types), $values); + } + + /** + * Binds a list of parameters to a statement + * + * @param mysqli_stmt $statement MySQLi statement + * @param array $parameters List of parameters to pass to bind_param + */ + public function bindParameters(mysqli_stmt $statement, array $parameters) { + // Because mysqli_stmt::bind_param arguments must be passed by reference + // we need to do a bit of hackery + for ($i = 0; $i < count($parameters); $i++) + { + $boundName = "param$i"; + $$boundName = $parameters[$i]; + $boundNames[] = &$$boundName; + } + call_user_func_array( array($statement, 'bind_param'), $boundNames); + } + + public function preparedQuery($sql, $parameters, $errorLevel = E_USER_ERROR) { + // Shortcut to basic query when not given parameters + if(empty($parameters)) return $this->query($sql, $errorLevel); + + // Check if we should only preview this query + if ($this->previewWrite($sql)) return; + + // Type check, identify, and prepare parameters for passing to the statement bind function + $parsedParameters = $this->parsePreparedParameters($parameters, $blobs); + + // Benchmark query + $self = $this; + $lastStatement = $this->benchmarkQuery($sql, function($sql) use($parsedParameters, $blobs, $self) { + + $statement = $self->getOrPrepareStatement($sql, $success); + if(!$success) return $statement; + + $self->bindParameters($statement, $parsedParameters); + + // Bind any blobs given + foreach($blobs as $blob) { + $statement->send_long_data($blob['index'], $blob['value']); + } + + // Safely execute the statement + $statement->execute(); + return $statement; + }); + + // check result + $this->setLastStatement($lastStatement); + if (!$lastStatement || $lastStatement->error) { + $values = $this->parameterValues($parameters); + $this->databaseError($this->getLastError(), $errorLevel, $sql, $values); + return null; + } + + // May not return result for non-select statements + if($result = $lastStatement->get_result()) { + return new MySQLQuery($this, $result, $lastStatement); + } + } + + public function selectDatabase($name) { + if ($this->dbConn->select_db($name)) { + $this->databaseName = $name; + return true; + } else { + return false; + } + } + + public function getSelectedDatabase() { + return $this->databaseName; + } + + public function unloadDatabase() { + $this->databaseName = null; + } + + public function isActive() { + return $this->databaseName && $this->dbConn && empty($this->dbConn->connect_error); + } + + public function affectedRows() { + return $this->dbConn->affected_rows; + } + + public function getGeneratedID($table) { + return $this->dbConn->insert_id; + } + + public function getLastError() { + // Check if a statement was used for the most recent query + if($this->lastStatement && $this->lastStatement->error) { + return $this->lastStatement->error; + } + return $this->dbConn->error; + } + +} diff --git a/model/connect/PDOConnector.php b/model/connect/PDOConnector.php new file mode 100644 index 000000000..117abe02d --- /dev/null +++ b/model/connect/PDOConnector.php @@ -0,0 +1,359 @@ +query + * + * @var PDOStatement + */ + protected $lastStatement = null; + + /** + * List of prepared statements, cached by SQL string + * + * @var array + */ + protected $cachedStatements = array(); + + /** + * Flush all prepared statements + */ + public function flushStatements() { + $this->cachedStatements = array(); + } + + /** + * Retrieve a prepared statement for a given SQL string, or return an already prepared version if + * one exists for the given query + * + * @param string $sql + * @return PDOStatement + */ + public function getOrPrepareStatement($sql) { + if(empty($this->cachedStatements[$sql])) { + $this->cachedStatements[$sql] = $this->pdoConnection->prepare( + $sql, + array(PDO::ATTR_CURSOR => PDO::CURSOR_FWDONLY) + ); + } + return $this->cachedStatements[$sql]; + } + + /** + * Is PDO running in emulated mode + * + * @return boolean + */ + public static function is_emulate_prepare() { + return Config::inst()->get('PDOConnector', 'emulate_prepare'); + } + + public function connect($parameters, $selectDB = false) { + $this->flushStatements(); + + // Build DSN string + // Note that we don't select the database here until explicitly + // requested via selectDatabase + $driver = $parameters['driver'] . ":"; + $dsn = array(); + + // Typically this is false, but some drivers will request this + if($selectDB) { + // Specify complete file path immediately following driver (SQLLite3) + if(!empty($parameters['filepath'])) { + $dsn[] = $parameters['filepath']; + } elseif(!empty($parameters['database'])) { + // Some databases require a selected database at connection (SQLite3, Azure) + if($parameters['driver'] === 'sqlsrv') { + $dsn[] = "Database={$parameters['database']}"; + } else { + $dsn[] = "dbname={$parameters['database']}"; + } + } + } + + // Syntax for sql server is slightly different + if($parameters['driver'] === 'sqlsrv') { + $server = $parameters['server']; + if (!empty($parameters['port'])) { + $server .= ",{$parameters['port']}"; + } + $dsn[] = "Server=$server"; + } else { + if (!empty($parameters['server'])) { + // Use Server instead of host for sqlsrv + $dsn[] = "host={$parameters['server']}"; + } + + if (!empty($parameters['port'])) { + $dsn[] = "port={$parameters['port']}"; + } + } + + // Set charset if given and not null. Can explicitly set to empty string to omit + if($parameters['driver'] !== 'sqlsrv') { + $charset = isset($parameters['charset']) + ? $parameters['charset'] + : 'utf8'; + if (!empty($charset)) $dsn[] = "charset=$charset"; + } + + // Connection commands to be run on every re-connection + $options = array( + PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8', + PDO::ATTR_EMULATE_PREPARES => self::is_emulate_prepare() + ); + + // May throw a PDOException if fails + if(empty($parameters['username']) || empty($parameters['password'])) { + $this->pdoConnection = new PDO($driver.implode(';', $dsn)); + } else { + $this->pdoConnection = new PDO($driver.implode(';', $dsn), $parameters['username'], + $parameters['password'], $options); + } + + // Show selected DB if requested + if($this->pdoConnection && $selectDB && !empty($parameters['database'])) { + $this->databaseName = $parameters['database']; + } + } + + public function getVersion() { + return $this->pdoConnection->getAttribute(PDO::ATTR_SERVER_VERSION); + } + + public function escapeString($value) { + $value = $this->quoteString($value); + + // Since the PDO library quotes the value, we should remove this to maintain + // consistency with MySQLDatabase::escapeString + if (preg_match('/^\'(?.*)\'$/', $value, $matches)) { + $value = $matches['value']; + } + return $value; + } + + public function quoteString($value) { + return $this->pdoConnection->quote($value); + } + + /** + * Executes a query that doesn't return a resultset + * + * @param string $sql + * @param string $sql The SQL query to execute + * @param integer $errorLevel For errors to this query, raise PHP errors + * using this error level. + */ + public function exec($sql, $errorLevel = E_USER_ERROR) { + // Check if we should only preview this query + if ($this->previewWrite($sql)) return; + + // Reset last statement to prevent interference in case of error + $this->lastStatement = null; + + // Benchmark query + $pdo = $this->pdoConnection; + $result = $this->benchmarkQuery($sql, function($sql) use($pdo) { + return $pdo->exec($sql); + }); + + // Check for errors + if ($result === false) { + $this->databaseError($this->getLastError(), $errorLevel, $sql); + return null; + } + + return $result; + } + + public function query($sql, $errorLevel = E_USER_ERROR) { + // Check if we should only preview this query + if ($this->previewWrite($sql)) return; + + // Benchmark query + $pdo = $this->pdoConnection; + $this->lastStatement = $this->benchmarkQuery($sql, function($sql) use($pdo) { + return $pdo->query($sql); + }); + + // Check for errors + if (!$this->lastStatement || $this->hasError($this->lastStatement)) { + $this->databaseError($this->getLastError(), $errorLevel, $sql); + return null; + } + + return new PDOQuery($this->lastStatement); + } + + /** + * Determines the PDO::PARAM_* type for a given PHP type string + * @param string $phpType Type of object in PHP + * @return integer PDO Parameter constant value + */ + public function getPDOParamType($phpType) { + switch($phpType) { + case 'boolean': + return PDO::PARAM_BOOL; + case 'NULL': + return PDO::PARAM_NULL; + case 'integer': + return PDO::PARAM_INT; + case 'object': // Allowed if the object or resource has a __toString method + case 'resource': + case 'float': // Not actually returnable from get_type + case 'double': + case 'string': + return PDO::PARAM_STR; + case 'blob': + return PDO::PARAM_LOB; + case 'array': + case 'unknown type': + default: + user_error("Cannot bind parameter as it is an unsupported type ($phpType)", E_USER_ERROR); + } + } + + /** + * Bind all parameters to a PDOStatement + * + * @param PDOStatement $statement + * @param array $parameters + */ + public function bindParameters(PDOStatement $statement, $parameters) { + // Bind all parameters + for($index = 0; $index < count($parameters); $index++) { + $value = $parameters[$index]; + $phpType = gettype($value); + + // Allow overriding of parameter type using an associative array + if($phpType === 'array') { + $phpType = $value['type']; + $value = $value['value']; + } + + // Check type of parameter + $type = $this->getPDOParamType($phpType); + if($type === PDO::PARAM_STR) $value = strval($value); + + // Bind this value + $statement->bindValue($index+1, $value, $type); + } + } + + public function preparedQuery($sql, $parameters, $errorLevel = E_USER_ERROR) { + // Check if we should only preview this query + if ($this->previewWrite($sql)) return; + + // Benchmark query + $self = $this; + $this->lastStatement = $this->benchmarkQuery($sql, function($sql) use($parameters, $self) { + + // Prepare statement + $statement = $self->getOrPrepareStatement($sql); + if(!$statement) return null; + + // Inject parameters + $self->bindParameters($statement, $parameters); + + // Safely execute the statement + $statement->execute($parameters); + return $statement; + }); + + // Check for errors + if (!$this->lastStatement || $this->hasError($this->lastStatement)) { + $values = $this->parameterValues($parameters); + $this->databaseError($this->getLastError(), $errorLevel, $sql, $values); + return null; + } + + return new PDOQuery($this->lastStatement); + } + + /** + * Determine if a resource has an attached error + * + * @param PDOStatement|PDO $resource the resource to check + * @return boolean Flag indicating true if the resource has an error + */ + protected function hasError($resource) { + // No error if no resource + if(empty($resource)) return false; + + // If the error code is empty the statement / connection has not been run yet + $code = $resource->errorCode(); + if(empty($code)) return false; + + // Skip 'ok' and undefined 'warning' types. + // @see http://docstore.mik.ua/orelly/java-ent/jenut/ch08_06.htm + return $code !== '00000' && $code !== '01000'; + } + + public function getLastError() { + if ($this->hasError($this->lastStatement)) { + $error = $this->lastStatement->errorInfo(); + } elseif($this->hasError($this->pdoConnection)) { + $error = $this->pdoConnection->errorInfo(); + } + if (isset($error)) { + return sprintf("%s-%s: %s", $error[0], $error[1], $error[2]); + } + } + + public function getGeneratedID($table) { + return $this->pdoConnection->lastInsertId(); + } + + public function affectedRows() { + if (empty($this->lastStatement)) return 0; + return $this->lastStatement->rowCount(); + } + + public function selectDatabase($name) { + $this->exec("USE \"{$name}\""); + $this->databaseName = $name; + return true; + } + + public function getSelectedDatabase() { + return $this->databaseName; + } + + public function unloadDatabase() { + $this->databaseName = null; + } + + public function isActive() { + return $this->databaseName && $this->pdoConnection; + } + +} diff --git a/model/connect/PDOQuery.php b/model/connect/PDOQuery.php new file mode 100644 index 000000000..de5f65af1 --- /dev/null +++ b/model/connect/PDOQuery.php @@ -0,0 +1,53 @@ +statement = $statement; + // Since no more than one PDOStatement for any one connection can be safely + // traversed, each statement simply requests all rows at once for safety. + // This could be re-engineered to call fetchAll on an as-needed basis + $this->results = $statement->fetchAll(PDO::FETCH_ASSOC); + $statement->closeCursor(); + } + + public function __destruct() { + $this->statement->closeCursor(); + } + + public function seek($row) { + $this->rowNum = $row - 1; + return $this->nextRecord(); + } + + public function numRecords() { + return count($this->results); + } + + public function nextRecord() { + $index = $this->rowNum + 1; + + if (isset($this->results[$index])) { + return $this->results[$index]; + } else { + return false; + } + } + +} diff --git a/model/Query.php b/model/connect/Query.php similarity index 71% rename from model/Query.php rename to model/connect/Query.php index 7765775e7..c67b9749c 100644 --- a/model/Query.php +++ b/model/connect/Query.php @@ -2,57 +2,51 @@ /** * Abstract query-result class. + * Once again, this should be subclassed by an actual database implementation. It will only + * ever be constructed by a subclass of SS_Database. The result of a database query - an iteratable object + * that's returned by DB::SS_Query * - * Once again, this should be subclassed by an actual database implementation - * such as {@link MySQLQuery}. - * - * It will only ever be constructed by a subclass of {@link SS_Database} and - * contain the result of a database query as an iteratable object. - * - * Primarily, the SS_Query class takes care of the iterator plumbing, letting - * the subclasses focusing on providing the specific data-access methods that - * are required: {@link nextRecord()}, {@link numRecords()} and {@link seek()} - * + * Primarily, the SS_Query class takes care of the iterator plumbing, letting the subclasses focusing + * on providing the specific data-access methods that are required: {@link nextRecord()}, {@link numRecords()} + * and {@link seek()} * @package framework * @subpackage model */ abstract class SS_Query implements Iterator { - + /** * The current record in the interator. - * + * * @var array */ - private $currentRecord = null; - - /** - * The number of the current row in the interator. - * - * @var int - */ - private $rowNum = -1; - - /** - * Flag to keep track of whether iteration has begun, to prevent unnecessary - * seeks. - * - * @var boolean - */ - private $queryHasBegun = false; + protected $currentRecord = null; /** - * Return an array containing all the values from a specific column. If no - * column is set, then the first will be returned. + * The number of the current row in the interator. + * + * @var int + */ + protected $rowNum = -1; + + /** + * Flag to keep track of whether iteration has begun, to prevent unnecessary seeks + * + * @var bool + */ + protected $queryHasBegun = false; + + /** + * Return an array containing all the values from a specific column. If no column is set, then the first will be + * returned * * @param string $column - * * @return array */ public function column($column = null) { $result = array(); - - while($record = $this->next()) { - if($column) { + + while ($record = $this->next()) { + if ($column) { $result[] = $record[$column]; } else { $result[] = $record[key($record)]; @@ -63,42 +57,38 @@ abstract class SS_Query implements Iterator { } /** - * Return an array containing all values in the leftmost column, where the - * keys are the same as the values. - * + * Return an array containing all values in the leftmost column, where the keys are the + * same as the values. + * * @return array */ public function keyedColumn() { $column = array(); - - foreach($this as $record) { + foreach ($this as $record) { $val = $record[key($record)]; $column[$val] = $val; } - return $column; } /** * Return a map from the first column to the second column. - * + * * @return array */ public function map() { $column = array(); - - foreach($this as $record) { + foreach ($this as $record) { $key = reset($record); $val = next($record); $column[$key] = $val; } - return $column; } /** * Returns the next record in the iterator. - * + * * @return array */ public function record() { @@ -107,72 +97,66 @@ abstract class SS_Query implements Iterator { /** * Returns the first column of the first record. - * + * * @return string */ public function value() { $record = $this->next(); - - if($record) { - return $record[key($record)]; - } + if ($record) return $record[key($record)]; } /** - * Return an HTML table containing the full result-set. + * Return an HTML table containing the full result-set + * + * @return string */ public function table() { $first = true; $result = "\n"; - - foreach($this as $record) { - if($first) { + + foreach ($this as $record) { + if ($first) { $result .= ""; - foreach($record as $k => $v) { + foreach ($record as $k => $v) { $result .= " "; } $result .= " \n"; } $result .= ""; - foreach($record as $k => $v) { + foreach ($record as $k => $v) { $result .= " "; } $result .= " \n"; - + $first = false; } $result .= "
    " . Convert::raw2xml($k) . "
    " . Convert::raw2xml($v) . "
    \n"; - - if($first) return "No records found"; + + if ($first) return "No records found"; return $result; } - + /** - * Iterator function implementation. Rewind the iterator to the first item - * and return it. - * - * Makes use of {@link seek()} and {@link numRecords()}, takes care of the - * plumbing. - * + * Iterator function implementation. Rewind the iterator to the first item and return it. + * Makes use of {@link seek()} and {@link numRecords()}, takes care of the plumbing. + * * @return array */ public function rewind() { - if($this->queryHasBegun && $this->numRecords() > 0) { + if ($this->queryHasBegun && $this->numRecords() > 0) { $this->queryHasBegun = false; - return $this->seek(0); } } /** - * Iterator function implementation. Return the current item of the - * iterator. - * + * Iterator function implementation. Return the current item of the iterator. + * * @return array */ public function current() { - if(!$this->currentRecord) { + if (!$this->currentRecord) { return $this->next(); } else { return $this->currentRecord; @@ -181,18 +165,17 @@ abstract class SS_Query implements Iterator { /** * Iterator function implementation. Return the first item of this iterator. + * * @return array */ public function first() { $this->rewind(); - return $this->current(); } /** - * Iterator function implementation. Return the row number of the current - * item. - * + * Iterator function implementation. Return the row number of the current item. + * * @return int */ public function key() { @@ -201,9 +184,8 @@ abstract class SS_Query implements Iterator { /** * Iterator function implementation. Return the next record in the iterator. - * * Makes use of {@link nextRecord()}, takes care of the plumbing. - * + * * @return array */ public function next() { @@ -214,38 +196,33 @@ abstract class SS_Query implements Iterator { } /** - * Iterator function implementation. Check if the iterator is pointing to a - * valid item. - * - * @return boolean + * Iterator function implementation. Check if the iterator is pointing to a valid item. + * + * @return bool */ public function valid() { - if(!$this->queryHasBegun) { - $this->next(); - } - + if (!$this->queryHasBegun) $this->next(); return $this->currentRecord !== false; } /** * Return the next record in the query result. - * + * * @return array */ abstract public function nextRecord(); /** * Return the total number of items in the query result. - * + * * @return int */ abstract public function numRecords(); /** * Go to a specific row number in the query result and return the record. - * - * @param int $rowNum Tow number to go to. - * + * + * @param int $rowNum Row number to go to. * @return array */ abstract public function seek($rowNum); diff --git a/model/fieldtypes/Boolean.php b/model/fieldtypes/Boolean.php index 0431813bb..4d58031d7 100644 --- a/model/fieldtypes/Boolean.php +++ b/model/fieldtypes/Boolean.php @@ -23,7 +23,7 @@ class Boolean extends DBField { 'arrayValue'=>$this->arrayValue ); $values=Array('type'=>'boolean', 'parts'=>$parts); - DB::requireField($this->tableName, $this->name, $values); + DB::require_field($this->tableName, $this->name, $values); } public function Nice() { @@ -62,26 +62,27 @@ class Boolean extends DBField { return $field; } - /** - * Return an encoding of the given value suitable for inclusion in a SQL statement. - * If necessary, this should include quotes. - */ - public function prepValueForDB($value) { - if(strpos($value, '[')!==false) - return Convert::raw2sql($value); - else { - if($value && strtolower($value) != 'f') { - return "'1'"; - } else { - return "'0'"; - } - } + public function nullValue() { + return 0; } - public function nullValue() { - return "'0'"; + public function prepValueForDB($value) { + if(is_bool($value)) { + return $value ? 1 : 0; + } else if(empty($value)) { + return 0; + } else if(is_string($value)){ + switch(strtolower($value)) { + case 'false': + case 'f': + return 0; + case 'true': + case 't': + return 1; + } + } + return $value ? 1 : 0; } - } diff --git a/model/fieldtypes/CompositeDBField.php b/model/fieldtypes/CompositeDBField.php index 979bba6b9..ae49f9820 100644 --- a/model/fieldtypes/CompositeDBField.php +++ b/model/fieldtypes/CompositeDBField.php @@ -150,10 +150,10 @@ interface CompositeDBField { /** * Add all columns which are defined through {@link requireField()} * and {@link $composite_db}, or any additional SQL that is required - * to get to these columns. Will mostly just write to the {@link SQLQuery->select} + * to get to these columns. Will mostly just write to the {@link SQLSelect->select} * array. * - * @param SQLQuery $query + * @param SQLSelect $query */ public function addToQuery(&$query); diff --git a/model/fieldtypes/DBField.php b/model/fieldtypes/DBField.php index 036745960..b0a9826dd 100644 --- a/model/fieldtypes/DBField.php +++ b/model/fieldtypes/DBField.php @@ -69,6 +69,12 @@ abstract class DBField extends ViewableData { /** * Create a DBField object that's not bound to any particular field. * Useful for accessing the classes behaviour for other parts of your code. + * + * @param string $className class of field to construct + * @param mixed $value value of field + * @param string $name Name of field + * @param mixed $object Additional parameter to pass to field constructor + * @return DBField */ public static function create_field($className, $value, $name = null, $object = null) { $dbField = Object::create($className, $name, $object); @@ -130,18 +136,24 @@ abstract class DBField extends ViewableData { } /** - * Return an encoding of the given value suitable - * for inclusion in a SQL statement. If necessary, - * this should include quotes. + * Return the transformed value ready to be sent to the database. This value + * will be escaped automatically by the prepared query processor, so it + * should not be escaped or quoted at all. + * + * The field values could also be in paramaterised format, such as + * array('MAX(?,?)' => array(42, 69)), allowing the use of raw SQL values such as + * array('NOW()' => array()). + * + * @see SQLWriteExpression::addAssignments for syntax examples * * @param $value mixed The value to check - * @return string The encoded value + * @return mixed The raw value, or escaped parameterised details */ public function prepValueForDB($value) { if($value === null || $value === "" || $value === false) { - return "null"; + return null; } else { - return DB::getConn()->prepStringForDB($value); + return $value; } } @@ -223,9 +235,11 @@ abstract class DBField extends ViewableData { /** * Returns the value to be set in the database to blank this field. * Usually it's a choice between null, 0, and '' + * + * @return mixed */ public function nullValue() { - return "null"; + return null; } /** diff --git a/model/fieldtypes/Date.php b/model/fieldtypes/Date.php index c7c630b31..d93646335 100644 --- a/model/fieldtypes/Date.php +++ b/model/fieldtypes/Date.php @@ -325,7 +325,7 @@ class Date extends DBField { public function requireField() { $parts=Array('datatype'=>'date', 'arrayValue'=>$this->arrayValue); $values=Array('type'=>'date', 'parts'=>$parts); - DB::requireField($this->tableName, $this->name, $values); + DB::require_field($this->tableName, $this->name, $values); } /** diff --git a/model/fieldtypes/Datetime.php b/model/fieldtypes/Datetime.php index 933a09297..6c345a72b 100644 --- a/model/fieldtypes/Datetime.php +++ b/model/fieldtypes/Datetime.php @@ -119,7 +119,7 @@ class SS_Datetime extends Date implements TemplateGlobalProvider { public function requireField() { $parts=Array('datatype'=>'datetime', 'arrayValue'=>$this->arrayValue); $values=Array('type'=>'SS_Datetime', 'parts'=>$parts); - DB::requireField($this->tableName, $this->name, $values); + DB::require_field($this->tableName, $this->name, $values); } /** diff --git a/model/fieldtypes/Decimal.php b/model/fieldtypes/Decimal.php index f2a677a88..3ea644787 100644 --- a/model/fieldtypes/Decimal.php +++ b/model/fieldtypes/Decimal.php @@ -53,7 +53,7 @@ class Decimal extends DBField { 'parts' => $parts ); - DB::requireField($this->tableName, $this->name, $values); + DB::require_field($this->tableName, $this->name, $values); } /** @@ -83,28 +83,16 @@ class Decimal extends DBField { * @return float */ public function nullValue() { - return "0.00"; + return 0; } - /** - * Return an encoding of the given value suitable for inclusion in a SQL - * statement. If necessary, this should include quotes. - * - * @param float $value - * - * @return mixed - */ public function prepValueForDB($value) { if($value === true) { return 1; - } if(!$value || !is_numeric($value)) { - if(strpos($value, '[') === false) { - return '0'; - } else { - return Convert::raw2sql($value); - } - } else { - return Convert::raw2sql($value); + } elseif(empty($value) || !is_numeric($value)) { + return 0; } + + return $value; } } diff --git a/model/fieldtypes/Double.php b/model/fieldtypes/Double.php index e528f1fc6..d86407819 100644 --- a/model/fieldtypes/Double.php +++ b/model/fieldtypes/Double.php @@ -9,10 +9,10 @@ class Double extends Float { public function requireField() { // HACK: MSSQL does not support double so we're using float instead // @todo This should go into MSSQLDatabase ideally somehow - if(DB::getConn() instanceof MySQLDatabase) { - DB::requireField($this->tableName, $this->name, "double"); + if(DB::get_conn() instanceof MySQLDatabase) { + DB::require_field($this->tableName, $this->name, "double"); } else { - DB::requireField($this->tableName, $this->name, "float"); + DB::require_field($this->tableName, $this->name, "float"); } } } diff --git a/model/fieldtypes/Enum.php b/model/fieldtypes/Enum.php index 9f05bdd03..ce43e12ab 100644 --- a/model/fieldtypes/Enum.php +++ b/model/fieldtypes/Enum.php @@ -67,10 +67,10 @@ class Enum extends StringField { public function requireField() { $parts = array( 'datatype' => 'enum', - 'enums' => Convert::raw2sql($this->enum), + 'enums' => $this->enum, 'character set' => 'utf8', 'collate' => 'utf8_general_ci', - 'default' => Convert::raw2sql($this->default), + 'default' => $this->default, 'table' => $this->tableName, 'arrayValue' => $this->arrayValue ); @@ -80,7 +80,7 @@ class Enum extends StringField { 'parts' => $parts ); - DB::requireField($this->tableName, $this->name, $values); + DB::require_field($this->tableName, $this->name, $values); } /** diff --git a/model/fieldtypes/Float.php b/model/fieldtypes/Float.php index 1c0ddafd7..88d69df2f 100644 --- a/model/fieldtypes/Float.php +++ b/model/fieldtypes/Float.php @@ -14,14 +14,14 @@ class Float extends DBField { } public function requireField() { - $parts=Array( + $parts = Array( 'datatype'=>'float', 'null'=>'not null', 'default'=>$this->defaultVal, - 'arrayValue'=>$this->arrayValue); - - $values=Array('type'=>'float', 'parts'=>$parts); - DB::requireField($this->tableName, $this->name, $values); + 'arrayValue'=>$this->arrayValue + ); + $values = Array('type'=>'float', 'parts'=>$parts); + DB::require_field($this->tableName, $this->name, $values); } /** @@ -45,31 +45,18 @@ class Float extends DBField { return new NumericField($this->name, $title); } - /** - * Returns the value to be set in the database to blank this field. - * Usually it's a choice between null, 0, and '' - */ public function nullValue() { return 0; } - /** - * Return an encoding of the given value suitable for inclusion in a SQL statement. - * If necessary, this should include quotes. - */ public function prepValueForDB($value) { if($value === true) { return 1; + } elseif(empty($value) || !is_numeric($value)) { + return 0; } - if(!$value || !is_numeric($value)) { - if(strpos($value, '[') === false) { - return '0'; - } else { - return Convert::raw2sql($value); - } - } else { - return Convert::raw2sql($value); - } + + return $value; } } diff --git a/model/fieldtypes/Int.php b/model/fieldtypes/Int.php index 6abd2c099..b1af62ce2 100644 --- a/model/fieldtypes/Int.php +++ b/model/fieldtypes/Int.php @@ -20,10 +20,6 @@ class Int extends DBField { return number_format($this->value); } - public function nullValue() { - return "0"; - } - public function requireField() { $parts=Array( 'datatype'=>'int', @@ -33,7 +29,7 @@ class Int extends DBField { 'arrayValue'=>$this->arrayValue); $values=Array('type'=>'int', 'parts'=>$parts); - DB::requireField($this->tableName, $this->name, $values); + DB::require_field($this->tableName, $this->name, $values); } public function Times() { @@ -51,22 +47,19 @@ class Int extends DBField { public function scaffoldFormField($title = null, $params = null) { return new NumericField($this->name, $title); } + + public function nullValue() { + return 0; + } - /** - * Return an encoding of the given value suitable for inclusion in a SQL statement. - * If necessary, this should include quotes. - */ public function prepValueForDB($value) { if($value === true) { return 1; - } if(!$value || !is_numeric($value)) { - if(strpos($value, '[')===false) - return '0'; - else - return Convert::raw2sql($value); - } else { - return Convert::raw2sql($value); + } elseif(empty($value) || !is_numeric($value)) { + return 0; } + + return $value; } } diff --git a/model/fieldtypes/Money.php b/model/fieldtypes/Money.php index 3fa0dabb9..beae306ad 100644 --- a/model/fieldtypes/Money.php +++ b/model/fieldtypes/Money.php @@ -76,7 +76,7 @@ class Money extends DBField implements CompositeDBField { public function requireField() { $fields = $this->compositeDatabaseFields(); if($fields) foreach($fields as $name => $type){ - DB::requireField($this->tableName, $this->name.$name, $type); + DB::require_field($this->tableName, $this->name.$name, $type); } } diff --git a/model/fieldtypes/MultiEnum.php b/model/fieldtypes/MultiEnum.php index 7e7cf17b4..15a857d44 100644 --- a/model/fieldtypes/MultiEnum.php +++ b/model/fieldtypes/MultiEnum.php @@ -38,13 +38,13 @@ class MultiEnum extends Enum { 'enums'=>$this->enum, 'character set'=>'utf8', 'collate'=> 'utf8_general_ci', - 'default'=>Convert::raw2sql($this->default), + 'default'=> $this->default, 'table'=>$this->tableName, 'arrayValue'=>$this->arrayValue ) ); - DB::requireField($this->tableName, $this->name, $values); + DB::require_field($this->tableName, $this->name, $values); } diff --git a/model/fieldtypes/PolymorphicForeignKey.php b/model/fieldtypes/PolymorphicForeignKey.php index d4c69e099..f365f1e9e 100644 --- a/model/fieldtypes/PolymorphicForeignKey.php +++ b/model/fieldtypes/PolymorphicForeignKey.php @@ -167,9 +167,9 @@ class PolymorphicForeignKey extends ForeignKey implements CompositeDBField { $classNames = ClassInfo::subclassesFor('DataObject'); unset($classNames['DataObject']); - $db = DB::getConn(); - if($db->hasField($this->tableName, "{$this->name}Class")) { - $existing = $db->query("SELECT DISTINCT \"{$this->name}Class\" FROM \"{$this->tableName}\"")->column(); + $schema = DB::get_schema(); + if($schema->hasField($this->tableName, "{$this->name}Class")) { + $existing = DB::query("SELECT DISTINCT \"{$this->name}Class\" FROM \"{$this->tableName}\"")->column(); $classNames = array_unique(array_merge($classNames, $existing)); } diff --git a/model/fieldtypes/StringField.php b/model/fieldtypes/StringField.php index 6bc92a73b..494e00091 100644 --- a/model/fieldtypes/StringField.php +++ b/model/fieldtypes/StringField.php @@ -91,7 +91,7 @@ abstract class StringField extends DBField { */ public function prepValueForDB($value) { if(!$this->nullifyEmpty && $value === '') { - return DB::getConn()->prepStringForDB($value); + return $value; } else { return parent::prepValueForDB($value); } diff --git a/model/fieldtypes/Text.php b/model/fieldtypes/Text.php index 63d057381..c63559f7b 100644 --- a/model/fieldtypes/Text.php +++ b/model/fieldtypes/Text.php @@ -50,7 +50,7 @@ class Text extends StringField { 'parts' => $parts ); - DB::requireField($this->tableName, $this->name, $values, $this->default); + DB::require_field($this->tableName, $this->name, $values, $this->default); } /** diff --git a/model/fieldtypes/Time.php b/model/fieldtypes/Time.php index 39a335571..1ca8d074b 100644 --- a/model/fieldtypes/Time.php +++ b/model/fieldtypes/Time.php @@ -67,7 +67,7 @@ class Time extends DBField { public function requireField() { $parts=Array('datatype'=>'time', 'arrayValue'=>$this->arrayValue); $values=Array('type'=>'time', 'parts'=>$parts); - DB::requireField($this->tableName, $this->name, $values); + DB::require_field($this->tableName, $this->name, $values); } public function scaffoldFormField($title = null, $params = null) { diff --git a/model/fieldtypes/Varchar.php b/model/fieldtypes/Varchar.php index e6dc6c983..c48e21bb4 100644 --- a/model/fieldtypes/Varchar.php +++ b/model/fieldtypes/Varchar.php @@ -63,7 +63,7 @@ class Varchar extends StringField { 'parts' => $parts ); - DB::requireField($this->tableName, $this->name, $values); + DB::require_field($this->tableName, $this->name, $values); } /** diff --git a/model/fieldtypes/Year.php b/model/fieldtypes/Year.php index 5d31b5235..a52ac56cc 100755 --- a/model/fieldtypes/Year.php +++ b/model/fieldtypes/Year.php @@ -15,7 +15,7 @@ class Year extends DBField { public function requireField() { $parts=Array('datatype'=>'year', 'precision'=>4, 'arrayValue'=>$this->arrayValue); $values=Array('type'=>'year', 'parts'=>$parts); - DB::requireField($this->tableName, $this->name, $values); + DB::require_field($this->tableName, $this->name, $values); } public function scaffoldFormField($title = null, $params = null) { diff --git a/model/queries/SQLAssignmentRow.php b/model/queries/SQLAssignmentRow.php new file mode 100644 index 000000000..ad7a8e12f --- /dev/null +++ b/model/queries/SQLAssignmentRow.php @@ -0,0 +1,220 @@ + array($parameters)). + * The field name is stored as the key + * + * E.g. + * + * $assignments['ID'] = array('?' => array(1)); + * + * This allows for complex, parameterised updates, or explict field values set + * without any prameters + * + * @var array + */ + protected $assignments = array(); + + /** + * Instantiate a new SQLAssignmentRow object with the given values + * + * @param array $values + */ + function __construct(array $values = array()) { + $this->setAssignments($values); + } + + + /** + * Given a key / value pair, extract the predicate and any potential paramaters + * in a format suitable for storing internally as a list of paramaterised conditions. + * + * @param mixed $value Either a literal field value, or an array with + * placeholder => parameter(s) as a pair + * @return array A single item array in the format array($sql => array($parameters)) + */ + protected function parseAssignment($value) { + // Assume string values (or values saved as customised array objects) + // represent simple assignment + if(!is_array($value) || isset($value['type'])) { + return array('?' => array($value)); + } + + // If given as array then extract and check both the SQL as well as the parameter(s) + // Note that there could be multiple parameters, e.g. + // array('MAX(?,?)' => array(1,2)) although the container should + // have a single item + if(count($value) == 1) { + foreach($value as $sql => $parameters) { + if(!is_string($sql)) continue; + if(!is_array($parameters)) $parameters = array($parameters); + + // @todo Some input sanitisation checking the key contains the + // correct number of ? placeholders as the number of parameters + return array($sql => $parameters); + } + } + + user_error("Nested field assignments should be given as a single parameterised item array in " + . "array('?' => array('value')) format)", E_USER_ERROR); + } + + /** + * Given a list of assignments in any user-acceptible format, normalise the + * value to a common array('SQL' => array(parameters)) format + * + * @param array $predicates List of assignments. + * The key of this array should be the field name, and the value the assigned + * literal value, or an array with parameterised information. + * @return array List of normalised assignments + */ + protected function normaliseAssignments(array $assignments) { + $normalised = array(); + foreach($assignments as $field => $value) { + $normalised[$field] = $this->parseAssignment($value); + } + return $normalised; + } + + /** + * Adds assignments for a list of several fields + * + * Note that field values must not be escaped, as these will be internally + * parameterised by the database engine. + * + * + * + * // Basic assignments + * $query->addAssignments(array( + * '"Object"."Title"' => 'Bob', + * '"Object"."Description"' => 'Bob was here' + * )) + * + * // Parameterised assignments + * $query->addAssignments(array( + * '"Object"."Title"' => array('?' => 'Bob')), + * '"Object"."Description"' => array('?' => null)) + * )) + * + * // Complex parameters + * $query->addAssignments(array( + * '"Object"."Score"' => array('MAX(?,?)' => array(1, 3)) + * )); + * + * // Assigment of literal SQL for a field. The empty array is + * // important to denote the zero-number paramater list + * $query->addAssignments(array( + * '"Object"."Score"' => array('NOW()' => array()) + * )); + * + * + * + * @param array $assignments The list of fields to assign + * @return self The self reference to this row + */ + public function addAssignments(array $assignments) { + $assignments = $this->normaliseAssignments($assignments); + $this->assignments = array_merge($this->assignments, $assignments); + return $this; + } + + /** + * Sets the list of assignments to the given list + * + * @see SQLWriteExpression::addAssignments() for syntax examples + * + * @param array $assignments + * @return self The self reference to this row + */ + public function setAssignments(array $assignments) { + return $this->clear()->addAssignments($assignments); + } + + /** + * Retrieves the list of assignments in parameterised format + * + * @return array List of assigments. The key of this array will be the + * column to assign, and the value a parameterised array in the format + * array('SQL' => array(parameters)); + */ + public function getAssignments() { + return $this->assignments; + } + + /** + * Set the value for a single field + * + * E.g. + * + * + * // Literal assignment + * $query->assign('"Object"."Description"', 'lorum ipsum'); + * + * // Single parameter + * $query->assign('"Object"."Title"', array('?' => 'Bob')); + * + * // Complex parameters + * $query->assign('"Object"."Score"', array('MAX(?,?)' => array(1, 3)); + * + * + * @param string $field The field name to update + * @param mixed $value The value to assign to this field. This could be an + * array containing a parameterised SQL query of any number of parameters, + * or a single literal value. + * @return self The self reference to this row + */ + public function assign($field, $value) { + return $this->addAssignments(array($field => $value)); + } + + /** + * Assigns a value to a field using the literal SQL expression, rather than + * a value to be escaped + * + * @param string $field The field name to update + * @param string $sql The SQL to use for this update. E.g. "NOW()" + * @return self The self reference to this row + */ + public function assignSQL($field, $sql) { + return $this->assign($field, array($sql => array())); + } + + /** + * Determine if this assignment is empty + * + * @return boolean Flag indicating that this assignment is empty + */ + public function isEmpty() { + return empty($this->assignments); + } + + /** + * Retrieves the list of columns updated + * + * @return array + */ + public function getColumns() { + return array_keys($this->assignments); + } + + /** + * Clears all assignment values + * + * @return self The self reference to this row + */ + public function clear() { + $this->assignments = array(); + return $this; + } +} diff --git a/model/queries/SQLConditionGroup.php b/model/queries/SQLConditionGroup.php new file mode 100644 index 000000000..38d9eeff2 --- /dev/null +++ b/model/queries/SQLConditionGroup.php @@ -0,0 +1,20 @@ + array($parameters)) + * + * @var array + */ + protected $where = array(); + + /** + * The logical connective used to join WHERE clauses. Defaults to AND. + * + * @var string + */ + protected $connective = 'AND'; + + /** + * An array of tables. The first one is just the table name. + * Used as the FROM in DELETE/SELECT statements, the INTO in INSERT statements, + * and the target table in UPDATE statements + * + * The keys of this array are the aliases of the tables (unquoted), where the + * values are either the literal table names, or an array with join details. + * + * @see SQLConditionalExpression::addLeftJoin() + * + * @var array + */ + protected $from = array(); + + /** + * Construct a new SQLInteractExpression. + * + * @param array|string $from An array of Tables (FROM clauses). The first one should be just the table name. + * @param array $where An array of WHERE clauses. + */ + function __construct($from = array(), $where = array()) { + $this->setFrom($from); + $this->setWhere($where); + } + + /** + * Sets the list of tables to query from or update + * + * @example $query->setFrom("MyTable"); // SELECT * FROM MyTable + * + * @param string|array $from Escaped SQL statement, usually an unquoted table name + * @return SQLSelect + */ + public function setFrom($from) { + $this->from = array(); + return $this->addFrom($from); + } + + /** + * @deprecated since version 3.0 + */ + public function from($from) { + Deprecation::notice('3.0', 'Please use setFrom() or addFrom() instead!'); + return $this->setFrom($from); + } + + /** + * Add a table to include in the query or update + * + * @example $query->addFrom("MyTable"); // UPDATE MyTable + * + * @param string|array $from Escaped SQL statement, usually an unquoted table name + * @return self Self reference + */ + public function addFrom($from) { + if(is_array($from)) { + $this->from = array_merge($this->from, $from); + } elseif(!empty($from)) { + $this->from[str_replace(array('"','`'), '', $from)] = $from; + } + + return $this; + } + + /** + * Set the connective property. + * + * @param string $value either 'AND' or 'OR' + */ + public function setConnective($value) { + $this->connective = $value; + } + + /** + * Get the connective property. + * + * @return string 'AND' or 'OR' + */ + public function getConnective() { + return $this->connective; + } + + /** + * Use the disjunctive operator 'OR' to join filter expressions in the WHERE clause. + */ + public function useDisjunction() { + $this->setConnective('OR'); + } + + /** + * Use the conjunctive operator 'AND' to join filter expressions in the WHERE clause. + */ + public function useConjunction() { + $this->setConnective('AND'); + } + + /** + * Add a LEFT JOIN criteria to the tables list. + * + * @param string $table Unquoted table name + * @param string $onPredicate The "ON" SQL fragment in a "LEFT JOIN ... AS ... ON ..." statement, Needs to be valid + * (quoted) SQL. + * @param string $tableAlias Optional alias which makes it easier to identify and replace joins later on + * @param int $order A numerical index to control the order that joins are added to the query; lower order values + * will cause the query to appear first. The default is 20, and joins created automatically by the + * ORM have a value of 10. + * @return self Self reference + */ + public function addLeftJoin($table, $onPredicate, $tableAlias = '', $order = 20) { + if(!$tableAlias) { + $tableAlias = $table; + } + $this->from[$tableAlias] = array( + 'type' => 'LEFT', + 'table' => $table, + 'filter' => array($onPredicate), + 'order' => $order + ); + return $this; + } + + /** + * @deprecated since version 3.0 + */ + public function leftjoin($table, $onPredicate, $tableAlias = null, $order = 20) { + Deprecation::notice('3.0', 'Please use addLeftJoin() instead!'); + $this->addLeftJoin($table, $onPredicate, $tableAlias); + } + + /** + * Add an INNER JOIN criteria + * + * @param string $table Unquoted table name + * @param string $onPredicate The "ON" SQL fragment in an "INNER JOIN ... AS ... ON ..." statement. Needs to be + * valid (quoted) SQL. + * @param string $tableAlias Optional alias which makes it easier to identify and replace joins later on + * @param int $order A numerical index to control the order that joins are added to the query; lower order + * values will cause the query to appear first. The default is 20, and joins created automatically by the + * ORM have a value of 10. + * @return self Self reference + */ + public function addInnerJoin($table, $onPredicate, $tableAlias = null, $order = 20) { + if(!$tableAlias) $tableAlias = $table; + $this->from[$tableAlias] = array( + 'type' => 'INNER', + 'table' => $table, + 'filter' => array($onPredicate), + 'order' => $order + ); + return $this; + } + + /** + * @deprecated since version 3.0 + */ + public function innerjoin($table, $onPredicate, $tableAlias = null, $order = 20) { + Deprecation::notice('3.0', 'Please use addInnerJoin() instead!'); + return $this->addInnerJoin($table, $onPredicate, $tableAlias, $order); + } + + /** + * Add an additional filter (part of the ON clause) on a join. + * + * @param string $table Table to join on from the original join + * @param string $filter The "ON" SQL fragment (escaped) + * @return self Self reference + */ + public function addFilterToJoin($table, $filter) { + $this->from[$table]['filter'][] = $filter; + return $this; + } + + /** + * Set the filter (part of the ON clause) on a join. + * + * @param string $table Table to join on from the original join + * @param string $filter The "ON" SQL fragment (escaped) + * @return self Self reference + */ + public function setJoinFilter($table, $filter) { + $this->from[$table]['filter'] = array($filter); + return $this; + } + + /** + * Returns true if we are already joining to the given table alias + * + * @return boolean + */ + public function isJoinedTo($tableAlias) { + return isset($this->from[$tableAlias]); + } + + /** + * Return a list of tables that this query is selecting from. + * + * @return array Unquoted table names + */ + public function queriedTables() { + $tables = array(); + + foreach($this->from as $key => $tableClause) { + if(is_array($tableClause)) { + $table = '"'.$tableClause['table'].'"'; + } else if(is_string($tableClause) && preg_match('/JOIN +("[^"]+") +(AS|ON) +/i', $tableClause, $matches)) { + $table = $matches[1]; + } else { + $table = $tableClause; + } + + // Handle string replacements + if($this->replacementsOld) $table = str_replace($this->replacementsOld, $this->replacementsNew, $table); + + $tables[] = preg_replace('/^"|"$/','',$table); + } + + return $tables; + } + + /** + * Return a list of tables queried + * + * @return array + */ + public function getFrom() { + return $this->from; + } + + /** + * Retrieves the finalised list of joins + * + * @todo This part of the code could be simplified + * + * @return array List of joins as a mapping from array('Alias' => 'Join Expression') + */ + public function getJoins() { + // Sort the joins + $joins = $this->getOrderedJoins($this->from); + + // Build from clauses + foreach($joins as $alias => $join) { + // $join can be something like this array structure + // array('type' => 'inner', 'table' => 'SiteTree', 'filter' => array("SiteTree.ID = 1", + // "Status = 'approved'", 'order' => 20)) + if(is_array($join)) { + if(is_string($join['filter'])) { + $filter = $join['filter']; + } elseif(sizeof($join['filter']) == 1) { + $filter = $join['filter'][0]; + } else { + $filter = "(" . implode(") AND (", $join['filter']) . ")"; + } + + $aliasClause = ($alias != $join['table']) ? " AS \"$alias\"" : ""; + $joins[$alias] = strtoupper($join['type']) . ' JOIN "' . $join['table'] . "\"$aliasClause ON $filter"; + } + } + + return $joins; + } + + /** + * Ensure that framework "auto-generated" table JOINs are first in the finalised SQL query. + * This prevents issues where developer-initiated JOINs attempt to JOIN using relations that haven't actually + * yet been scaffolded by the framework. Demonstrated by PostGres in errors like: + *"...ERROR: missing FROM-clause..." + * + * @param $from array - in the format of $this->from + * @return array - and reorderded list of selects + */ + protected function getOrderedJoins($from) { + // shift the first FROM table out from so we only deal with the JOINs + $baseFrom = array_shift($from); + $this->mergesort($from, function($firstJoin, $secondJoin) { + if( + !is_array($firstJoin) + || !is_array($secondJoin) + || $firstJoin['order'] == $secondJoin['order'] + ) { + return 0; + } else { + return ($firstJoin['order'] < $secondJoin['order']) ? -1 : 1; + } + }); + + // Put the first FROM table back into the results + array_unshift($from, $baseFrom); + return $from; + } + + /** + * Since uasort don't preserve the order of an array if the comparison is equal + * we have to resort to a merge sort. It's quick and stable: O(n*log(n)). + * + * @see http://stackoverflow.com/q/4353739/139301 + * + * @param array &$array - the array to sort + * @param callable $cmpFunction - the function to use for comparison + */ + protected function mergesort(&$array, $cmpFunction = 'strcmp') { + // Arrays of size < 2 require no action. + if (count($array) < 2) { + return; + } + // Split the array in half + $halfway = count($array) / 2; + $array1 = array_slice($array, 0, $halfway); + $array2 = array_slice($array, $halfway); + // Recurse to sort the two halves + $this->mergesort($array1, $cmpFunction); + $this->mergesort($array2, $cmpFunction); + // If all of $array1 is <= all of $array2, just append them. + if(call_user_func($cmpFunction, end($array1), reset($array2)) < 1) { + $array = array_merge($array1, $array2); + return; + } + // Merge the two sorted arrays into a single sorted array + $array = array(); + $val1 = reset($array1); + $val2 = reset($array2); + do { + if (call_user_func($cmpFunction, $val1, $val2) < 1) { + $array[key($array1)] = $val1; + $val1 = next($array1); + } else { + $array[key($array2)] = $val2; + $val2 = next($array2); + } + } while($val1 && $val2); + + // Merge the remainder + while($val1) { + $array[key($array1)] = $val1; + $val1 = next($array1); + } + while($val2) { + $array[key($array2)] = $val2; + $val2 = next($array2); + } + return; + } + + /** + * Set a WHERE clause. + * + * @see SQLSelect::addWhere() for syntax examples + * + * @param mixed $where Predicate(s) to set, as escaped SQL statements or paramaterised queries + * @param mixed $where,... Unlimited additional predicates + * @return self Self reference + */ + public function setWhere($where) { + $where = func_num_args() > 1 ? func_get_args() : $where; + $this->where = array(); + return $this->addWhere($where); + } + + /** + * Adds a WHERE clause. + * + * Note that the database will execute any parameterised queries using + * prepared statements whenever available. + * + * There are several different ways of doing this. + * + * + * // the entire predicate as a single string + * $query->addWhere("\"Column\" = 'Value'"); + * + * // multiple predicates as an array + * $query->addWhere(array("\"Column\" = 'Value'", "\"Column\" != 'Value'")); + * + * // Shorthand for the above using argument expansion + * $query->addWhere("\"Column\" = 'Value'", "\"Column\" != 'Value'"); + * + * // multiple predicates with parameters + * $query->addWhere(array('"Column" = ?' => $column, '"Name" = ?' => $value))); + * + * // Shorthand for simple column comparison (as above), omitting the '?' + * $query->addWhere(array('"Column"' => $column, '"Name"' => $value)); + * + * // Multiple predicates, each with multiple parameters. + * $query->addWhere(array( + * '"ColumnOne" = ? OR "ColumnTwo" != ?' => array(1, 4), + * '"ID" != ?' => $value + * )); + * + * // Using a dynamically generated condition (any object that implements SQLConditionGroup) + * $condition = new ObjectThatImplements_SQLConditionGroup(); + * $query->addWhere($condition); + * + * + * + * Note that if giving multiple parameters for a single predicate the array + * of values must be given as an indexed array, not an associative array. + * + * Also should be noted is that any null values for parameters may give unexpected + * behaviour. array('Column' => NULL) is shorthand for array('Column = ?', NULL), and + * will not match null values for that column, as 'Column IS NULL' is the correct syntax. + * + * Additionally, be careful of key conflicts. Adding two predicates with the same + * condition but different parameters can cause a key conflict if added in the same array. + * This can be solved by wrapping each individual condition in an array. E.g. + * + * + * // Multiple predicates with duplicate conditions + * $query->addWhere(array( + * array('ID != ?' => 5), + * array('ID != ?' => 6) + * )); + * + * // Alternatively this can be added in two separate calls to addWhere + * $query->addWhere(array('ID != ?' => 5)); + * $query->addWhere(array('ID != ?' => 6)); + * + * // Or simply omit the outer array + * $query->addWhere(array('ID != ?' => 5), array('ID != ?' => 6)); + * + * + * If it's necessary to force the parameter to be considered as a specific data type + * by the database connector's prepared query processor any parameter can be cast + * to that type by using the following format. + * + * + * // Treat this value as a double type, regardless of its type within PHP + * $query->addWhere(array( + * 'Column' => array( + * 'value' => $variable, + * 'type' => 'double' + * ) + * )); + * + * + * @param mixed $where Predicate(s) to set, as escaped SQL statements or paramaterised queries + * @param mixed $where,... Unlimited additional predicates + * @return self Self reference + */ + public function addWhere($where) { + $where = $this->normalisePredicates(func_get_args()); + + // If the function is called with an array of items + $this->where = array_merge($this->where, $where); + + return $this; + } + + /** + * @deprecated since version 3.0 + */ + public function where($where) { + Deprecation::notice('3.0', 'Please use setWhere() or addWhere() instead!'); + return $this->setWhere($where); + } + + /** + * @deprecated since version 3.0 + */ + public function whereAny($where) { + Deprecation::notice('3.0', 'Please use setWhereAny() or setWhereAny() instead!'); + return $this->setWhereAny($where); + } + + /** + * @see SQLSelect::addWhere() + * + * @param mixed $filters Predicate(s) to set, as escaped SQL statements or paramaterised queries + * @param mixed $filters,... Unlimited additional predicates + * @return self Self reference + */ + public function setWhereAny($filters) { + $filters = func_num_args() > 1 ? func_get_args() : $filters; + return $this + ->setWhere(array()) + ->addWhereAny($filters); + } + + /** + * @see SQLSelect::addWhere() + * + * @param mixed $filters Predicate(s) to set, as escaped SQL statements or paramaterised queries + * @param mixed $filters,... Unlimited additional predicates + * @return self Self reference + */ + public function addWhereAny($filters) { + // Parse and split predicates along with any parameters + $filters = $this->normalisePredicates(func_get_args()); + $this->splitQueryParameters($filters, $predicates, $parameters); + + $clause = "(".implode(") OR (", $predicates).")"; + return $this->addWhere(array($clause => $parameters)); + } + + /** + * Return a list of WHERE clauses used internally. + * + * @return array + */ + public function getWhere() { + return $this->where; + } + + /** + * Return a list of WHERE clauses used internally. + * + * @param array $parameters Out variable for parameters required for this query + * @return array + */ + public function getWhereParameterised(&$parameters) { + $this->splitQueryParameters($this->where, $predicates, $parameters); + return $predicates; + } + + /** + * Given a key / value pair, extract the predicate and any potential paramaters + * in a format suitable for storing internally as a list of paramaterised conditions. + * + * @param string|integer $key The left hand (key index) of this condition. + * Could be the predicate or an integer index. + * @param mixed $value The The right hand (array value) of this condition. + * Could be the predicate (if non-paramaterised), or the parameter(s). Could also be + * an array containing a nested condition in the similar format this function outputs. + * @return array|SQLConditionGroup A single item array in the format + * array($predicate => array($parameters)), unless it's a SQLConditionGroup + */ + protected function parsePredicate($key, $value) { + // If a string key is given then presume this is a paramaterised condition + if($value instanceof SQLConditionGroup) { + return $value; + } elseif(is_string($key)) { + + // Extract the parameter(s) from the value + if(!is_array($value) || isset($value['type'])) { + $parameters = array($value); + } else { + $parameters = array_values($value); + } + + // Append '= ?' if not present, parameters are given, and we have exactly one parameter + if(strpos($key, '?') === FALSE) { + $parameterCount = count($parameters); + if($parameterCount === 1) { + $key .= " = ?"; + } elseif($parameterCount > 1) { + user_error("Incorrect number of '?' in predicate $key. Expected $parameterCount but none given.", + E_USER_ERROR); + } + } + return array($key => $parameters); + } elseif(is_array($value)) { + + // If predicates are nested one per array (as per the internal format) + // then run a quick check over the contents and recursively parse + if(count($value) != 1) { + user_error('Nested predicates should be given as a single item array in ' + . 'array($predicate => array($prameters)) format)', E_USER_ERROR); + } + foreach($value as $key => $value) { + return $this->parsePredicate($key, $value); + } + } else { + // Non-paramaterised condition + return array($value => array()); + } + } + + /** + * Given a list of conditions in any user-acceptable format, convert this + * to an array of paramaterised predicates suitable for merging with $this->where. + * + * Normalised predicates are in the below format, in order to avoid key collisions. + * + * + * array( + * array('Condition != ?' => array('parameter')), + * array('Condition != ?' => array('otherparameter')), + * array('Condition = 3' => array()), + * array('Condition = ? OR Condition = ?' => array('parameter1', 'parameter2)) + * ) + * + * + * @param array $predicates List of predicates. These should be wrapped in an array + * one level more than for addWhere, as query expansion is not supported here. + * @return array List of normalised predicates + */ + protected function normalisePredicates(array $predicates) { + // Since this function is called with func_get_args we should un-nest the single first parameter + if(count($predicates) == 1) $predicates = array_shift($predicates); + + // Ensure single predicates are iterable + if(!is_array($predicates)) $predicates = array($predicates); + + $normalised = array(); + foreach($predicates as $key => $value) { + if(empty($value) && (empty($key) || is_numeric($key))) continue; // Ignore empty conditions + $normalised[] = $this->parsePredicate($key, $value); + } + + return $normalised; + } + + /** + * Given a list of conditions as per the format of $this->where, split + * this into an array of predicates, and a separate array of ordered parameters + * + * Note, that any SQLConditionGroup objects will be evaluated here. + * @see SQLConditionGroup + * + * @param array $conditions List of Conditions including parameters + * @param array $predicates Out parameter for the list of string predicates + * @param array $parameters Out parameter for the list of parameters + */ + public function splitQueryParameters($conditions, &$predicates, &$parameters) { + // Merge all filters with paramaterised queries + $predicates = array(); + $parameters = array(); + foreach($conditions as $condition) { + // Evaluate the result of SQLConditionGroup here + if($condition instanceof SQLConditionGroup) { + $conditionSQL = $condition->conditionSQL($conditionParameters); + if(!empty($conditionSQL)) { + $predicates[] = $conditionSQL; + $parameters = array_merge($parameters, $conditionParameters); + } + } else { + foreach($condition as $key => $value) { + $predicates[] = $key; + $parameters = array_merge($parameters, $value); + } + } + } + } + + /** + * Checks whether this query is for a specific ID in a table + * + * @todo Doesn't work with combined statements (e.g. "Foo='bar' AND ID=5") + * + * @return boolean + */ + public function filtersOnID() { + $regexp = '/^(.*\.)?("|`)?ID("|`)?\s?=/'; + + // @todo - Test this works with paramaterised queries + foreach($this->getWhereParameterised($parameters) as $predicate) { + if(preg_match($regexp, $predicate)) return true; + } + + return false; + } + + /** + * Checks whether this query is filtering on a foreign key, ie finding a has_many relationship + * + * @todo Doesn't work with combined statements (e.g. "Foo='bar' AND ParentID=5") + * + * @return boolean + */ + public function filtersOnFK() { + $regexp = '/^(.*\.)?("|`)?[a-zA-Z]+ID("|`)?\s?=/'; + + // @todo - Test this works with paramaterised queries + foreach($this->getWhereParameterised($parameters) as $predicate) { + if(preg_match($regexp, $predicate)) return true; + } + + return false; + } + + public function isEmpty() { + return empty($this->from); + } + + /** + * Generates an SQLDelete object using the currently specified parameters + * + * @return SQLDelete + */ + public function toDelete() { + $delete = new SQLDelete(); + $this->copyTo($delete); + return $delete; + } + + /** + * Generates an SQLSelect object using the currently specified parameters. + * + * @return SQLSelect + */ + public function toSelect() { + $select = new SQLSelect(); + $this->copyTo($select); + return $select; + } + + /** + * Generates an SQLUpdate object using the currently specified parameters. + * No fields will have any assigned values for the newly generated SQLUpdate + * object. + * + * @return SQLUpdate + */ + public function toUpdate() { + $update = new SQLUpdate(); + $this->copyTo($update); + return $update; + } +} diff --git a/model/queries/SQLDelete.php b/model/queries/SQLDelete.php new file mode 100644 index 000000000..212365977 --- /dev/null +++ b/model/queries/SQLDelete.php @@ -0,0 +1,82 @@ +createWithArgs(__CLASS__, func_get_args()); + } + + /** + * Construct a new SQLDelete. + * + * @param array|string $from An array of Tables (FROM clauses). The first one should be just the table name. + * @param array $where An array of WHERE clauses. + * @param array|string $delete The table(s) to delete, if multiple tables are queried from + */ + function __construct($from = array(), $where = array(), $delete = array()) { + parent::__construct($from, $where); + $this->setDelete($delete); + } + + /** + * List of tables to limit the delete to, if multiple tables + * are specified in the condition clause + * + * @return array + */ + public function getDelete() { + return $this->delete; + } + + /** + * Sets the list of tables to limit the delete to, if multiple tables + * are specified in the condition clause + * + * @param string|array $tables Escaped SQL statement, usually an unquoted table name + * @return self Self reference + */ + public function setDelete($tables) { + $this->delete = array(); + return $this->addDelete($tables); + } + + /** + * Sets the list of tables to limit the delete to, if multiple tables + * are specified in the condition clause + * + * @param string|array $tables Escaped SQL statement, usually an unquoted table name + * @return self Self reference + */ + public function addDelete($tables) { + if(is_array($tables)) { + $this->delete = array_merge($this->delete, $tables); + } elseif(!empty($tables)) { + $this->delete[str_replace(array('"','`'), '', $tables)] = $tables; + } + } +} diff --git a/model/queries/SQLExpression.php b/model/queries/SQLExpression.php new file mode 100644 index 000000000..1f40ddb32 --- /dev/null +++ b/model/queries/SQLExpression.php @@ -0,0 +1,145 @@ +$field; + } + + /** + * @deprecated since version 3.2 + */ + public function __set($field, $value) { + Deprecation::notice('3.2', 'use set{Field} to set the necessary protected field\'s value'); + return $this->$field = $value; + } + + + + /** + * Swap some text in the SQL query with another. + * + * Note that values in parameters will not be replaced + * + * @param string $old The old text (escaped) + * @param string $new The new text (escaped) + */ + public function replaceText($old, $new) { + $this->replacementsOld[] = $old; + $this->replacementsNew[] = $new; + } + + /** + * Return the generated SQL string for this query + * + * @todo Is it ok for this to consider parameters? Test cases here! + * + * @return string + */ + public function __toString() { + try { + $sql = $this->sql($parameters); + if(!empty($parameters)) { + $sql .= " <" . var_export($parameters, true) . ">"; + } + return $sql; + } catch(Exception $e) { + return ""; + } + } + + /** + * Swap the use of one table with another. + * + * @param string $old Name of the old table (unquoted, escaped) + * @param string $new Name of the new table (unquoted, escaped) + */ + public function renameTable($old, $new) { + $this->replaceText("`$old`", "`$new`"); + $this->replaceText("\"$old\"", "\"$new\""); + $this->replaceText(Convert::symbol2sql($old), Convert::symbol2sql($new)); + } + + /** + * Determine if this query is empty, and thus cannot be executed + * + * @return bool Flag indicating that this query is empty + */ + abstract public function isEmpty(); + + /** + * Generate the SQL statement for this query. + * + * @param array $parameters Out variable for parameters required for this query + * @return string The completed SQL query + */ + public function sql(&$parameters = array()) { + if(func_num_args() == 0) { + Deprecation::notice( + '3.2', + 'SQLExpression::sql() now may produce parameters which are necessary to execute this query' + ); + } + + // Build each component as needed + $sql = DB::build_sql($this, $parameters); + + if(empty($sql)) return null; + + if($this->replacementsOld) { + $sql = str_replace($this->replacementsOld, $this->replacementsNew, $sql); + } + + return $sql; + } + + /** + * Execute this query. + * + * @return SS_Query + */ + public function execute() { + $sql = $this->sql($parameters); + return DB::prepared_query($sql, $parameters); + } + + /** + * Copies the query parameters contained in this object to another + * SQLExpression + * + * @param SQLExpression $expression The object to copy properties to + */ + protected function copyTo(SQLExpression $object) { + $target = array_keys(get_object_vars($object)); + foreach(get_object_vars($this) as $variable => $value) { + if(in_array($variable, $target)) { + $object->$variable = $value; + } + } + } +} diff --git a/model/queries/SQLInsert.php b/model/queries/SQLInsert.php new file mode 100644 index 000000000..73cd1b340 --- /dev/null +++ b/model/queries/SQLInsert.php @@ -0,0 +1,195 @@ +createWithArgs(__CLASS__, func_get_args()); + } + + /** + * Construct a new SQLInsert object + * + * @param string $into Table name to insert into + * @param array $assignments List of column assignments + */ + function __construct($into = null, $assignments = array()) { + $this->setInto($into); + if(!empty($assignments)) { + $this->setAssignments($assignments); + } + } + + /** + * Sets the table name to insert into + * + * @param string $into Single table name + * @return self The self reference to this query + */ + public function setInto($into) { + $this->into = $into; + return $this; + } + + /** + * Gets the table name to insert into + * + * @return string Single table name + */ + public function getInto() { + return $this->into; + } + + public function isEmpty() { + return empty($this->into) || empty($this->rows); + } + + /** + * Appends a new row to insert + * + * @param array|SQLAssignmentRow $data A list of data to include for this row + * @return self The self reference to this query + */ + public function addRow($data = null) { + // Clear existing empty row + if(($current = $this->currentRow()) && $current->isEmpty()) { + array_pop($this->rows); + } + + // Append data + if($data instanceof SQLAssignmentRow) { + $this->rows[] = $data; + } else { + $this->rows[] = new SQLAssignmentRow($data); + } + return $this; + } + + /** + * Returns the current list of rows + * + * @return array[SQLAssignmentRow] + */ + public function getRows() { + return $this->rows; + } + + /** + * Returns the list of distinct column names used in this insert + * + * @return array + */ + public function getColumns() { + $columns = array(); + foreach($this->getRows() as $row) { + $columns = array_merge($columns, $row->getColumns()); + } + return array_unique($columns); + } + + /** + * Sets all rows to the given array + * + * @param array $rows the list of rows + * @return self The self reference to this query + */ + public function setRows(array $rows) { + return $this->clear()->addRows($rows); + } + + /** + * Adds the list of rows to the array + * + * @param array $rows the list of rows + * @return self The self reference to this query + */ + public function addRows(array $rows) { + foreach($rows as $row) $this->addRow($row); + return $this; + } + + /** + * Returns the currently set row + * + * @param boolean $create Flag to indicate if a row should be created if none exists + * @return SQLAssignmentRow|false The row, or false if none exists + */ + public function currentRow($create = false) { + $current = end($this->rows); + if($create && !$current) { + $this->rows[] = $current = new SQLAssignmentRow(); + } + return $current; + } + + public function addAssignments(array $assignments) { + $this->currentRow(true)->addAssignments($assignments); + return $this; + } + + public function setAssignments(array $assignments) { + $this->currentRow(true)->setAssignments($assignments); + return $this; + } + + public function getAssignments() { + return $this->currentRow(true)->getAssignments(); + } + + public function assign($field, $value) { + $this->currentRow(true)->assign($field, $value); + return $this; + } + + public function assignSQL($field, $sql) { + $this->currentRow(true)->assignSQL($field, $sql); + return $this; + } + + /** + * Clears all currently set assigment values on the current row + * + * @return self The self reference to this query + */ + public function clearRow() { + $this->currentRow(true)->clear(); + return $this; + } + + /** + * Clears all rows + * + * @return self The self reference to this query + */ + public function clear() { + $this->rows = array(); + return $this; + } +} diff --git a/model/queries/SQLQuery.php b/model/queries/SQLQuery.php new file mode 100644 index 000000000..0c2d5aa99 --- /dev/null +++ b/model/queries/SQLQuery.php @@ -0,0 +1,39 @@ +setDelete no longer works. Create a SQLDelete object instead, or use toDelete()'; + Deprecation::notice('3.2', $message); + throw new BadMethodCallException($message); + } + + /** + * @deprecated since version 3.2 + */ + public function getDelete() { + Deprecation::notice('3.2', 'Use SQLDelete object instead'); + return false; + } +} diff --git a/model/queries/SQLSelect.php b/model/queries/SQLSelect.php new file mode 100644 index 000000000..e3f2799e8 --- /dev/null +++ b/model/queries/SQLSelect.php @@ -0,0 +1,709 @@ + array($parameters)) + * + * @var array + */ + protected $having = array(); + + /** + * If this is true DISTINCT will be added to the SQL. + * + * @var bool + */ + protected $distinct = false; + + /** + * An array of ORDER BY clauses, functions. Stores as an associative + * array of column / function to direction. + * + * May be used on SELECT or single table DELETE queries in some adapters + * + * @var string + */ + protected $orderby = array(); + + /** + * An array containing limit and offset keys for LIMIT clause. + * + * May be used on SELECT or single table DELETE queries in some adapters + * + * @var array + */ + protected $limit = array(); + + /** + * Construct a new SQLSelect. + * + * @param array $select An array of SELECT fields. + * @param array|string $from An array of FROM clauses. The first one should be just the table name. + * @param array $where An array of WHERE clauses. + * @param array $orderby An array ORDER BY clause. + * @param array $groupby An array of GROUP BY clauses. + * @param array $having An array of HAVING clauses. + * @param array|string $limit A LIMIT clause or array with limit and offset keys + * @return static + */ + public static function create($select = "*", $from = array(), $where = array(), $orderby = array(), + $groupby = array(), $having = array(), $limit = array()) { + return Injector::inst()->createWithArgs(__CLASS__, func_get_args()); + } + + /** + * Construct a new SQLSelect. + * + * @param array $select An array of SELECT fields. + * @param array|string $from An array of FROM clauses. The first one should be just the table name. + * @param array $where An array of WHERE clauses. + * @param array $orderby An array ORDER BY clause. + * @param array $groupby An array of GROUP BY clauses. + * @param array $having An array of HAVING clauses. + * @param array|string $limit A LIMIT clause or array with limit and offset keys + */ + public function __construct($select = "*", $from = array(), $where = array(), $orderby = array(), + $groupby = array(), $having = array(), $limit = array()) { + + parent::__construct($from, $where); + + $this->setSelect($select); + $this->setOrderBy($orderby); + $this->setGroupBy($groupby); + $this->setHaving($having); + $this->setLimit($limit); + } + + /** + * Set the list of columns to be selected by the query. + * + * + * // pass fields to select as single parameter array + * $query->setSelect(array("Col1","Col2"))->setFrom("MyTable"); + * + * // pass fields to select as multiple parameters + * $query->setSelect("Col1", "Col2")->setFrom("MyTable"); + * + * + * @param string|array $fields + * @param bool $clear Clear existing select fields? + * @return self Self reference + */ + public function setSelect($fields) { + $this->select = array(); + if (func_num_args() > 1) { + $fields = func_get_args(); + } else if(!is_array($fields)) { + $fields = array($fields); + } + return $this->addSelect($fields); + } + + /** + * Add to the list of columns to be selected by the query. + * + * + * // pass fields to select as single parameter array + * $query->addSelect(array("Col1","Col2"))->setFrom("MyTable"); + * + * // pass fields to select as multiple parameters + * $query->addSelect("Col1", "Col2")->setFrom("MyTable"); + * + * + * @param string|array $fields + * @param bool $clear Clear existing select fields? + * @return self Self reference + */ + public function addSelect($fields) { + if (func_num_args() > 1) { + $fields = func_get_args(); + } else if(!is_array($fields)) { + $fields = array($fields); + } + + foreach($fields as $idx => $field) { + if(preg_match('/^(.*) +AS +"?([^"]*)"?/i', $field, $matches)) { + Deprecation::notice("3.0", "Use selectField() to specify column aliases"); + $this->selectField($matches[1], $matches[2]); + } else { + $this->selectField($field, is_numeric($idx) ? null : $idx); + } + } + + return $this; + } + + /** + * @deprecated since version 3.0 + */ + public function select($fields) { + Deprecation::notice('3.0', 'Please use setSelect() or addSelect() instead!'); + $this->setSelect($fields); + } + + /** + * Select an additional field. + * + * @param $field string The field to select (escaped SQL statement) + * @param $alias string The alias of that field (escaped SQL statement). + * Defaults to the unquoted column name of the $field parameter. + * @return self Self reference + */ + public function selectField($field, $alias = null) { + if(!$alias) { + if(preg_match('/"([^"]+)"$/', $field, $matches)) $alias = $matches[1]; + else $alias = $field; + } + $this->select[$alias] = $field; + return $this; + } + + /** + * Return the SQL expression for the given field alias. + * Returns null if the given alias doesn't exist. + * See {@link selectField()} for details on alias generation. + * + * @param string $field + * @return string + */ + public function expressionForField($field) { + return isset($this->select[$field]) ? $this->select[$field] : null; + } + + /** + * Set distinct property. + * + * @param bool $value + * @return self Self reference + */ + public function setDistinct($value) { + $this->distinct = $value; + return $this; + } + + /** + * Get the distinct property. + * + * @return bool + */ + public function getDistinct() { + return $this->distinct; + } + + /** + * Get the limit property. + * @return array + */ + public function getLimit() { + return $this->limit; + } + + /** + * Pass LIMIT clause either as SQL snippet or in array format. + * Internally, limit will always be stored as a map containing the keys 'start' and 'limit' + * + * @param int|string|array $limit If passed as a string or array, assumes SQL escaped data. + * Only applies for positive values, or if an $offset is set as well. + * @param int $offset + * @throws InvalidArgumentException + * @return self Self reference + */ + public function setLimit($limit, $offset = 0) { + if((is_numeric($limit) && $limit < 0) || (is_numeric($offset) && $offset < 0)) { + throw new InvalidArgumentException("SQLSelect::setLimit() only takes positive values"); + } + + if(is_numeric($limit) && ($limit || $offset)) { + $this->limit = array( + 'start' => $offset, + 'limit' => $limit, + ); + } else if($limit && is_string($limit)) { + if(strpos($limit, ',') !== false) { + list($start, $innerLimit) = explode(',', $limit, 2); + } + else { + list($innerLimit, $start) = explode(' OFFSET ', strtoupper($limit), 2); + } + + $this->limit = array( + 'start' => trim($start), + 'limit' => trim($innerLimit), + ); + } else { + $this->limit = $limit; + } + + return $this; + } + + /** + * @deprecated since version 3.0 + */ + public function limit($limit, $offset = 0) { + Deprecation::notice('3.0', 'Please use setLimit() instead!'); + return $this->setLimit($limit, $offset); + } + + /** + * Set ORDER BY clause either as SQL snippet or in array format. + * + * @example $sql->setOrderBy("Column"); + * @example $sql->setOrderBy("Column DESC"); + * @example $sql->setOrderBy("Column DESC, ColumnTwo ASC"); + * @example $sql->setOrderBy("Column", "DESC"); + * @example $sql->setOrderBy(array("Column" => "ASC", "ColumnTwo" => "DESC")); + * + * @param string|array $clauses Clauses to add (escaped SQL statement) + * @param string $dir Sort direction, ASC or DESC + * + * @return self Self reference + */ + public function setOrderBy($clauses = null, $direction = null) { + $this->orderby = array(); + return $this->addOrderBy($clauses, $direction); + } + + /** + * Add ORDER BY clause either as SQL snippet or in array format. + * + * @example $sql->addOrderBy("Column"); + * @example $sql->addOrderBy("Column DESC"); + * @example $sql->addOrderBy("Column DESC, ColumnTwo ASC"); + * @example $sql->addOrderBy("Column", "DESC"); + * @example $sql->addOrderBy(array("Column" => "ASC", "ColumnTwo" => "DESC")); + * + * @param string|array $clauses Clauses to add (escaped SQL statements) + * @param string $direction Sort direction, ASC or DESC + * @return self Self reference + */ + public function addOrderBy($clauses = null, $direction = null) { + if(empty($clauses)) return $this; + + if(is_string($clauses)) { + if(strpos($clauses, "(") !== false) { + $sort = preg_split("/,(?![^()]*+\\))/", $clauses); + } else { + $sort = explode(",", $clauses); + } + + $clauses = array(); + + foreach($sort as $clause) { + list($column, $direction) = $this->getDirectionFromString($clause, $direction); + $clauses[$column] = $direction; + } + } + + if(is_array($clauses)) { + foreach($clauses as $key => $value) { + if(!is_numeric($key)) { + $column = trim($key); + $columnDir = strtoupper(trim($value)); + } else { + list($column, $columnDir) = $this->getDirectionFromString($value); + } + + $this->orderby[$column] = $columnDir; + } + } else { + user_error('SQLSelect::orderby() incorrect format for $orderby', E_USER_WARNING); + } + + // If sort contains a public function call, let's move the sort clause into a + // separate selected field. + // + // Some versions of MySQL choke if you have a group public function referenced + // directly in the ORDER BY + if($this->orderby) { + $i = 0; + $orderby = array(); + foreach($this->orderby as $clause => $dir) { + + // public function calls and multi-word columns like "CASE WHEN ..." + if(strpos($clause, '(') !== false || strpos($clause, " ") !== false ) { + + // Move the clause to the select fragment, substituting a placeholder column in the sort fragment. + $clause = trim($clause); + $column = "_SortColumn{$i}"; + $this->selectField($clause, $column); + $clause = '"' . $column . '"'; + $i++; + } + $orderby[$clause] = $dir; + } + $this->orderby = $orderby; + } + + return $this; + } + + /** + * @deprecated since version 3.0 + */ + public function orderby($clauses = null, $direction = null) { + Deprecation::notice('3.0', 'Please use setOrderBy() instead!'); + return $this->setOrderBy($clauses, $direction); + } + + /** + * Extract the direction part of a single-column order by clause. + * + * @param String + * @param String + * @return Array A two element array: array($column, $direction) + */ + private function getDirectionFromString($value, $defaultDirection = null) { + if(preg_match('/^(.*)(asc|desc)$/i', $value, $matches)) { + $column = trim($matches[1]); + $direction = strtoupper($matches[2]); + } else { + $column = $value; + $direction = $defaultDirection ? $defaultDirection : "ASC"; + } + return array($column, $direction); + } + + /** + * Returns the current order by as array if not already. To handle legacy + * statements which are stored as strings. Without clauses and directions, + * convert the orderby clause to something readable. + * + * @return array + */ + public function getOrderBy() { + $orderby = $this->orderby; + if(!$orderby) $orderby = array(); + + if(!is_array($orderby)) { + // spilt by any commas not within brackets + $orderby = preg_split('/,(?![^()]*+\\))/', $orderby); + } + + foreach($orderby as $k => $v) { + if(strpos($v, ' ') !== false) { + unset($orderby[$k]); + + $rule = explode(' ', trim($v)); + $clause = $rule[0]; + $dir = (isset($rule[1])) ? $rule[1] : 'ASC'; + + $orderby[$clause] = $dir; + } + } + + return $orderby; + } + + /** + * Reverses the order by clause by replacing ASC or DESC references in the + * current order by with it's corollary. + * + * @return self Self reference + */ + public function reverseOrderBy() { + $order = $this->getOrderBy(); + $this->orderby = array(); + + foreach($order as $clause => $dir) { + $dir = (strtoupper($dir) == 'DESC') ? 'ASC' : 'DESC'; + $this->addOrderBy($clause, $dir); + } + + return $this; + } + + /** + * Set a GROUP BY clause. + * + * @param string|array $groupby Escaped SQL statement + * @return self Self reference + */ + public function setGroupBy($groupby) { + $this->groupby = array(); + return $this->addGroupBy($groupby); + } + + /** + * Add a GROUP BY clause. + * + * @param string|array $groupby Escaped SQL statement + * @return self Self reference + */ + public function addGroupBy($groupby) { + if(is_array($groupby)) { + $this->groupby = array_merge($this->groupby, $groupby); + } elseif(!empty($groupby)) { + $this->groupby[] = $groupby; + } + + return $this; + } + + /** + * @deprecated since version 3.0 + */ + public function groupby($where) { + Deprecation::notice('3.0', 'Please use setGroupBy() or addHaving() instead!'); + return $this->setGroupBy($where); + } + + /** + * Set a HAVING clause. + * + * @see SQLSelect::addWhere() for syntax examples + * + * @param mixed $having Predicate(s) to set, as escaped SQL statements or paramaterised queries + * @param mixed $having,... Unlimited additional predicates + * @return self Self reference + */ + public function setHaving($having) { + $having = func_num_args() > 1 ? func_get_args() : $having; + $this->having = array(); + return $this->addHaving($having); + } + + /** + * Add a HAVING clause + * + * @see SQLSelect::addWhere() for syntax examples + * + * @param mixed $having Predicate(s) to set, as escaped SQL statements or paramaterised queries + * @param mixed $having,... Unlimited additional predicates + * @return self Self reference + */ + public function addHaving($having) { + $having = $this->normalisePredicates(func_get_args()); + + // If the function is called with an array of items + $this->having = array_merge($this->having, $having); + + return $this; + } + + /** + * @deprecated since version 3.0 + */ + public function having($having) { + Deprecation::notice('3.0', 'Please use setHaving() or addHaving() instead!'); + return $this->setHaving($having); + } + + /** + * Return a list of HAVING clauses used internally. + * @return array + */ + public function getHaving() { + return $this->having; + } + + /** + * Return a list of HAVING clauses used internally. + * + * @param array $parameters Out variable for parameters required for this query + * @return array + */ + public function getHavingParameterised(&$parameters) { + $this->splitQueryParameters($this->having, $conditions, $parameters); + return $conditions; + } + + /** + * Return a list of GROUP BY clauses used internally. + * + * @return array + */ + public function getGroupBy() { + return $this->groupby; + } + + /** + * Return an itemised select list as a map, where keys are the aliases, and values are the column sources. + * Aliases will always be provided (if the alias is implicit, the alias value will be inferred), and won't be + * quoted. + * E.g., 'Title' => '"SiteTree"."Title"'. + * + * @return array + */ + public function getSelect() { + return $this->select; + } + + /// VARIOUS TRANSFORMATIONS BELOW + + /** + * Return the number of rows in this query if the limit were removed. Useful in paged data sets. + * + * @param string $column + * @return int + */ + public function unlimitedRowCount($column = null) { + // we can't clear the select if we're relying on its output by a HAVING clause + if(count($this->having)) { + $records = $this->execute(); + return $records->numRecords(); + } + + $clone = clone $this; + $clone->limit = null; + $clone->orderby = null; + + // Choose a default column + if($column == null) { + if($this->groupby) { + // @todo Test case required here + $countQuery = new SQLSelect(); + $countQuery->select("count(*)"); + $countQuery->addFrom(array('(' . $clone->sql($innerParameters) . ') all_distinct')); + $sql = $countQuery->sql($parameters); // $parameters should be empty + $result = DB::prepared_query($sql, $innerParameters); + return $result->value(); + } else { + $clone->setSelect(array("count(*)")); + } + } else { + $clone->setSelect(array("count($column)")); + } + + $clone->setGroupBy(array());; + return $clone->execute()->value(); + } + + /** + * Returns true if this query can be sorted by the given field. + * + * @param string $fieldName + * @return bool + */ + public function canSortBy($fieldName) { + $fieldName = preg_replace('/(\s+?)(A|DE)SC$/', '', $fieldName); + + return isset($this->select[$fieldName]); + } + + + /** + * Return the number of rows in this query if the limit were removed. Useful in paged data sets. + * + * @todo Respect HAVING and GROUPBY, which can affect the result-count + * + * @param string $column Quoted, escaped column name + * @return int + */ + public function count( $column = null) { + // Choose a default column + if($column == null) { + if($this->groupby) { + $column = 'DISTINCT ' . implode(", ", $this->groupby); + } else { + $column = '*'; + } + } + + $clone = clone $this; + $clone->select = array('Count' => "count($column)"); + $clone->limit = null; + $clone->orderby = null; + $clone->groupby = null; + + $count = $clone->execute()->value(); + // If there's a limit set, then that limit is going to heavily affect the count + if($this->limit) { + if($count >= ($this->limit['start'] + $this->limit['limit'])) { + return $this->limit['limit']; + } else { + return max(0, $count - $this->limit['start']); + } + + // Otherwise, the count is going to be the output of the SQL query + } else { + return $count; + } + } + + /** + * Return a new SQLSelect that calls the given aggregate functions on this data. + * + * @param string $column An aggregate expression, such as 'MAX("Balance")', or a set of them + * (as an escaped SQL statement) + * @param string $alias An optional alias for the aggregate column. + * @return SQLSelect A clone of this object with the given aggregate function + */ + public function aggregate($column, $alias = null) { + + $clone = clone $this; + + // don't set an ORDER BY clause if no limit has been set. It doesn't make + // sense to add an ORDER BY if there is no limit, and it will break + // queries to databases like MSSQL if you do so. Note that the reason + // this came up is because DataQuery::initialiseQuery() introduces + // a default sort. + if($this->limit) { + $clone->setLimit($this->limit); + $clone->setOrderBy($this->orderby); + } else { + $clone->setOrderBy(array()); + } + + $clone->setGroupBy($this->groupby); + if($alias) { + $clone->setSelect(array()); + $clone->selectField($column, $alias); + } else { + $clone->setSelect($column); + } + + return $clone; + } + + /** + * Returns a query that returns only the first row of this query + * + * @return SQLSelect A clone of this object with the first row only + */ + public function firstRow() { + $query = clone $this; + $offset = $this->limit ? $this->limit['start'] : 0; + $query->setLimit(1, $offset); + return $query; + } + + /** + * Returns a query that returns only the last row of this query + * + * @return SQLSelect A clone of this object with the last row only + */ + public function lastRow() { + $query = clone $this; + $offset = $this->limit ? $this->limit['start'] : 0; + + // Limit index to start in case of empty results + $index = max($this->count() + $offset - 1, 0); + $query->setLimit(1, $index); + return $query; + } +} diff --git a/model/queries/SQLUpdate.php b/model/queries/SQLUpdate.php new file mode 100644 index 000000000..e38fcbaec --- /dev/null +++ b/model/queries/SQLUpdate.php @@ -0,0 +1,101 @@ +createWithArgs(__CLASS__, func_get_args()); + } + + /** + * Construct a new SQLUpdate object + * + * @param string $table Table name to update + * @param array $assignment List of column assignments + * @param array $where List of where clauses + */ + function __construct($table = null, $assignment = array(), $where = array()) { + parent::__construct(null, $where); + $this->assignment = new SQLAssignmentRow(); + $this->setTable($table); + $this->setAssignments($assignment); + } + + /** + * Sets the table name to update + * + * @param string $table + * @return self Self reference + */ + public function setTable($table) { + return $this->setFrom($table); + } + + /** + * Gets the table name to update + * + * @return string Name of the table + */ + public function getTable() { + return reset($this->from); + } + + public function addAssignments(array $assignments) { + $this->assignment->addAssignments($assignments); + return $this; + } + + public function setAssignments(array $assignments) { + $this->assignment->setAssignments($assignments); + return $this; + } + + public function getAssignments() { + return $this->assignment->getAssignments(); + } + + public function assign($field, $value) { + $this->assignment->assign($field, $value); + return $this; + } + + public function assignSQL($field, $sql) { + $this->assignment->assignSQL($field, $sql); + return $this; + } + + /** + * Clears all currently set assigment values + * + * @return self The self reference to this query + */ + public function clear() { + $this->assignment->clear(); + return $this; + } + + public function isEmpty() { + return empty($this->assignment) || $this->assignment->isEmpty() || parent::isEmpty(); + } +} diff --git a/model/queries/SQLWriteExpression.php b/model/queries/SQLWriteExpression.php new file mode 100644 index 000000000..38abe124f --- /dev/null +++ b/model/queries/SQLWriteExpression.php @@ -0,0 +1,112 @@ + + * + * // Basic assignments + * $query->addAssignments(array( + * '"Object"."Title"' => 'Bob', + * '"Object"."Description"' => 'Bob was here' + * )) + * + * // Parameterised assignments + * $query->addAssignments(array( + * '"Object"."Title"' => array('?' => 'Bob')), + * '"Object"."Description"' => array('?' => null)) + * )) + * + * // Complex parameters + * $query->addAssignments(array( + * '"Object"."Score"' => array('MAX(?,?)' => array(1, 3)) + * )); + * + * // Assigment of literal SQL for a field. The empty array is + * // important to denote the zero-number paramater list + * $query->addAssignments(array( + * '"Object"."Score"' => array('NOW()' => array()) + * )); + * + *
    + * + * @param array $assignments The list of fields to assign + * @return self Self reference + */ + public function addAssignments(array $assignments); + + /** + * Sets the list of assignments to the given list + * + * For multi-row objects this applies this to the current row. + * + * @see SQLWriteExpression::addAssignments() for syntax examples + * + * @param array $assignments + * @return self Self reference + */ + public function setAssignments(array $assignments); + + /** + * Retrieves the list of assignments in parameterised format + * + * For multi-row objects returns assignments for the current row. + * + * @return array List of assigments. The key of this array will be the + * column to assign, and the value a parameterised array in the format + * array('SQL' => array(parameters)); + */ + public function getAssignments(); + + /** + * Set the value for a single field + * + * For multi-row objects this applies this to the current row. + * + * E.g. + * + * + * // Literal assignment + * $query->assign('"Object"."Description"', 'lorum ipsum')); + * + * // Single parameter + * $query->assign('"Object"."Title"', array('?' => 'Bob')); + * + * // Complex parameters + * $query->assign('"Object"."Score"', array('MAX(?,?)' => array(1, 3)); + * + * + * @param string $field The field name to update + * @param mixed $value The value to assign to this field. This could be an + * array containing a parameterised SQL query of any number of parameters, + * or a single literal value. + * @return self Self reference + */ + public function assign($field, $value); + + /** + * Assigns a value to a field using the literal SQL expression, rather than + * a value to be escaped + * + * For multi-row objects this applies this to the current row. + * + * @param string $field The field name to update + * @param string $sql The SQL to use for this update. E.g. "NOW()" + * @return self Self reference + */ + public function assignSQL($field, $sql); +} diff --git a/search/FulltextSearchable.php b/search/FulltextSearchable.php index 3b450a131..5004f8ffc 100644 --- a/search/FulltextSearchable.php +++ b/search/FulltextSearchable.php @@ -44,7 +44,7 @@ class FulltextSearchable extends DataExtension { public static function enable($searchableClasses = array('SiteTree', 'File')) { $defaultColumns = array( 'SiteTree' => '"Title","MenuTitle","Content","MetaDescription"', - 'File' => '"Filename","Title","Content"' + 'File' => '"Title","Filename","Content"' ); if(!is_array($searchableClasses)) $searchableClasses = array($searchableClasses); @@ -52,11 +52,14 @@ class FulltextSearchable extends DataExtension { if(!class_exists($class)) continue; if(isset($defaultColumns[$class])) { - Config::inst()->update($class, 'create_table_options', array('MySQLDatabase' => 'ENGINE=MyISAM')); + Config::inst()->update( + $class, 'create_table_options', array(MySQLSchemaManager::ID => 'ENGINE=MyISAM') + ); $class::add_extension("FulltextSearchable('{$defaultColumns[$class]}')"); } else { throw new Exception( - "FulltextSearchable::enable() I don't know the default search columns for class '$class'"); + "FulltextSearchable::enable() I don't know the default search columns for class '$class'" + ); } } self::$searchable_classes = $searchableClasses; diff --git a/search/SearchContext.php b/search/SearchContext.php index fcce9dc33..bfd7b3d5b 100644 --- a/search/SearchContext.php +++ b/search/SearchContext.php @@ -5,7 +5,7 @@ * SearchContext is intentionally decoupled from any controller-logic, * it just receives a set of search parameters and an object class it acts on. * -* The default output of a SearchContext is either a {@link SQLQuery} object +* The default output of a SearchContext is either a {@link SQLSelect} object * for further refinement, or a {@link SS_List} that can be used to display * search results, e.g. in a {@link TableListField} instance. * @@ -88,7 +88,7 @@ class SearchContext extends Object { } /** - * @todo move to SQLQuery + * @todo move to SQLSelect * @todo fix hack */ protected function applyBaseTableFields() { diff --git a/search/filters/ComparisonFilter.php b/search/filters/ComparisonFilter.php index d15dc4a2a..b082afe69 100755 --- a/search/filters/ComparisonFilter.php +++ b/search/filters/ComparisonFilter.php @@ -38,17 +38,11 @@ abstract class ComparisonFilter extends SearchFilter { */ protected function applyOne(DataQuery $query) { $this->model = $query->applyRelation($this->relation); - $value = $this->getDbFormattedValue(); - if(is_numeric($value)) { - $filter = sprintf("%s %s %s", - $this->getDbName(), $this->getOperator(), Convert::raw2sql($value)); - } else { - $filter = sprintf("%s %s '%s'", - $this->getDbName(), $this->getOperator(), Convert::raw2sql($value)); - } - - return $query->where($filter); + $predicate = sprintf("%s %s ?", $this->getDbName(), $this->getOperator()); + return $query->where(array( + $predicate => $this->getDbFormattedValue() + )); } /** @@ -60,17 +54,11 @@ abstract class ComparisonFilter extends SearchFilter { */ protected function excludeOne(DataQuery $query) { $this->model = $query->applyRelation($this->relation); - $value = $this->getDbFormattedValue(); - if(is_numeric($value)) { - $filter = sprintf("%s %s %s", - $this->getDbName(), $this->getInverseOperator(), Convert::raw2sql($value)); - } else { - $filter = sprintf("%s %s '%s'", - $this->getDbName(), $this->getInverseOperator(), Convert::raw2sql($value)); - } - - return $query->where($filter); + $predicate = sprintf("%s %s ?", $this->getDbName(), $this->getInverseOperator()); + return $query->where(array( + $predicate => $this->getDbFormattedValue() + )); } public function isEmpty() { diff --git a/search/filters/EndsWithFilter.php b/search/filters/EndsWithFilter.php index 05bf258ed..40ef59333 100644 --- a/search/filters/EndsWithFilter.php +++ b/search/filters/EndsWithFilter.php @@ -16,100 +16,9 @@ * @package framework * @subpackage search */ -class EndsWithFilter extends SearchFilter { +class EndsWithFilter extends PartialMatchFilter { - public function setModifiers(array $modifiers) { - if(($extras = array_diff($modifiers, array('not', 'nocase', 'case'))) != array()) { - throw new InvalidArgumentException( - get_class($this) . ' does not accept ' . implode(', ', $extras) . ' as modifiers'); - } - - parent::setModifiers($modifiers); - } - - /** - * Applies a match on the trailing characters of a field value. - * - * @return DataQuery - */ - protected function applyOne(DataQuery $query) { - $this->model = $query->applyRelation($this->relation); - $modifiers = $this->getModifiers(); - $where = DB::getConn()->comparisonClause( - $this->getDbName(), - '%' . Convert::raw2sql($this->getValue()), - false, // exact? - false, // negate? - $this->getCaseSensitive() - ); - return $query->where($where); - } - - /** - * Applies a match on the trailing characters of a field value. - * Matches against one of the many values. - * - * @return DataQuery - */ - protected function applyMany(DataQuery $query) { - $this->model = $query->applyRelation($this->relation); - $modifiers = $this->getModifiers(); - $connectives = array(); - foreach($this->getValue() as $value) { - $connectives[] = DB::getConn()->comparisonClause( - $this->getDbName(), - '%' . Convert::raw2sql($value), - false, // exact? - false, // negate? - $this->getCaseSensitive() - ); - } - $whereClause = implode(' OR ', $connectives); - return $query->where($whereClause); - } - - /** - * Excludes a match on the trailing characters of a field value. - * - * @return DataQuery - */ - protected function excludeOne(DataQuery $query) { - $this->model = $query->applyRelation($this->relation); - $modifiers = $this->getModifiers(); - $where = DB::getConn()->comparisonClause( - $this->getDbName(), - '%' . Convert::raw2sql($this->getValue()), - false, // exact? - true, // negate? - $this->getCaseSensitive() - ); - return $query->where($where); - } - - /** - * Excludes a match on the trailing characters of a field value. - * Excludes a field if it matches any of the values. - * - * @return DataQuery - */ - protected function excludeMany(DataQuery $query) { - $this->model = $query->applyRelation($this->relation); - $modifiers = $this->getModifiers(); - $connectives = array(); - foreach($this->getValue() as $value) { - $connectives[] = DB::getConn()->comparisonClause( - $this->getDbName(), - '%' . Convert::raw2sql($value), - false, // exact? - true, // negate? - $this->getCaseSensitive() - ); - } - $whereClause = implode(' AND ', $connectives); - return $query->where($whereClause); - } - - public function isEmpty() { - return $this->getValue() === array() || $this->getValue() === null || $this->getValue() === ''; + protected function getMatchPattern($value) { + return "%$value"; } } diff --git a/search/filters/ExactMatchFilter.php b/search/filters/ExactMatchFilter.php index f47e8d0c9..a822eda3d 100644 --- a/search/filters/ExactMatchFilter.php +++ b/search/filters/ExactMatchFilter.php @@ -31,15 +31,15 @@ class ExactMatchFilter extends SearchFilter { */ protected function applyOne(DataQuery $query) { $this->model = $query->applyRelation($this->relation); - $modifiers = $this->getModifiers(); - $where = DB::getConn()->comparisonClause( + $where = DB::get_conn()->comparisonClause( $this->getDbName(), - Convert::raw2sql($this->getValue()), + null, true, // exact? false, // negate? - $this->getCaseSensitive() + $this->getCaseSensitive(), + true ); - return $query->where($where); + return $query->where(array($where => $this->getValue())); } /** @@ -50,30 +50,30 @@ class ExactMatchFilter extends SearchFilter { */ protected function applyMany(DataQuery $query) { $this->model = $query->applyRelation($this->relation); - $modifiers = $this->getModifiers(); - $values = array(); - foreach($this->getValue() as $value) { - $values[] = Convert::raw2sql($value); - } - if(!in_array('case', $modifiers) && !in_array('nocase', $modifiers)) { - $valueStr = "'" . implode("', '", $values) . "'"; - return $query->where(sprintf( - '%s IN (%s)', - $this->getDbName(), - $valueStr + $caseSensitive = $this->getCaseSensitive(); + $values = $this->getValue(); + if($caseSensitive === null) { + // For queries using the default collation (no explicit case) we can use the WHERE .. IN .. syntax, + // providing simpler SQL than many WHERE .. OR .. fragments. + $column = $this->getDbName(); + $placeholders = DB::placeholders($values); + return $query->where(array( + "$column IN ($placeholders)" => $values )); } else { - foreach($values as &$v) { - $v = DB::getConn()->comparisonClause( - $this->getDbName(), - $v, - true, // exact? - false, // negate? - $this->getCaseSensitive() - ); + $whereClause = array(); + $comparisonClause = DB::get_conn()->comparisonClause( + $this->getDbName(), + null, + true, // exact? + false, // negate? + $caseSensitive, + true + ); + foreach($values as $value) { + $whereClause[] = array($comparisonClause => $value); } - $where = implode(' OR ', $values); - return $query->where($where); + return $query->whereAny($whereClause); } } @@ -84,15 +84,15 @@ class ExactMatchFilter extends SearchFilter { */ protected function excludeOne(DataQuery $query) { $this->model = $query->applyRelation($this->relation); - $modifiers = $this->getModifiers(); - $where = DB::getConn()->comparisonClause( + $where = DB::get_conn()->comparisonClause( $this->getDbName(), - Convert::raw2sql($this->getValue()), + null, true, // exact? true, // negate? - $this->getCaseSensitive() + $this->getCaseSensitive(), + true ); - return $query->where($where); + return $query->where(array($where => $this->getValue())); } /** @@ -103,30 +103,30 @@ class ExactMatchFilter extends SearchFilter { */ protected function excludeMany(DataQuery $query) { $this->model = $query->applyRelation($this->relation); - $modifiers = $this->getModifiers(); - $values = array(); - foreach($this->getValue() as $value) { - $values[] = Convert::raw2sql($value); - } - if(!in_array('case', $modifiers) && !in_array('nocase', $modifiers)) { - $valueStr = "'" . implode("', '", $values) . "'"; - return $query->where(sprintf( - '%s NOT IN (%s)', - $this->getDbName(), - $valueStr + $caseSensitive = $this->getCaseSensitive(); + $values = $this->getValue(); + if($caseSensitive === null) { + // For queries using the default collation (no explicit case) we can use the WHERE .. NOT IN .. syntax, + // providing simpler SQL than many WHERE .. AND .. fragments. + $column = $this->getDbName(); + $placeholders = DB::placeholders($values); + return $query->where(array( + "$column NOT IN ($placeholders)" => $values )); } else { - foreach($values as &$v) { - $v = DB::getConn()->comparisonClause( - $this->getDbName(), - $v, - true, // exact? - true, // negate? - $this->getCaseSensitive() - ); - } - $where = implode(' OR ', $values); - return $query->where($where); + // Generate reusable comparison clause + $comparisonClause = DB::get_conn()->comparisonClause( + $this->getDbName(), + null, + true, // exact? + true, // negate? + $this->getCaseSensitive(), + true + ); + // Since query connective is ambiguous, use AND explicitly here + $count = count($values); + $predicate = implode(' AND ', array_fill(0, $count, $comparisonClause)); + return $query->where(array($predicate => $values)); } } diff --git a/search/filters/FulltextFilter.php b/search/filters/FulltextFilter.php index 36b77b4c2..632378b96 100644 --- a/search/filters/FulltextFilter.php +++ b/search/filters/FulltextFilter.php @@ -28,19 +28,13 @@ class FulltextFilter extends SearchFilter { protected function applyOne(DataQuery $query) { - return $query->where(sprintf( - "MATCH (%s) AGAINST ('%s')", - $this->getDbName(), - Convert::raw2sql($this->getValue()) - )); + $predicate = sprintf("MATCH (%s) AGAINST (?)", $this->getDbName()); + return $query->where(array($predicate => $this->getValue())); } protected function excludeOne(DataQuery $query) { - return $query->where(sprintf( - "NOT MATCH (%s) AGAINST ('%s')", - $this->getDbName(), - Convert::raw2sql($this->getValue()) - )); + $predicate = sprintf("NOT MATCH (%s) AGAINST (?)", $this->getDbName()); + return $query->where(array($predicate => $this->getValue())); } public function isEmpty() { diff --git a/search/filters/PartialMatchFilter.php b/search/filters/PartialMatchFilter.php index 2a884797c..a6ef598f3 100644 --- a/search/filters/PartialMatchFilter.php +++ b/search/filters/PartialMatchFilter.php @@ -21,66 +21,82 @@ class PartialMatchFilter extends SearchFilter { parent::setModifiers($modifiers); } + /** + * Apply the match filter to the given variable value + * + * @param string $value The raw value + * @return string + */ + protected function getMatchPattern($value) { + return "%$value%"; + } + protected function applyOne(DataQuery $query) { $this->model = $query->applyRelation($this->relation); - $modifiers = $this->getModifiers(); - $where = DB::getConn()->comparisonClause( + $comparisonClause = DB::get_conn()->comparisonClause( $this->getDbName(), - '%' . Convert::raw2sql($this->getValue()) . '%', + null, false, // exact? false, // negate? - $this->getCaseSensitive() + $this->getCaseSensitive(), + true ); - - return $query->where($where); + return $query->where(array( + $comparisonClause => $this->getMatchPattern($this->getValue()) + )); } protected function applyMany(DataQuery $query) { $this->model = $query->applyRelation($this->relation); - $where = array(); - $modifiers = $this->getModifiers(); + $whereClause = array(); + $comparisonClause = DB::get_conn()->comparisonClause( + $this->getDbName(), + null, + false, // exact? + false, // negate? + $this->getCaseSensitive(), + true + ); foreach($this->getValue() as $value) { - $where[]= DB::getConn()->comparisonClause( - $this->getDbName(), - '%' . Convert::raw2sql($value) . '%', - false, // exact? - false, // negate? - $this->getCaseSensitive() - ); + $whereClause[] = array($comparisonClause => $this->getMatchPattern($value)); } - - return $query->where(implode(' OR ', $where)); + return $query->whereAny($whereClause); } protected function excludeOne(DataQuery $query) { $this->model = $query->applyRelation($this->relation); - $modifiers = $this->getModifiers(); - $where = DB::getConn()->comparisonClause( + $comparisonClause = DB::get_conn()->comparisonClause( $this->getDbName(), - '%' . Convert::raw2sql($this->getValue()) . '%', + null, false, // exact? true, // negate? - $this->getCaseSensitive() + $this->getCaseSensitive(), + true ); - - return $query->where($where); + return $query->where(array( + $comparisonClause => $this->getMatchPattern($this->getValue()) + )); } protected function excludeMany(DataQuery $query) { $this->model = $query->applyRelation($this->relation); - $where = array(); - $modifiers = $this->getModifiers(); - foreach($this->getValue() as $value) { - $where[]= DB::getConn()->comparisonClause( - $this->getDbName(), - '%' . Convert::raw2sql($value) . '%', - false, // exact? - true, // negate? - $this->getCaseSensitive() - ); + $values = $this->getValue(); + $comparisonClause = DB::get_conn()->comparisonClause( + $this->getDbName(), + null, + false, // exact? + true, // negate? + $this->getCaseSensitive(), + true + ); + $parameters = array(); + foreach($values as $value) { + $parameters[] = $this->getMatchPattern($value); } - - return $query->where(implode(' AND ', $where)); + // Since query connective is ambiguous, use AND explicitly here + $count = count($values); + $predicate = implode(' AND ', array_fill(0, $count, $comparisonClause)); + return $query->where(array($predicate => $parameters)); } public function isEmpty() { diff --git a/search/filters/SearchFilter.php b/search/filters/SearchFilter.php index 5df64815f..8e68ba129 100644 --- a/search/filters/SearchFilter.php +++ b/search/filters/SearchFilter.php @@ -86,9 +86,9 @@ abstract class SearchFilter extends Object { } /** - * Set the current value to be filtered on. + * Set the current value(s) to be filtered on. * - * @param string $value + * @param string|array $value */ public function setValue($value) { $this->value = $value; @@ -96,9 +96,8 @@ abstract class SearchFilter extends Object { /** * Accessor for the current value to be filtered on. - * Caution: Data is not escaped. * - * @return string + * @return string|array */ public function getValue() { return $this->value; @@ -172,8 +171,9 @@ abstract class SearchFilter extends Object { $candidateClass = $this->model; while($candidateClass != 'DataObject') { - if(DataObject::has_own_table($candidateClass) - && singleton($candidateClass)->hasOwnTableDatabaseField($this->name)) { + if( DataObject::has_own_table($candidateClass) + && DataObject::has_own_table_database_field($candidateClass, $this->name) + ) { break; } diff --git a/search/filters/StartsWithFilter.php b/search/filters/StartsWithFilter.php index cdf445139..b77ab48c0 100644 --- a/search/filters/StartsWithFilter.php +++ b/search/filters/StartsWithFilter.php @@ -16,100 +16,9 @@ * @package framework * @subpackage search */ -class StartsWithFilter extends SearchFilter { +class StartsWithFilter extends PartialMatchFilter { - public function setModifiers(array $modifiers) { - if(($extras = array_diff($modifiers, array('not', 'nocase', 'case'))) != array()) { - throw new InvalidArgumentException( - get_class($this) . ' does not accept ' . implode(', ', $extras) . ' as modifiers'); - } - - parent::setModifiers($modifiers); - } - - /** - * Applies a match on the starting characters of a field value. - * - * @return DataQuery - */ - protected function applyOne(DataQuery $query) { - $this->model = $query->applyRelation($this->relation); - $modifiers = $this->getModifiers(); - $where = DB::getConn()->comparisonClause( - $this->getDbName(), - Convert::raw2sql($this->getValue()) . '%', - false, // exact? - false, // negate? - $this->getCaseSensitive() - ); - return $query->where($where); - } - - /** - * Applies a match on the starting characters of a field value. - * Matches against one of the many values. - * - * @return DataQuery - */ - protected function applyMany(DataQuery $query) { - $this->model = $query->applyRelation($this->relation); - $modifiers = $this->getModifiers(); - $connectives = array(); - foreach($this->getValue() as $value) { - $connectives[] = DB::getConn()->comparisonClause( - $this->getDbName(), - Convert::raw2sql($value) . '%', - false, // exact? - false, // negate? - $this->getCaseSensitive() - ); - } - $whereClause = implode(' OR ', $connectives); - return $query->where($whereClause); - } - - /** - * Excludes a match on the starting characters of a field value. - * - * @return DataQuery - */ - protected function excludeOne(DataQuery $query) { - $this->model = $query->applyRelation($this->relation); - $modifiers = $this->getModifiers(); - $where = DB::getConn()->comparisonClause( - $this->getDbName(), - Convert::raw2sql($this->getValue()) . '%', - false, // exact? - true, // negate? - $this->getCaseSensitive() - ); - return $query->where($where); - } - - /** - * Excludes a match on the starting characters of a field value. - * Excludes a field if it matches any of the values. - * - * @return DataQuery - */ - protected function excludeMany(DataQuery $query) { - $this->model = $query->applyRelation($this->relation); - $modifiers = $this->getModifiers(); - $connectives = array(); - foreach($this->getValue() as $value) { - $connectives[] = DB::getConn()->comparisonClause( - $this->getDbName(), - Convert::raw2sql($value) . '%', - false, // exact? - true, // negate? - $this->getCaseSensitive() - ); - } - $whereClause = implode(' AND ', $connectives); - return $query->where($whereClause); - } - - public function isEmpty() { - return $this->getValue() === array() || $this->getValue() === null || $this->getValue() === ''; + protected function getMatchPattern($value) { + return "$value%"; } } diff --git a/search/filters/WithinRangeFilter.php b/search/filters/WithinRangeFilter.php index ca0bb10d4..2a33e64f4 100644 --- a/search/filters/WithinRangeFilter.php +++ b/search/filters/WithinRangeFilter.php @@ -27,23 +27,23 @@ class WithinRangeFilter extends SearchFilter { protected function applyOne(DataQuery $query) { $this->model = $query->applyRelation($this->relation); - return $query->where(sprintf( - "%s >= '%s' AND %s <= '%s'", - $this->getDbName(), - Convert::raw2sql($this->min), - $this->getDbName(), - Convert::raw2sql($this->max) + $predicate = sprintf('%1$s >= ? AND %1$s <= ?', $this->getDbName()); + return $query->where(array( + $predicate => array( + $this->min, + $this->max + ) )); } protected function excludeOne(DataQuery $query) { $this->model = $query->applyRelation($this->relation); - return $query->where(sprintf( - "%s < '%s' OR %s > '%s'", - $this->getDbName(), - Convert::raw2sql($this->min), - $this->getDbName(), - Convert::raw2sql($this->max) + $predicate = sprintf('%1$s < ? OR %1$s > ?', $this->getDbName()); + return $query->where(array( + $predicate => array( + $this->min, + $this->max + ) )); } } diff --git a/security/Group.php b/security/Group.php index eba1e1dbb..157000628 100755 --- a/security/Group.php +++ b/security/Group.php @@ -57,11 +57,10 @@ class Group extends DataObject { public function getAllChildren() { $doSet = new ArrayList(); - if ($children = DataObject::get('Group', '"ParentID" = '.$this->ID)) { - foreach($children as $child) { - $doSet->push($child); - $doSet->merge($child->getAllChildren()); - } + $children = DataObject::get('Group')->filter("ParentID", $this->ID); + foreach($children as $child) { + $doSet->push($child); + $doSet->merge($child->getAllChildren()); } return $doSet; @@ -183,9 +182,10 @@ class Group extends DataObject { ); // Add roles (and disable all checkboxes for inherited roles) - $allRoles = Permission::check('ADMIN') - ? DataObject::get('PermissionRole') - : DataObject::get('PermissionRole', 'OnlyAdminCanApply = 0'); + $allRoles = PermissionRole::get(); + if(Permission::check('ADMIN')) { + $allRoles = $allRoles->filter("OnlyAdminCanApply", 0); + } if($this->ID) { $groupRoles = $this->Roles(); $inheritedRoles = new ArrayList(); @@ -298,11 +298,10 @@ class Group extends DataObject { while($chunkToAdd) { $familyIDs = array_merge($familyIDs,$chunkToAdd); - $idList = implode(',', $chunkToAdd); // Get the children of *all* the groups identified in the previous chunk. - // This minimises the number of SQL queries necessary - $chunkToAdd = Group::get()->where("\"ParentID\" IN ($idList)")->column('ID'); + // This minimises the number of SQL queries necessary + $chunkToAdd = Group::get()->filter("ParentID", $chunkToAdd)->column('ID'); } return $familyIDs; @@ -331,11 +330,10 @@ class Group extends DataObject { * Override this so groups are ordered in the CMS */ public function stageChildren() { - return DataObject::get( - 'Group', - "\"Group\".\"ParentID\" = " . (int)$this->ID . " AND \"Group\".\"ID\" != " . (int)$this->ID, - '"Sort"' - ); + return Group::get() + ->filter("ParentID", $this->ID) + ->exclude("ID", $this->ID) + ->sort('"Sort"'); } public function getTreeTitle() { diff --git a/security/GroupCsvBulkLoader.php b/security/GroupCsvBulkLoader.php index c83b313a9..9f80e96b5 100644 --- a/security/GroupCsvBulkLoader.php +++ b/security/GroupCsvBulkLoader.php @@ -28,10 +28,9 @@ class GroupCsvBulkLoader extends CsvBulkLoader { // are imported to avoid missing "early" references to parents // which are imported later on in the CSV file. if(isset($record['ParentCode']) && $record['ParentCode']) { - $parentGroup = DataObject::get_one( - 'Group', - sprintf('"Code" = \'%s\'', Convert::raw2sql($record['ParentCode'])) - ); + $parentGroup = DataObject::get_one('Group', array( + '"Group"."Code"' => $record['ParentCode'] + )); if($parentGroup) { $group->ParentID = $parentGroup->ID; $group->write(); @@ -42,14 +41,10 @@ class GroupCsvBulkLoader extends CsvBulkLoader { // existing permissions arent cleared. if(isset($record['PermissionCodes']) && $record['PermissionCodes']) { foreach(explode(',', $record['PermissionCodes']) as $code) { - $p = DataObject::get_one( - 'Permission', - sprintf( - '"Code" = \'%s\' AND "GroupID" = %d', - Convert::raw2sql($code), - $group->ID - ) - ); + $p = DataObject::get_one('Permission', array( + '"Permission"."Code"' => $code, + '"Permission"."GroupID"' => $group->ID + )); if(!$p) { $p = new Permission(array('Code' => $code)); $p->write(); diff --git a/security/Member.php b/security/Member.php index 8210167af..9de869f1f 100644 --- a/security/Member.php +++ b/security/Member.php @@ -404,7 +404,7 @@ class Member extends DataObject implements TemplateGlobalProvider { } // Don't set column if its not built yet (the login might be precursor to a /dev/build...) - if(array_key_exists('LockedOutUntil', DB::fieldList('Member'))) { + if(array_key_exists('LockedOutUntil', DB::field_list('Member'))) { $this->LockedOutUntil = null; } @@ -443,9 +443,8 @@ class Member extends DataObject implements TemplateGlobalProvider { if(strpos(Cookie::get('alc_enc'), ':') && !Session::get("loggedInAs")) { list($uid, $token) = explode(':', Cookie::get('alc_enc'), 2); - $SQL_uid = Convert::raw2sql($uid); - - $member = DataObject::get_one("Member", "\"Member\".\"ID\" = '$SQL_uid'"); + + $member = DataObject::get_by_id("Member", $uid); // check if autologin token matches if($member) { @@ -536,7 +535,9 @@ class Member extends DataObject implements TemplateGlobalProvider { $generator = new RandomGenerator(); $token = $generator->randomToken(); $hash = $this->encryptWithUserSettings($token); - } while(DataObject::get_one('Member', "\"AutoLoginHash\" = '$hash'")); + } while(DataObject::get_one('Member', array( + '"Member"."AutoLoginHash"' => $hash + ))); $this->AutoLoginHash = $hash; $this->AutoLoginExpired = date('Y-m-d', time() + (86400 * $lifetime)); @@ -555,30 +556,27 @@ class Member extends DataObject implements TemplateGlobalProvider { */ public function validateAutoLoginToken($autologinToken) { $hash = $this->encryptWithUserSettings($autologinToken); - - $member = DataObject::get_one( - 'Member', - "\"AutoLoginHash\"='" . $hash . "' AND \"AutoLoginExpired\" > " . DB::getConn()->now() - ); - + $member = self::member_from_autologinhash($hash, false); return (bool)$member; } /** * Return the member for the auto login hash * + * @param string $hash The hash key * @param bool $login Should the member be logged in? + * + * @return Member the matching member, if valid */ - public static function member_from_autologinhash($RAW_hash, $login = false) { - $SQL_hash = Convert::raw2sql($RAW_hash); + public static function member_from_autologinhash($hash, $login = false) { + + $nowExpression = DB::get_conn()->now(); + $member = DataObject::get_one('Member', array( + "\"Member\".\"AutoLoginHash\"" => $hash, + "\"Member\".\"AutoLoginExpired\" > $nowExpression" // NOW() can't be parameterised + )); - $member = DataObject::get_one( - 'Member', - "\"AutoLoginHash\"='" . $SQL_hash . "' AND \"AutoLoginExpired\" > " . DB::getConn()->now() - ); - - if($login && $member) - $member->logIn(); + if($login && $member) $member->logIn(); return $member; } @@ -706,17 +704,14 @@ class Member extends DataObject implements TemplateGlobalProvider { // but rather a last line of defense against data inconsistencies. $identifierField = Member::config()->unique_identifier_field; if($this->$identifierField) { + // Note: Same logic as Member_Validator class - $idClause = ($this->ID) ? sprintf(" AND \"Member\".\"ID\" <> %d", (int)$this->ID) : ''; - $existingRecord = DataObject::get_one( - 'Member', - sprintf( - "\"%s\" = '%s' %s", - $identifierField, - Convert::raw2sql($this->$identifierField), - $idClause - ) - ); + $filter = array("\"$identifierField\"" => $this->$identifierField); + if($this->ID) { + $filter[] = array('"Member"."ID" <> ?' => $this->ID); + } + $existingRecord = DataObject::get_one('Member', $filter); + if($existingRecord) { throw new ValidationException(ValidationResult::create(false, _t( 'Member.ValidationIdentifierFailed', @@ -836,8 +831,9 @@ class Member extends DataObject implements TemplateGlobalProvider { if(is_numeric($group)) { $groupCheckObj = DataObject::get_by_id('Group', $group); } elseif(is_string($group)) { - $SQL_group = Convert::raw2sql($group); - $groupCheckObj = DataObject::get_one('Group', "\"Code\" = '{$SQL_group}'"); + $groupCheckObj = DataObject::get_one('Group', array( + '"Group"."Code"' => $group + )); } elseif($group instanceof Group) { $groupCheckObj = $group; } else { @@ -862,12 +858,13 @@ class Member extends DataObject implements TemplateGlobalProvider { * @param string Title of the group */ public function addToGroupByCode($groupcode, $title = "") { - $group = DataObject::get_one('Group', "\"Code\" = '" . Convert::raw2sql($groupcode). "'"); + $group = DataObject::get_one('Group', array( + '"Group"."Code"' => $groupcode + )); if($group) { $this->Groups()->add($group); - } - else { + } else { if(!$title) $title = $groupcode; $group = new Group(); @@ -946,7 +943,7 @@ class Member extends DataObject implements TemplateGlobalProvider { */ public static function get_title_sql($tableName = 'Member') { // This should be abstracted to SSDatabase concatOperator or similar. - $op = (DB::getConn() instanceof MSSQLDatabase) ? " + " : " || "; + $op = (DB::get_conn() instanceof MSSQLDatabase) ? " + " : " || "; $format = self::config()->title_format; if ($format) { @@ -1098,7 +1095,7 @@ class Member extends DataObject implements TemplateGlobalProvider { * * @param array $groups Groups to consider or NULL to use all groups with * CMS permissions. - * @return SQLMap Returns a map of all members in the groups given that + * @return SS_Map Returns a map of all members in the groups given that * have CMS permissions. */ public static function mapInCMSGroups($groups = null) { @@ -1114,14 +1111,13 @@ class Member extends DataObject implements TemplateGlobalProvider { if(!empty($cmsPerms)) { $perms = array_unique(array_merge($perms, array_keys($cmsPerms))); } - - $SQL_perms = "'" . implode("', '", Convert::raw2sql($perms)) . "'"; - + + $permsClause = DB::placeholders($perms); $groups = DataObject::get('Group') - ->innerJoin( - "Permission", - "\"Permission\".\"GroupID\" = \"Group\".\"ID\" AND \"Permission\".\"Code\" IN ($SQL_perms)" - ); + ->innerJoin("Permission", '"Permission"."GroupID" = "Group"."ID"') + ->where(array( + "\"Permission\".\"Code\" IN ($permsClause)" => $perms + )); } $groupIDList = array(); @@ -1134,14 +1130,17 @@ class Member extends DataObject implements TemplateGlobalProvider { $groupIDList = $groups; } - $filterClause = ($groupIDList) - ? "\"GroupID\" IN (" . implode( ',', $groupIDList ) . ")" - : ""; + $members = Member::get() + ->innerJoin("Group_Members", '"Group_Members"."MemberID" = "Member"."ID"') + ->innerJoin("Group", '"Group"."ID" = "Group_Members"."GroupID"'); + if($groupIDList) { + $groupClause = DB::placeholders($groupIDList); + $members = $members->where(array( + "\"Group\".\"ID\" IN ($groupClause)" => $groupIDList + )); + } - return Member::get()->where($filterClause)->sort("\"Surname\", \"FirstName\"") - ->innerJoin("Group_Members", "\"MemberID\"=\"Member\".\"ID\"") - ->innerJoin("Group", "\"Group\".\"ID\"=\"GroupID\"") - ->map(); + return $members->sort('"Member"."Surname", "Member"."FirstName"')->map(); } @@ -1497,7 +1496,9 @@ class Member extends DataObject implements TemplateGlobalProvider { * @subpackage security */ class Member_GroupSet extends ManyManyList { + public function __construct($dataClass, $joinTable, $localKey, $foreignKey, $extraFields = array()) { + // Bypass the many-many constructor DataList::__construct($dataClass); @@ -1509,15 +1510,23 @@ class Member_GroupSet extends ManyManyList { /** * Link this group set to a specific member. + * + * Recursively selects all groups applied to this member, as well as any + * parent groups of any applied groups + * + * @param array|integer $id (optional) An ID or an array of IDs - if not provided, will use the current + * ids as per getForeignID + * @return array Condition In array(SQL => parameters format) */ public function foreignIDFilter($id = null) { if ($id === null) $id = $this->getForeignID(); // Find directly applied groups $manyManyFilter = parent::foreignIDFilter($id); - $groupIDs = DB::query('SELECT "GroupID" FROM "Group_Members" WHERE ' . $manyManyFilter)->column(); + $query = new SQLSelect('"Group_Members"."GroupID"', '"Group_Members"', $manyManyFilter); + $groupIDs = $query->execute()->column(); - // Get all ancestors + // Get all ancestors, iteratively merging these into the master set $allGroupIDs = array(); while($groupIDs) { $allGroupIDs = array_merge($allGroupIDs, $groupIDs); @@ -1526,15 +1535,17 @@ class Member_GroupSet extends ManyManyList { } // Add a filter to this DataList - if($allGroupIDs) { - return "\"Group\".\"ID\" IN (" . implode(',', $allGroupIDs) .")"; - } - else { - return "\"Group\".\"ID\" = 0"; + if(!empty($allGroupIDs)) { + $allGroupIDsPlaceholders = DB::placeholders($allGroupIDs); + return array("\"Group\".\"ID\" IN ($allGroupIDsPlaceholders)" => $allGroupIDs); + } else { + return array('"Group"."ID"' => 0); } } - + public function foreignIDWriteFilter($id = null) { + // Use the ManyManyList::foreignIDFilter rather than the one + // in this class, otherwise we end up selecting all inherited groups return parent::foreignIDFilter($id); } } @@ -1628,8 +1639,9 @@ class Member_Validator extends RequiredFields { $valid = parent::php($data); $identifierField = Member::config()->unique_identifier_field; - $SQL_identifierField = Convert::raw2sql($data[$identifierField]); - $member = DataObject::get_one('Member', "\"$identifierField\" = '{$SQL_identifierField}'"); + $member = DataObject::get_one('Member', array( + "\"$identifierField\"" => $data[$identifierField] + )); // if we are in a complex table field popup, use ctf[childID], else use ID if(isset($_REQUEST['ctf']['childID'])) { diff --git a/security/MemberAuthenticator.php b/security/MemberAuthenticator.php index 0aa35c996..d80f136ab 100644 --- a/security/MemberAuthenticator.php +++ b/security/MemberAuthenticator.php @@ -9,9 +9,11 @@ class MemberAuthenticator extends Authenticator { /** - * @var Array Contains encryption algorithm identifiers. - * If set, will migrate to new precision-safe password hashing - * upon login. See http://open.silverstripe.org/ticket/3004 + * Contains encryption algorithm identifiers. + * If set, will migrate to new precision-safe password hashing + * upon login. See http://open.silverstripe.org/ticket/3004 + * + * @var array */ private static $migrate_legacy_hashes = array( 'md5' => 'md5_v2.4', @@ -29,24 +31,26 @@ class MemberAuthenticator extends Authenticator { * the member object * @see Security::setDefaultAdmin() */ - public static function authenticate($RAW_data, Form $form = null) { - if(array_key_exists('Email', $RAW_data) && $RAW_data['Email']){ - $SQL_user = Convert::raw2sql($RAW_data['Email']); - } else { + public static function authenticate($RAW_data, Form $form = null) { + // Check for email + if(empty($RAW_data['Email'])) return false; + + $userEmail = $RAW_data['Email']; + if(is_array($userEmail)) { + user_error("Bad email passed to MemberAuthenticator::authenticate()", E_USER_WARNING); return false; } - $isLockedOut = false; $result = null; // Default login (see Security::setDefaultAdmin()) - if(Security::check_default_admin($RAW_data['Email'], $RAW_data['Password'])) { + if(Security::check_default_admin($userEmail, $RAW_data['Password'])) { $member = Security::findAnAdministrator(); } else { - $member = DataObject::get_one( - "Member", - "\"" . Member::config()->unique_identifier_field . "\" = '$SQL_user' AND \"Password\" IS NOT NULL" - ); + $member = Member::get() + ->filter(Member::config()->unique_identifier_field, $userEmail) + ->where('"Member"."Password" IS NOT NULL') + ->first(); if($member) { $result = $member->checkPassword($RAW_data['Password']); @@ -75,10 +79,9 @@ class MemberAuthenticator extends Authenticator { $member->extend('authenticated'); } else { // failed login - we're trying to see if a user exists with this email (disregarding wrong passwords) - $existingMember = DataObject::get_one( - "Member", - "\"" . Member::config()->unique_identifier_field . "\" = '$SQL_user'" - ); + $existingMember = DataObject::get_one("Member", array( + '"'.Member::config()->unique_identifier_field.'"' => $userEmail + )); if($existingMember) { $attempt->MemberID = $existingMember->ID; @@ -91,12 +94,8 @@ class MemberAuthenticator extends Authenticator { } $attempt->Status = 'Failure'; } - if(is_array($RAW_data['Email'])) { - user_error("Bad email passed to MemberAuthenticator::authenticate(): $RAW_data[Email]", E_USER_WARNING); - return false; - } - $attempt->Email = $RAW_data['Email']; + $attempt->Email = $userEmail; $attempt->IP = Controller::curr()->getRequest()->getIP(); $attempt->write(); } diff --git a/security/MemberCsvBulkLoader.php b/security/MemberCsvBulkLoader.php index 9ee8e98a1..f92480d84 100644 --- a/security/MemberCsvBulkLoader.php +++ b/security/MemberCsvBulkLoader.php @@ -41,10 +41,7 @@ class MemberCsvBulkLoader extends CsvBulkLoader { $groupCodes = explode(',', $record['Groups']); foreach($groupCodes as $groupCode) { if(!isset($_cache_groupByCode[$groupCode])) { - $group = DataObject::get_one( - 'Group', - sprintf('"Code" = \'%s\'', Convert::raw2sql($groupCode)) - ); + $group = Group::get()->filter('Code', $groupCode)->first(); if(!$group) { $group = new Group(); $group->Code = $groupCode; diff --git a/security/MemberLoginForm.php b/security/MemberLoginForm.php index 162bd5857..0974e0cf3 100644 --- a/security/MemberLoginForm.php +++ b/security/MemberLoginForm.php @@ -287,16 +287,26 @@ JS; * @param array $data Submitted data */ public function forgotPassword($data) { - $SQL_data = Convert::raw2sql($data); - $SQL_email = $SQL_data['Email']; - $member = DataObject::get_one('Member', "\"Email\" = '{$SQL_email}'"); + // Ensure password is given + if(empty($data['Email'])) { + $this->sessionMessage( + _t('Member.ENTEREMAIL', 'Please enter an email address to get a password reset link.'), + 'bad' + ); + + $this->controller->redirect('Security/lostpassword'); + return; + } + + // Find existing member + $member = Member::get()->filter("Email", $data['Email'])->first(); // Allow vetoing forgot password requests $results = $this->extend('forgotPassword', $member); if($results && is_array($results) && in_array(false, $results, true)) { return $this->controller->redirect('Security/lostpassword'); } - + if($member) { $token = $member->generateAutologinTokenAndStoreHash(); @@ -310,8 +320,8 @@ JS; $this->controller->redirect('Security/passwordsent/' . urlencode($data['Email'])); } elseif($data['Email']) { - // Avoid information disclosure by displaying the same status, - // regardless wether the email address actually exists + // Avoid information disclosure by displaying the same status, + // regardless wether the email address actually exists $this->controller->redirect('Security/passwordsent/' . rawurlencode($data['Email'])); } else { $this->sessionMessage( @@ -320,7 +330,8 @@ JS; ); $this->controller->redirect('Security/lostpassword'); - } + } } } + diff --git a/security/PasswordEncryptor.php b/security/PasswordEncryptor.php index ed5742711..ae30a3fb4 100644 --- a/security/PasswordEncryptor.php +++ b/security/PasswordEncryptor.php @@ -333,9 +333,7 @@ class PasswordEncryptor_LegacyPHPHash extends PasswordEncryptor_PHPHash { */ class PasswordEncryptor_MySQLPassword extends PasswordEncryptor { public function encrypt($password, $salt = null, $member = null) { - return DB::query( - sprintf("SELECT PASSWORD('%s')", Convert::raw2sql($password)) - )->value(); + return DB::prepared_query("SELECT PASSWORD(?)", array($password))->value(); } public function salt($password, $member = null) { @@ -351,9 +349,7 @@ class PasswordEncryptor_MySQLPassword extends PasswordEncryptor { */ class PasswordEncryptor_MySQLOldPassword extends PasswordEncryptor { public function encrypt($password, $salt = null, $member = null) { - return DB::query( - sprintf("SELECT OLD_PASSWORD('%s')", Convert::raw2sql($password)) - )->value(); + return DB::prepared_query("SELECT OLD_PASSWORD(?)", array($password))->value(); } public function salt($password, $member = null) { diff --git a/security/PasswordValidator.php b/security/PasswordValidator.php index 4cbd9da98..311cc4ed9 100644 --- a/security/PasswordValidator.php +++ b/security/PasswordValidator.php @@ -109,13 +109,10 @@ class PasswordValidator extends Object { } if($this->historicalPasswordCount) { - $previousPasswords = DataObject::get( - "MemberPassword", - "\"MemberID\" = $member->ID", - "\"Created\" DESC, \"ID\" DESC", - "", - $this->historicalPasswordCount - ); + $previousPasswords = MemberPassword::get() + ->where(array('"MemberPassword"."MemberID"' => $member->ID)) + ->sort('"Created" DESC, "ID" DESC') + ->limit($this->historicalPasswordCount); if($previousPasswords) foreach($previousPasswords as $previousPasswords) { if($previousPasswords->checkPassword($password)) { $valid->error( diff --git a/security/Permission.php b/security/Permission.php index deb79111d..47de216d9 100644 --- a/security/Permission.php +++ b/security/Permission.php @@ -177,65 +177,71 @@ class Permission extends DataObject implements TemplateGlobalProvider { // Multiple $code values - return true if at least one matches, ie, intersection exists return (bool)array_intersect($code, self::$cache_permissions[$memberID]); } + + // Code filters + $codeParams = is_array($code) ? $code : array($code); + $codeClause = DB::placeholders($codes); + $adminParams = (self::$admin_implies_all) ? array('ADMIN') : array(); + $adminClause = (self::$admin_implies_all) ? ", ?" : ''; // The following code should only be used if you're not using the "any" arg. This is kind // of obselete functionality and could possibly be deprecated. - - $groupList = self::groupList($memberID); - if(!$groupList) return false; - - $groupCSV = implode(", ", $groupList); + $groupParams = self::groupList($memberID); + if(empty($groupParams)) return false; + $groupClause = DB::placeholders($groupParams); // Arg component + $argClause = ""; + $argParams = array(); switch($arg) { case "any": - $argClause = ""; break; case "all": - $argClause = " AND \"Arg\" = -1"; + $argClause = " AND \"Arg\" = ?"; + $argParams = array(-1); break; default: if(is_numeric($arg)) { - $argClause = "AND \"Arg\" IN (-1, $arg) "; + $argClause = "AND \"Arg\" IN (?, ?) "; + $argParams = array(-1, $arg); } else { user_error("Permission::checkMember: bad arg '$arg'", E_USER_ERROR); } } - - if(is_array($code)) { - $SQL_codeList = "'" . implode("', '", Convert::raw2sql($code)) . "'"; - } else { - $SQL_codeList = "'" . Convert::raw2sql($code) . "'"; - } - - $SQL_code = Convert::raw2sql($code); - $adminFilter = (Config::inst()->get('Permission', 'admin_implies_all')) ? ",'ADMIN'" : ''; // Raw SQL for efficiency - $permission = DB::query(" - SELECT \"ID\" + $permission = DB::prepared_query( + "SELECT \"ID\" FROM \"Permission\" WHERE ( - \"Code\" IN ($SQL_codeList $adminFilter) - AND \"Type\" = " . self::GRANT_PERMISSION . " - AND \"GroupID\" IN ($groupCSV) + \"Code\" IN ($codeClause $adminClause) + AND \"Type\" = ? + AND \"GroupID\" IN ($groupClause) $argClause + )", + array_merge( + $codeParams, + $adminParams, + array(self::GRANT_PERMISSION), + $groupParams, + $argParams ) - ")->value(); + )->value(); if($permission) return $permission; // Strict checking disabled? if(!Config::inst()->get('Permission', 'strict_checking') || !$strict) { - $hasPermission = DB::query(" - SELECT COUNT(*) + $hasPermission = DB::prepared_query( + "SELECT COUNT(*) FROM \"Permission\" WHERE ( - (\"Code\" IN '$SQL_code')' - AND (\"Type\" = " . self::GRANT_PERMISSION . ") - ) - ")->value(); + \"Code\" IN ($codeClause) AND + \"Type\" = ? + )", + array_merge($codeParams, array(self::GRANT_PERMISSION)) + )->value(); if(!$hasPermission) return; } @@ -412,11 +418,13 @@ class Permission extends DataObject implements TemplateGlobalProvider { } } - if(!count($groupIDs)) return new ArrayList(); + if(empty($groupIDs)) return new ArrayList(); - $members = DataObject::get('Member')->where("\"Group\".\"ID\" IN (" . implode(",",$groupIDs) . ")") - ->leftJoin("Group_Members", "\"Member\".\"ID\" = \"Group_Members\".\"MemberID\"") - ->leftJoin("Group", "\"Group_Members\".\"GroupID\" = \"Group\".\"ID\""); + $groupClause = DB::placeholders($groupIDs); + $members = Member::get() + ->where(array("\"Group\".\"ID\" IN ($groupClause)" => $groupIDs)) + ->leftJoin("Group_Members", '"Member"."ID" = "Group_Members"."MemberID"') + ->leftJoin("Group", '"Group_Members"."GroupID" = "Group"."ID"'); return $members; } @@ -427,14 +435,15 @@ class Permission extends DataObject implements TemplateGlobalProvider { * @return SS_List The matching group objects */ public static function get_groups_by_permission($codes) { - if(!is_array($codes)) $codes = array($codes); - - $SQLa_codes = Convert::raw2sql($codes); - $SQL_codes = join("','", $SQLa_codes); + $codeParams = is_array($codes) ? $codes : array($codes); + $codeClause = DB::placeholders($codeParams); // Via Roles are groups that have the permission via a role return DataObject::get('Group') - ->where("\"PermissionRoleCode\".\"Code\" IN ('$SQL_codes') OR \"Permission\".\"Code\" IN ('$SQL_codes')") + ->where(array( + "\"PermissionRoleCode\".\"Code\" IN ($codeClause) OR \"Permission\".\"Code\" IN ($codeClause)" + => array_merge($codeParams, $codeParams) + )) ->leftJoin('Permission', "\"Permission\".\"GroupID\" = \"Group\".\"ID\"") ->leftJoin('Group_Roles', "\"Group_Roles\".\"GroupID\" = \"Group\".\"ID\"") ->leftJoin('PermissionRole', "\"Group_Roles\".\"PermissionRoleID\" = \"PermissionRole\".\"ID\"") diff --git a/security/Security.php b/security/Security.php index 711c7d91c..daf152b59 100644 --- a/security/Security.php +++ b/security/Security.php @@ -755,9 +755,9 @@ class Security extends Controller implements TemplateGlobalProvider { // find a group with ADMIN permission $adminGroup = DataObject::get('Group') - ->where("\"Permission\".\"Code\" = 'ADMIN'") - ->sort("\"Group\".\"ID\"") - ->innerJoin("Permission", "\"Group\".\"ID\"=\"Permission\".\"GroupID\"") + ->where(array('"Permission"."Code"' => 'ADMIN')) + ->sort('"Group"."ID"') + ->innerJoin("Permission", '"Group"."ID" = "Permission"."GroupID"') ->First(); if(is_callable('Subsite::changeSubsite')) { @@ -945,10 +945,10 @@ class Security extends Controller implements TemplateGlobalProvider { singleton($table); // if any of the tables don't have all fields mapped as table columns - $dbFields = DB::fieldList($table); + $dbFields = DB::field_list($table); if(!$dbFields) return false; - $objFields = DataObject::database_fields($table); + $objFields = DataObject::database_fields($table, false); $missingFields = array_diff_key($objFields, $dbFields); if($missingFields) return false; diff --git a/tasks/EncryptAllPasswordsTask.php b/tasks/EncryptAllPasswordsTask.php index 09020e631..b02fdce65 100644 --- a/tasks/EncryptAllPasswordsTask.php +++ b/tasks/EncryptAllPasswordsTask.php @@ -35,10 +35,10 @@ class EncryptAllPasswordsTask extends BuildTask { } // Are there members with a clear text password? - $members = DataObject::get( - "Member", - "\"PasswordEncryption\" = 'none' AND \"Password\" IS NOT NULL" - ); + $members = DataObject::get("Member")->where(array( + '"Member"."PasswordEncryption"' => 'none', + '"Member"."Password" IS NOT NULL' + )); if(!$members) { $this->debugMessage('No passwords to encrypt'); diff --git a/tests/core/ConvertTest.php b/tests/core/ConvertTest.php index 24b6dd4b2..cad4e2ed4 100644 --- a/tests/core/ConvertTest.php +++ b/tests/core/ConvertTest.php @@ -155,8 +155,8 @@ class ConvertTest extends SapphireTest { /** * Helper function for comparing characters with significant whitespaces - * @param type $expected - * @param type $actual + * @param string $expected + * @param string $actual */ protected function assertEqualsQuoted($expected, $actual) { $message = sprintf( diff --git a/tests/dev/CsvBulkLoaderTest.php b/tests/dev/CsvBulkLoaderTest.php index a6f9fcbe9..233cc4a30 100644 --- a/tests/dev/CsvBulkLoaderTest.php +++ b/tests/dev/CsvBulkLoaderTest.php @@ -30,7 +30,9 @@ class CsvBulkLoaderTest extends SapphireTest { $this->assertEquals(4, $results->Count(), 'Test correct count of imported data'); // Test that columns were correctly imported - $obj = DataObject::get_one("CsvBulkLoaderTest_Player", "\"FirstName\" = 'John'"); + $obj = DataObject::get_one("CsvBulkLoaderTest_Player", array( + '"CsvBulkLoaderTest_Player"."FirstName"' => 'John' + )); $this->assertNotNull($obj); $this->assertEquals("He's a good guy", $obj->Biography); $this->assertEquals("1988-01-31", $obj->Birthday); @@ -81,13 +83,17 @@ class CsvBulkLoaderTest extends SapphireTest { $this->assertEquals(4, $results->Count(), 'Test correct count of imported data'); // Test that columns were correctly imported - $obj = DataObject::get_one("CsvBulkLoaderTest_Player", "\"FirstName\" = 'John'"); + $obj = DataObject::get_one("CsvBulkLoaderTest_Player", array( + '"CsvBulkLoaderTest_Player"."FirstName"' => 'John' + )); $this->assertNotNull($obj); $this->assertEquals("He's a good guy", $obj->Biography); $this->assertEquals("1988-01-31", $obj->Birthday); $this->assertEquals("1", $obj->IsRegistered); - $obj2 = DataObject::get_one('CsvBulkLoaderTest_Player', "\"FirstName\" = 'Jane'"); + $obj2 = DataObject::get_one("CsvBulkLoaderTest_Player", array( + '"CsvBulkLoaderTest_Player"."FirstName"' => 'Jane' + )); $this->assertNotNull($obj2); $this->assertEquals('0', $obj2->IsRegistered); @@ -131,7 +137,9 @@ class CsvBulkLoaderTest extends SapphireTest { // Test of creating relation $testContract = DataObject::get_one('CsvBulkLoaderTest_PlayerContract'); - $testPlayer = Dataobject::get_one("CsvBulkLoaderTest_Player", "\"FirstName\" = 'John'"); + $testPlayer = DataObject::get_one("CsvBulkLoaderTest_Player", array( + '"CsvBulkLoaderTest_Player"."FirstName"' => 'John' + )); $this->assertEquals($testPlayer->ContractID, $testContract->ID, 'Creating new has_one relation works'); // Test nested setting of relation properties @@ -262,8 +270,9 @@ class CsvBulkLoaderTest_Player extends DataObject implements TestOnly { ); public function getTeamByTitle($title) { - $SQL_title = Convert::raw2sql($title); - return DataObject::get_one('CsvBulkLoaderTest_Team', "\"Title\" = '{$SQL_title}'"); + return DataObject::get_one("CsvBulkLoaderTest_Team", array( + '"CsvBulkLoaderTest_Team"."Title"' => $title + )); } /** diff --git a/tests/dev/MySQLDatabaseConfigurationHelperTest.php b/tests/dev/MySQLDatabaseConfigurationHelperTest.php new file mode 100644 index 000000000..b6882bad5 --- /dev/null +++ b/tests/dev/MySQLDatabaseConfigurationHelperTest.php @@ -0,0 +1,134 @@ +assertEmpty($helper->checkValidDatabaseName('database%name')); + $this->assertEmpty($helper->checkValidDatabaseName('database?name')); + $this->assertEmpty($helper->checkValidDatabaseName('database|name')); + $this->assertEmpty($helper->checkValidDatabaseName('databaseassertEmpty($helper->checkValidDatabaseName('database"name')); + + // Reject additional characters + $this->assertEmpty($helper->checkValidDatabaseName('database.name')); + $this->assertEmpty($helper->checkValidDatabaseName('database\name')); + $this->assertEmpty($helper->checkValidDatabaseName('database/name')); + + // Reject blank + $this->assertEmpty($helper->checkValidDatabaseName("")); + } + + /** + * Tests that valid names are allowed + */ + public function testValidDatabaseNames() { + $helper = new MySQLDatabaseConfigurationHelper(); + + // Names with spaces + $this->assertNotEmpty($helper->checkValidDatabaseName('database name')); + + // Basic latin characters + $this->assertNotEmpty($helper->checkValidDatabaseName('database_name')); + $this->assertNotEmpty($helper->checkValidDatabaseName('UPPERCASE_NAME')); + $this->assertNotEmpty($helper->checkValidDatabaseName('name_with_numbers_1234')); + + // Extended unicode names + $this->assertNotEmpty($helper->checkValidDatabaseName('亝亞亟')); // U+4E9D, U+4E9E, U+4E9F + $this->assertNotEmpty($helper->checkValidDatabaseName('おかが')); // U+304A, U+304B, U+304C + $this->assertNotEmpty($helper->checkValidDatabaseName('¶»Ã')); // U+00B6, U+00BB, U+00C3 + } + + public function testDatabaseCreateCheck() { + + $helper = new MySQLDatabaseConfigurationHelper(); + + // Accept all privileges + $this->assertNotEmpty($helper->checkDatabasePermissionGrant( + 'database_name', + 'create', + "GRANT ALL PRIVILEGES ON *.* TO 'root'@'localhost' IDENTIFIED BY PASSWORD 'XXXX' WITH GRANT OPTION" + )); + + // Accept create (mysql syntax) + $this->assertNotEmpty($helper->checkDatabasePermissionGrant( + 'database_name', + 'create', + "GRANT CREATE, SELECT ON *.* TO 'root'@'localhost' IDENTIFIED BY PASSWORD 'XXXX' WITH GRANT OPTION" + )); + + // Accept create on this database only + $this->assertNotEmpty($helper->checkDatabasePermissionGrant( + 'database_name', + 'create', + "GRANT ALL PRIVILEGES, CREATE ON \"database_name\".* TO 'root'@'localhost' IDENTIFIED BY PASSWORD 'XXXX'" + . " WITH GRANT OPTION" + )); + + // Accept create on any database (alternate wildcard syntax) + $this->assertNotEmpty($helper->checkDatabasePermissionGrant( + 'database_name', + 'create', + "GRANT CREATE ON \"%\".* TO 'root'@'localhost' IDENTIFIED BY PASSWORD 'XXXX' WITH GRANT OPTION" + )); + } + + public function testDatabaseCreateFail() { + + $helper = new MySQLDatabaseConfigurationHelper(); + + // Don't be fooled by create routine + $this->assertEmpty($helper->checkDatabasePermissionGrant( + 'database_name', + 'create', + "GRANT SELECT, CREATE ROUTINE ON *.* TO 'user'@'localhost' IDENTIFIED BY PASSWORD 'XXXX' WITH GRANT OPTION" + )); + + // Or create view + $this->assertEmpty($helper->checkDatabasePermissionGrant( + 'database_name', + 'create', + "GRANT CREATE VIEW, SELECT ON *.* TO 'user'@'localhost' IDENTIFIED BY PASSWORD 'XXXX' WITH GRANT OPTION" + )); + + // Don't accept permission if only given on a single subtable + $this->assertEmpty($helper->checkDatabasePermissionGrant( + 'database_name', + 'create', + "GRANT CREATE, SELECT ON *.\"onetable\" TO 'user'@'localhost' IDENTIFIED BY PASSWORD 'XXXX' " + . "WITH GRANT OPTION" + )); + + // Don't accept permission on wrong database + $this->assertEmpty($helper->checkDatabasePermissionGrant( + 'database_name', + 'create', + "GRANT ALL PRIVILEGES, CREATE ON \"wrongdb\".* TO 'user'@'localhost' IDENTIFIED BY PASSWORD 'XXXX' " + . "WITH GRANT OPTION" + )); + + // Don't accept wrong permission + $this->assertEmpty($helper->checkDatabasePermissionGrant( + 'database_name', + 'create', + "GRANT UPDATE ON \"%\".* TO 'user'@'localhost' IDENTIFIED BY PASSWORD 'XXXX' WITH GRANT OPTION" + )); + + // Don't accept sneaky table name + $this->assertEmpty($helper->checkDatabasePermissionGrant( + 'grant create on . to', + 'create', + "GRANT UPDATE ON \"grant create on . to\".* TO 'user'@'localhost' IDENTIFIED BY PASSWORD 'XXXX' WITH " + . "GRANT OPTION" + )); + } +} diff --git a/tests/filesystem/FolderTest.php b/tests/filesystem/FolderTest.php index 52dfc4121..ea4c7ee4d 100644 --- a/tests/filesystem/FolderTest.php +++ b/tests/filesystem/FolderTest.php @@ -39,7 +39,9 @@ class FolderTest extends SapphireTest { ); $this->assertTrue(file_exists(ASSETS_PATH . $path), 'File'); - $parentFolder = DataObject::get_one('Folder', '"Name" = \'FolderTest\''); + $parentFolder = DataObject::get_one('Folder', array( + '"File"."Name"' => 'FolderTest' + )); $this->assertNotNull($parentFolder); $this->assertEquals($parentFolder->ID, $folder->ParentID); @@ -149,7 +151,9 @@ class FolderTest extends SapphireTest { */ public function testRenameFolderAndCheckTheFile() { // ID is prefixed in case Folder is subclassed by project/other module. - $folder1 = DataObject::get_one('Folder', '"File"."ID"='.$this->idFromFixture('Folder', 'folder1')); + $folder1 = DataObject::get_one('Folder', array( + '"File"."ID"' => $this->idFromFixture('Folder', 'folder1') + )); $folder1->Name = 'FileTest-folder1-changed'; $folder1->write(); diff --git a/tests/filesystem/GDTest.php b/tests/filesystem/GDTest.php index 4f331915e..e34bf6478 100644 --- a/tests/filesystem/GDTest.php +++ b/tests/filesystem/GDTest.php @@ -25,7 +25,7 @@ class GDTest extends SapphireTest { $gds = array(); foreach(self::$filenames as $type => $file) { $fullPath = realpath(dirname(__FILE__) . '/gdtest/' . $file); - $gd = new GD($fullPath); + $gd = new GDBackend($fullPath); if($callback) { $gd = $callback($gd); } @@ -36,13 +36,13 @@ class GDTest extends SapphireTest { /** * Takes samples from the given GD at 5 pixel increments - * @param GD $gd The source image + * @param GDBackend $gd The source image * @param integer $horizontal Number of samples to take horizontally * @param integer $vertical Number of samples to take vertically * @return array List of colours for each sample, each given as an associative * array with red, blue, green, and alpha components */ - protected function sampleAreas(GD $gd, $horizontal = 4, $vertical = 4) { + protected function sampleAreas(GDBackend $gd, $horizontal = 4, $vertical = 4) { $samples = array(); for($y = 0; $y < $vertical; $y++) { for($x = 0; $x < $horizontal; $x++) { @@ -115,7 +115,7 @@ class GDTest extends SapphireTest { function testGreyscale() { // Apply greyscaling to each image - $images = $this->applyToEachImage(function(GD $gd) { + $images = $this->applyToEachImage(function(GDBackend $gd) { return $gd->greyscale(); }); diff --git a/tests/forms/CheckboxSetFieldTest.php b/tests/forms/CheckboxSetFieldTest.php index ac4bc22d6..454b398c5 100644 --- a/tests/forms/CheckboxSetFieldTest.php +++ b/tests/forms/CheckboxSetFieldTest.php @@ -59,10 +59,10 @@ class CheckboxSetFieldTest extends SapphireTest { $field->saveInto($article); $this->assertNull( - DB::query("SELECT * + DB::prepared_query("SELECT * FROM \"CheckboxSetFieldTest_Article_Tags\" - WHERE \"CheckboxSetFieldTest_Article_Tags\".\"CheckboxSetFieldTest_ArticleID\" = $article->ID - ")->value(), + WHERE \"CheckboxSetFieldTest_Article_Tags\".\"CheckboxSetFieldTest_ArticleID\" = ?", array($article->ID) + )->value(), 'Nothing should go into manymany join table for a saved field without any ticked boxes' ); } @@ -85,10 +85,10 @@ class CheckboxSetFieldTest extends SapphireTest { $this->assertEquals( array($tag1->ID,$tag2->ID), - DB::query("SELECT \"CheckboxSetFieldTest_TagID\" + DB::prepared_query("SELECT \"CheckboxSetFieldTest_TagID\" FROM \"CheckboxSetFieldTest_Article_Tags\" - WHERE \"CheckboxSetFieldTest_Article_Tags\".\"CheckboxSetFieldTest_ArticleID\" = $article->ID - ")->column(), + WHERE \"CheckboxSetFieldTest_Article_Tags\".\"CheckboxSetFieldTest_ArticleID\" = ?", array($article->ID) + )->column(), 'Data shold be saved into CheckboxSetField manymany relation table on the "right end"' ); $this->assertEquals( diff --git a/tests/forms/uploadfield/UploadFieldTest.php b/tests/forms/uploadfield/UploadFieldTest.php index 028201c5e..c0497ce60 100644 --- a/tests/forms/uploadfield/UploadFieldTest.php +++ b/tests/forms/uploadfield/UploadFieldTest.php @@ -26,7 +26,9 @@ class UploadFieldTest extends FunctionalTest { $response = $this->mockFileUpload('NoRelationField', $tmpFileName); $this->assertFalse($response->isError()); $this->assertFileExists(ASSETS_PATH . "/UploadFieldTest/$tmpFileName"); - $uploadedFile = DataObject::get_one('File', sprintf('"Name" = \'%s\'', $tmpFileName)); + $uploadedFile = DataObject::get_one('File', array( + '"File"."Name"' => $tmpFileName + )); $this->assertTrue(is_object($uploadedFile), 'The file object is created'); } @@ -46,7 +48,9 @@ class UploadFieldTest extends FunctionalTest { $response = $this->mockFileUpload('HasOneFile', $tmpFileName); $this->assertFalse($response->isError()); $this->assertFileExists(ASSETS_PATH . "/UploadFieldTest/$tmpFileName"); - $uploadedFile = DataObject::get_one('File', sprintf('"Name" = \'%s\'', $tmpFileName)); + $uploadedFile = DataObject::get_one('File', array( + '"File"."Name"' => $tmpFileName + )); $this->assertTrue(is_object($uploadedFile), 'The file object is created'); // Secondly, ensure that simply uploading an object does not save the file against the relation @@ -77,7 +81,9 @@ class UploadFieldTest extends FunctionalTest { $response = $this->mockFileUpload('HasOneExtendedFile', $tmpFileName); $this->assertFalse($response->isError()); $this->assertFileExists(ASSETS_PATH . "/UploadFieldTest/$tmpFileName"); - $uploadedFile = DataObject::get_one('UploadFieldTest_ExtendedFile', sprintf('"Name" = \'%s\'', $tmpFileName)); + $uploadedFile = DataObject::get_one('UploadFieldTest_ExtendedFile', array( + '"File"."Name"' => $tmpFileName + )); $this->assertTrue(is_object($uploadedFile), 'The file object is created'); // Test that the record isn't written to automatically @@ -106,7 +112,9 @@ class UploadFieldTest extends FunctionalTest { $response = $this->mockFileUpload('HasManyFiles', $tmpFileName); $this->assertFalse($response->isError()); $this->assertFileExists(ASSETS_PATH . "/UploadFieldTest/$tmpFileName"); - $uploadedFile = DataObject::get_one('File', sprintf('"Name" = \'%s\'', $tmpFileName)); + $uploadedFile = DataObject::get_one('File', array( + '"File"."Name"' => $tmpFileName + )); $this->assertTrue(is_object($uploadedFile), 'The file object is created'); // Test that the record isn't written to automatically @@ -135,7 +143,9 @@ class UploadFieldTest extends FunctionalTest { $response = $this->mockFileUpload('ManyManyFiles', $tmpFileName); $this->assertFalse($response->isError()); $this->assertFileExists(ASSETS_PATH . "/UploadFieldTest/$tmpFileName"); - $uploadedFile = DataObject::get_one('File', sprintf('"Name" = \'%s\'', $tmpFileName)); + $uploadedFile = DataObject::get_one('File', array( + '"File"."Name"' => $tmpFileName + )); $this->assertTrue(is_object($uploadedFile), 'The file object is created'); // Test that the record isn't written to automatically diff --git a/tests/model/DBFieldTest.php b/tests/model/DBFieldTest.php index 376d50c1a..415d23fb9 100644 --- a/tests/model/DBFieldTest.php +++ b/tests/model/DBFieldTest.php @@ -22,116 +22,122 @@ class DBFieldTest extends SapphireTest { * Test the prepValueForDB() method on DBField. */ public function testPrepValueForDB() { - $db = DB::getConn(); + $db = DB::get_conn(); /* Float behaviour, asserting we have 0 */ - $this->assertEquals('0', singleton('Float')->prepValueForDB(0)); - $this->assertEquals('0', singleton('Float')->prepValueForDB(null)); - $this->assertEquals('0', singleton('Float')->prepValueForDB(false)); - $this->assertEquals('0', singleton('Float')->prepValueForDB('')); + $this->assertEquals(0, singleton('Float')->prepValueForDB(0)); + $this->assertEquals(0, singleton('Float')->prepValueForDB(null)); + $this->assertEquals(0, singleton('Float')->prepValueForDB(false)); + $this->assertEquals(0, singleton('Float')->prepValueForDB('')); $this->assertEquals('0', singleton('Float')->prepValueForDB('0')); /* Double behaviour, asserting we have 0 */ - $this->assertEquals('0', singleton('Double')->prepValueForDB(0)); - $this->assertEquals('0', singleton('Double')->prepValueForDB(null)); - $this->assertEquals('0', singleton('Double')->prepValueForDB(false)); - $this->assertEquals('0', singleton('Double')->prepValueForDB('')); + $this->assertEquals(0, singleton('Double')->prepValueForDB(0)); + $this->assertEquals(0, singleton('Double')->prepValueForDB(null)); + $this->assertEquals(0, singleton('Double')->prepValueForDB(false)); + $this->assertEquals(0, singleton('Double')->prepValueForDB('')); $this->assertEquals('0', singleton('Double')->prepValueForDB('0')); /* Integer behaviour, asserting we have 0 */ - $this->assertEquals('0', singleton('Int')->prepValueForDB(0)); - $this->assertEquals('0', singleton('Int')->prepValueForDB(null)); - $this->assertEquals('0', singleton('Int')->prepValueForDB(false)); - $this->assertEquals('0', singleton('Int')->prepValueForDB('')); + $this->assertEquals(0, singleton('Int')->prepValueForDB(0)); + $this->assertEquals(0, singleton('Int')->prepValueForDB(null)); + $this->assertEquals(0, singleton('Int')->prepValueForDB(false)); + $this->assertEquals(0, singleton('Int')->prepValueForDB('')); $this->assertEquals('0', singleton('Int')->prepValueForDB('0')); /* Integer behaviour, asserting we have 1 */ - $this->assertEquals('1', singleton('Int')->prepValueForDB(true)); - $this->assertEquals('1', singleton('Int')->prepValueForDB(1)); + $this->assertEquals(1, singleton('Int')->prepValueForDB(true)); + $this->assertEquals(1, singleton('Int')->prepValueForDB(1)); $this->assertEquals('1', singleton('Int')->prepValueForDB('1')); /* Decimal behaviour, asserting we have 0 */ - $this->assertEquals('0', singleton('Decimal')->prepValueForDB(0)); - $this->assertEquals('0', singleton('Decimal')->prepValueForDB(null)); - $this->assertEquals('0', singleton('Decimal')->prepValueForDB(false)); - $this->assertEquals('0', singleton('Decimal')->prepValueForDB('')); + $this->assertEquals(0, singleton('Decimal')->prepValueForDB(0)); + $this->assertEquals(0, singleton('Decimal')->prepValueForDB(null)); + $this->assertEquals(0, singleton('Decimal')->prepValueForDB(false)); + $this->assertEquals(0, singleton('Decimal')->prepValueForDB('')); $this->assertEquals('0', singleton('Decimal')->prepValueForDB('0')); /* Decimal behaviour, asserting we have 1 */ - $this->assertEquals('1', singleton('Decimal')->prepValueForDB(true)); - $this->assertEquals('1', singleton('Decimal')->prepValueForDB(1)); + $this->assertEquals(1, singleton('Decimal')->prepValueForDB(true)); + $this->assertEquals(1, singleton('Decimal')->prepValueForDB(1)); $this->assertEquals('1', singleton('Decimal')->prepValueForDB('1')); /* Boolean behaviour, asserting we have 0 */ - $this->assertEquals("'0'", singleton('Boolean')->prepValueForDB(0)); - $this->assertEquals("'0'", singleton('Boolean')->prepValueForDB(null)); - $this->assertEquals("'0'", singleton('Boolean')->prepValueForDB(false)); - $this->assertEquals("'0'", singleton('Boolean')->prepValueForDB('')); - $this->assertEquals("'0'", singleton('Boolean')->prepValueForDB('0')); + $this->assertEquals(false, singleton('Boolean')->prepValueForDB(0)); + $this->assertEquals(false, singleton('Boolean')->prepValueForDB(null)); + $this->assertEquals(false, singleton('Boolean')->prepValueForDB(false)); + $this->assertEquals(false, singleton('Boolean')->prepValueForDB('false')); + $this->assertEquals(false, singleton('Boolean')->prepValueForDB('f')); + $this->assertEquals(false, singleton('Boolean')->prepValueForDB('')); + $this->assertEquals(false, singleton('Boolean')->prepValueForDB('0')); /* Boolean behaviour, asserting we have 1 */ - $this->assertEquals("'1'", singleton('Boolean')->prepValueForDB(true)); - $this->assertEquals("'1'", singleton('Boolean')->prepValueForDB(1)); - $this->assertEquals("'1'", singleton('Boolean')->prepValueForDB('1')); - + $this->assertEquals(true, singleton('Boolean')->prepValueForDB(true)); + $this->assertEquals(true, singleton('Boolean')->prepValueForDB('true')); + $this->assertEquals(true, singleton('Boolean')->prepValueForDB('t')); + $this->assertEquals(true, singleton('Boolean')->prepValueForDB(1)); + $this->assertEquals(true, singleton('Boolean')->prepValueForDB('1')); + + // @todo - Revisit Varchar to evaluate correct behaviour of nullifyEmpty + /* Varchar behaviour */ - $this->assertEquals($db->prepStringForDB("0"), singleton('Varchar')->prepValueForDB(0)); - $this->assertEquals("null", singleton('Varchar')->prepValueForDB(null)); - $this->assertEquals("null", singleton('Varchar')->prepValueForDB(false)); - $this->assertEquals("null", singleton('Varchar')->prepValueForDB('')); - $this->assertEquals($db->prepStringForDB("0"), singleton('Varchar')->prepValueForDB('0')); - $this->assertEquals($db->prepStringForDB("1"), singleton('Varchar')->prepValueForDB(1)); - $this->assertEquals($db->prepStringForDB("1"), singleton('Varchar')->prepValueForDB(true)); - $this->assertEquals($db->prepStringForDB("1"), singleton('Varchar')->prepValueForDB('1')); - $this->assertEquals($db->prepStringForDB("00000"), singleton('Varchar')->prepValueForDB('00000')); - $this->assertEquals($db->prepStringForDB("0"), singleton('Varchar')->prepValueForDB(0000)); - $this->assertEquals($db->prepStringForDB("test"), singleton('Varchar')->prepValueForDB('test')); - $this->assertEquals($db->prepStringForDB("123"), singleton('Varchar')->prepValueForDB(123)); + $this->assertEquals(0, singleton('Varchar')->prepValueForDB(0)); + $this->assertEquals(null, singleton('Varchar')->prepValueForDB(null)); + $this->assertEquals(null, singleton('Varchar')->prepValueForDB(false)); + $this->assertEquals(null, singleton('Varchar')->prepValueForDB('')); + $this->assertEquals('0', singleton('Varchar')->prepValueForDB('0')); + $this->assertEquals(1, singleton('Varchar')->prepValueForDB(1)); + $this->assertEquals(true, singleton('Varchar')->prepValueForDB(true)); + $this->assertEquals('1', singleton('Varchar')->prepValueForDB('1')); + $this->assertEquals('00000', singleton('Varchar')->prepValueForDB('00000')); + $this->assertEquals(0, singleton('Varchar')->prepValueForDB(0000)); + $this->assertEquals('test', singleton('Varchar')->prepValueForDB('test')); + $this->assertEquals(123, singleton('Varchar')->prepValueForDB(123)); /* AllowEmpty Varchar behaviour */ $varcharField = new Varchar("testfield", 50, array("nullifyEmpty"=>false)); - $this->assertSame($db->prepStringForDB("0"), $varcharField->prepValueForDB(0)); - $this->assertSame("null", $varcharField->prepValueForDB(null)); - $this->assertSame("null", $varcharField->prepValueForDB(false)); - $this->assertSame($db->prepStringForDB(""), $varcharField->prepValueForDB('')); - $this->assertSame($db->prepStringForDB("0"), $varcharField->prepValueForDB('0')); - $this->assertSame($db->prepStringForDB("1"), $varcharField->prepValueForDB(1)); - $this->assertSame($db->prepStringForDB("1"), $varcharField->prepValueForDB(true)); - $this->assertSame($db->prepStringForDB("1"), $varcharField->prepValueForDB('1')); - $this->assertSame($db->prepStringForDB("00000"), $varcharField->prepValueForDB('00000')); - $this->assertSame($db->prepStringForDB("0"), $varcharField->prepValueForDB(0000)); - $this->assertSame($db->prepStringForDB("test"), $varcharField->prepValueForDB('test')); - $this->assertSame($db->prepStringForDB("123"), $varcharField->prepValueForDB(123)); + $this->assertSame(0, $varcharField->prepValueForDB(0)); + $this->assertSame(null, $varcharField->prepValueForDB(null)); + $this->assertSame(null, $varcharField->prepValueForDB(false)); + $this->assertSame('', $varcharField->prepValueForDB('')); + $this->assertSame('0', $varcharField->prepValueForDB('0')); + $this->assertSame(1, $varcharField->prepValueForDB(1)); + $this->assertSame(true, $varcharField->prepValueForDB(true)); + $this->assertSame('1', $varcharField->prepValueForDB('1')); + $this->assertSame('00000', $varcharField->prepValueForDB('00000')); + $this->assertSame(0, $varcharField->prepValueForDB(0000)); + $this->assertSame('test', $varcharField->prepValueForDB('test')); + $this->assertSame(123, $varcharField->prepValueForDB(123)); unset($varcharField); /* Text behaviour */ - $this->assertEquals($db->prepStringForDB("0"), singleton('Text')->prepValueForDB(0)); - $this->assertEquals("null", singleton('Text')->prepValueForDB(null)); - $this->assertEquals("null", singleton('Text')->prepValueForDB(false)); - $this->assertEquals("null", singleton('Text')->prepValueForDB('')); - $this->assertEquals($db->prepStringForDB("0"), singleton('Text')->prepValueForDB('0')); - $this->assertEquals($db->prepStringForDB("1"), singleton('Text')->prepValueForDB(1)); - $this->assertEquals($db->prepStringForDB("1"), singleton('Text')->prepValueForDB(true)); - $this->assertEquals($db->prepStringForDB("1"), singleton('Text')->prepValueForDB('1')); - $this->assertEquals($db->prepStringForDB("00000"), singleton('Text')->prepValueForDB('00000')); - $this->assertEquals($db->prepStringForDB("0"), singleton('Text')->prepValueForDB(0000)); - $this->assertEquals($db->prepStringForDB("test"), singleton('Text')->prepValueForDB('test')); - $this->assertEquals($db->prepStringForDB("123"), singleton('Text')->prepValueForDB(123)); + $this->assertEquals(0, singleton('Text')->prepValueForDB(0)); + $this->assertEquals(null, singleton('Text')->prepValueForDB(null)); + $this->assertEquals(null, singleton('Text')->prepValueForDB(false)); + $this->assertEquals(null, singleton('Text')->prepValueForDB('')); + $this->assertEquals('0', singleton('Text')->prepValueForDB('0')); + $this->assertEquals(1, singleton('Text')->prepValueForDB(1)); + $this->assertEquals(true, singleton('Text')->prepValueForDB(true)); + $this->assertEquals('1', singleton('Text')->prepValueForDB('1')); + $this->assertEquals('00000', singleton('Text')->prepValueForDB('00000')); + $this->assertEquals(0, singleton('Text')->prepValueForDB(0000)); + $this->assertEquals('test', singleton('Text')->prepValueForDB('test')); + $this->assertEquals(123, singleton('Text')->prepValueForDB(123)); /* AllowEmpty Text behaviour */ $textField = new Text("testfield", array("nullifyEmpty"=>false)); - $this->assertSame($db->prepStringForDB("0"), $textField->prepValueForDB(0)); - $this->assertSame("null", $textField->prepValueForDB(null)); - $this->assertSame("null", $textField->prepValueForDB(false)); - $this->assertSame($db->prepStringForDB(""), $textField->prepValueForDB('')); - $this->assertSame($db->prepStringForDB("0"), $textField->prepValueForDB('0')); - $this->assertSame($db->prepStringForDB("1"), $textField->prepValueForDB(1)); - $this->assertSame($db->prepStringForDB("1"), $textField->prepValueForDB(true)); - $this->assertSame($db->prepStringForDB("1"), $textField->prepValueForDB('1')); - $this->assertSame($db->prepStringForDB("00000"), $textField->prepValueForDB('00000')); - $this->assertSame($db->prepStringForDB("0"), $textField->prepValueForDB(0000)); - $this->assertSame($db->prepStringForDB("test"), $textField->prepValueForDB('test')); - $this->assertSame($db->prepStringForDB("123"), $textField->prepValueForDB(123)); + $this->assertSame(0, $textField->prepValueForDB(0)); + $this->assertSame(null, $textField->prepValueForDB(null)); + $this->assertSame(null, $textField->prepValueForDB(false)); + $this->assertSame('', $textField->prepValueForDB('')); + $this->assertSame('0', $textField->prepValueForDB('0')); + $this->assertSame(1, $textField->prepValueForDB(1)); + $this->assertSame(true, $textField->prepValueForDB(true)); + $this->assertSame('1', $textField->prepValueForDB('1')); + $this->assertSame('00000', $textField->prepValueForDB('00000')); + $this->assertSame(0, $textField->prepValueForDB(0000)); + $this->assertSame('test', $textField->prepValueForDB('test')); + $this->assertSame(123, $textField->prepValueForDB(123)); unset($textField); /* Time behaviour */ diff --git a/tests/model/DataDifferencerTest.php b/tests/model/DataDifferencerTest.php index b0c20bb01..077030301 100644 --- a/tests/model/DataDifferencerTest.php +++ b/tests/model/DataDifferencerTest.php @@ -112,7 +112,7 @@ class DataDifferencerTest_HasOneRelationObject extends DataObject implements Tes class DataDifferencerTest_MockImage extends Image implements TestOnly { public function generateFormattedImage($format, $arg1 = null, $arg2 = null) { $cacheFile = $this->cacheFilename($format, $arg1, $arg2); - $gd = new GD(Director::baseFolder()."/" . $this->Filename); + $gd = new GDBackend(Director::baseFolder()."/" . $this->Filename); // Skip aktual generation return $gd; } diff --git a/tests/model/DataExtensionTest.php b/tests/model/DataExtensionTest.php index e289ee717..b248186a2 100644 --- a/tests/model/DataExtensionTest.php +++ b/tests/model/DataExtensionTest.php @@ -38,8 +38,12 @@ class DataExtensionTest extends SapphireTest { unset($contact); unset($object); - $contact = DataObject::get_one("DataExtensionTest_Member", "\"Website\"='http://www.example.com'"); - $object = DataObject::get_one('DataExtensionTest_RelatedObject', "\"ContactID\" = {$contactID}"); + $contact = DataObject::get_one("DataExtensionTest_Member", array( + '"DataExtensionTest_Member"."Website"' => 'http://www.example.com' + )); + $object = DataObject::get_one('DataExtensionTest_RelatedObject', array( + '"DataExtensionTest_RelatedObject"."ContactID"' => $contactID + )); $this->assertNotNull($object, 'Related object not null'); $this->assertInstanceOf('DataExtensionTest_Member', $object->Contact(), @@ -93,7 +97,9 @@ class DataExtensionTest extends SapphireTest { unset($player); // Pull the record out of the DB and examine the extended fields - $player = DataObject::get_one('DataExtensionTest_Player', "\"Name\" = 'Joe'"); + $player = DataObject::get_one('DataExtensionTest_Player', array( + '"DataExtensionTest_Player"."Name"' => 'Joe' + )); $this->assertEquals($player->DateBirth, '1990-05-10'); $this->assertEquals($player->Address, '123 somewhere street'); $this->assertEquals($player->Status, 'Goalie'); diff --git a/tests/model/DataListTest.php b/tests/model/DataListTest.php index 2c647260a..cda717234 100755 --- a/tests/model/DataListTest.php +++ b/tests/model/DataListTest.php @@ -133,19 +133,21 @@ class DataListTest extends SapphireTest { } public function testSql() { - $db = DB::getConn(); + $db = DB::get_conn(); $list = DataObjectTest_TeamComment::get(); - $expected = 'SELECT DISTINCT "DataObjectTest_TeamComment"."ClassName", "DataObjectTest_TeamComment"."Created",' - . ' "DataObjectTest_TeamComment"."LastEdited", "DataObjectTest_TeamComment"."Name",' - . ' "DataObjectTest_TeamComment"."Comment", "DataObjectTest_TeamComment"."TeamID",' - . ' "DataObjectTest_TeamComment"."ID", CASE WHEN "DataObjectTest_TeamComment"."ClassName" IS NOT NULL' - . ' THEN "DataObjectTest_TeamComment"."ClassName" ELSE '.$db->prepStringForDB('DataObjectTest_TeamComment') + $expected = 'SELECT DISTINCT "DataObjectTest_TeamComment"."ClassName", ' + . '"DataObjectTest_TeamComment"."LastEdited", "DataObjectTest_TeamComment"."Created", ' + . '"DataObjectTest_TeamComment"."Name", "DataObjectTest_TeamComment"."Comment", ' + . '"DataObjectTest_TeamComment"."TeamID", "DataObjectTest_TeamComment"."ID", ' + . 'CASE WHEN "DataObjectTest_TeamComment"."ClassName" IS NOT NULL ' + . 'THEN "DataObjectTest_TeamComment"."ClassName" ELSE ' + . $db->quoteString('DataObjectTest_TeamComment') . ' END AS "RecordClassName" FROM "DataObjectTest_TeamComment"'; - $this->assertEquals($expected, $list->sql()); + $this->assertSQLEquals($expected, $list->sql($parameters)); } public function testInnerJoin() { - $db = DB::getConn(); + $db = DB::get_conn(); $list = DataObjectTest_TeamComment::get(); $list = $list->innerJoin( @@ -154,19 +156,22 @@ class DataListTest extends SapphireTest { 'Team' ); - $expected = 'SELECT DISTINCT "DataObjectTest_TeamComment"."ClassName", "DataObjectTest_TeamComment"."Created",' - . ' "DataObjectTest_TeamComment"."LastEdited", "DataObjectTest_TeamComment"."Name",' - . ' "DataObjectTest_TeamComment"."Comment", "DataObjectTest_TeamComment"."TeamID",' - . ' "DataObjectTest_TeamComment"."ID", CASE WHEN "DataObjectTest_TeamComment"."ClassName" IS NOT NULL' - . ' THEN "DataObjectTest_TeamComment"."ClassName" ELSE '.$db->prepStringForDB('DataObjectTest_TeamComment') - . ' END AS "RecordClassName" FROM "DataObjectTest_TeamComment" INNER JOIN "DataObjectTest_Team" AS "Team"' - . ' ON "DataObjectTest_Team"."ID" = "DataObjectTest_TeamComment"."TeamID"'; + $expected = 'SELECT DISTINCT "DataObjectTest_TeamComment"."ClassName", ' + . '"DataObjectTest_TeamComment"."LastEdited", "DataObjectTest_TeamComment"."Created", ' + . '"DataObjectTest_TeamComment"."Name", "DataObjectTest_TeamComment"."Comment", ' + . '"DataObjectTest_TeamComment"."TeamID", "DataObjectTest_TeamComment"."ID", ' + . 'CASE WHEN "DataObjectTest_TeamComment"."ClassName" IS NOT NULL' + . ' THEN "DataObjectTest_TeamComment"."ClassName" ELSE ' + . $db->quoteString('DataObjectTest_TeamComment') + . ' END AS "RecordClassName" FROM "DataObjectTest_TeamComment" INNER JOIN ' + . '"DataObjectTest_Team" AS "Team" ON "DataObjectTest_Team"."ID" = ' + . '"DataObjectTest_TeamComment"."TeamID"'; - $this->assertEquals($expected, $list->sql()); + $this->assertSQLEquals($expected, $list->sql($parameters)); } public function testLeftJoin() { - $db = DB::getConn(); + $db = DB::get_conn(); $list = DataObjectTest_TeamComment::get(); $list = $list->leftJoin( @@ -175,15 +180,17 @@ class DataListTest extends SapphireTest { 'Team' ); - $expected = 'SELECT DISTINCT "DataObjectTest_TeamComment"."ClassName", "DataObjectTest_TeamComment"."Created",' - . ' "DataObjectTest_TeamComment"."LastEdited", "DataObjectTest_TeamComment"."Name",' - . ' "DataObjectTest_TeamComment"."Comment", "DataObjectTest_TeamComment"."TeamID",' - . ' "DataObjectTest_TeamComment"."ID", CASE WHEN "DataObjectTest_TeamComment"."ClassName" IS NOT NULL' - . ' THEN "DataObjectTest_TeamComment"."ClassName" ELSE '.$db->prepStringForDB('DataObjectTest_TeamComment') - . ' END AS "RecordClassName" FROM "DataObjectTest_TeamComment" LEFT JOIN "DataObjectTest_Team" AS "Team"' - . ' ON "DataObjectTest_Team"."ID" = "DataObjectTest_TeamComment"."TeamID"'; + $expected = 'SELECT DISTINCT "DataObjectTest_TeamComment"."ClassName", ' + . '"DataObjectTest_TeamComment"."LastEdited", "DataObjectTest_TeamComment"."Created", ' + . '"DataObjectTest_TeamComment"."Name", "DataObjectTest_TeamComment"."Comment", ' + . '"DataObjectTest_TeamComment"."TeamID", "DataObjectTest_TeamComment"."ID", ' + . 'CASE WHEN "DataObjectTest_TeamComment"."ClassName" IS NOT NULL ' + . 'THEN "DataObjectTest_TeamComment"."ClassName" ELSE ' + . $db->quoteString('DataObjectTest_TeamComment') + . ' END AS "RecordClassName" FROM "DataObjectTest_TeamComment" LEFT JOIN "DataObjectTest_Team" ' + . 'AS "Team" ON "DataObjectTest_Team"."ID" = "DataObjectTest_TeamComment"."TeamID"'; - $this->assertEquals($expected, $list->sql()); + $this->assertSQLEquals($expected, $list->sql($parameters)); // Test with namespaces (with non-sensical join, but good enough for testing) $list = DataObjectTest_TeamComment::get(); @@ -193,19 +200,19 @@ class DataListTest extends SapphireTest { ); $expected = 'SELECT DISTINCT "DataObjectTest_TeamComment"."ClassName", ' - . '"DataObjectTest_TeamComment"."Created", ' . '"DataObjectTest_TeamComment"."LastEdited", ' + . '"DataObjectTest_TeamComment"."Created", ' . '"DataObjectTest_TeamComment"."Name", ' . '"DataObjectTest_TeamComment"."Comment", ' . '"DataObjectTest_TeamComment"."TeamID", ' . '"DataObjectTest_TeamComment"."ID", ' . 'CASE WHEN "DataObjectTest_TeamComment"."ClassName" IS NOT NULL ' . 'THEN "DataObjectTest_TeamComment"."ClassName" ' - . 'ELSE ' . $db->prepStringForDB('DataObjectTest_TeamComment') . ' END AS "RecordClassName" ' + . 'ELSE ' . $db->quoteString('DataObjectTest_TeamComment') . ' END AS "RecordClassName" ' . 'FROM "DataObjectTest_TeamComment" ' . 'LEFT JOIN "DataObjectTest\NamespacedClass" ON ' . '"DataObjectTest\NamespacedClass"."ID" = "DataObjectTest_TeamComment"."ID"'; - $this->assertEquals($expected, $list->sql(), 'Retains backslashes in namespaced classes'); + $this->assertSQLEquals($expected, $list->sql($parameters), 'Retains backslashes in namespaced classes'); } @@ -755,17 +762,20 @@ class DataListTest extends SapphireTest { $list = $list->filter('Comment', 'Phil is a unique guy, and comments on team2'); $list = $list->exclude('Name', 'Bob'); - $this->assertContains( - 'WHERE ("DataObjectTest_TeamComment"."Comment" = ' - . '\'Phil is a unique guy, and comments on team2\') ' - . 'AND (("DataObjectTest_TeamComment"."Name" != \'Bob\'))', - $list->sql()); + $sql = $list->sql($parameters); + $this->assertSQLContains( + 'WHERE ("DataObjectTest_TeamComment"."Comment" = ?) AND (("DataObjectTest_TeamComment"."Name" != ?))', + $sql); + $this->assertEquals(array('Phil is a unique guy, and comments on team2', 'Bob'), $parameters); } public function testExcludeWithSearchFilter() { $list = DataObjectTest_TeamComment::get(); $list = $list->exclude('Name:LessThan', 'Bob'); - $this->assertContains('WHERE (("DataObjectTest_TeamComment"."Name" >= \'Bob\'))', $list->sql()); + + $sql = $list->sql($parameters); + $this->assertSQLContains('WHERE (("DataObjectTest_TeamComment"."Name" >= ?))', $sql); + $this->assertEquals(array('Bob'), $parameters); } /** @@ -853,10 +863,11 @@ class DataListTest extends SapphireTest { } public function testSortByComplexExpression() { - // Test an expression with both spaces and commas - // This test also tests that column() can be called with a complex sort expression, so keep using column() below + // Test an expression with both spaces and commas. This test also tests that column() can be called + // with a complex sort expression, so keep using column() below $list = DataObjectTest_Team::get()->sort( - 'CASE WHEN "DataObjectTest_Team"."ClassName" = \'DataObjectTest_SubTeam\' THEN 0 ELSE 1 END, "Title" DESC'); + 'CASE WHEN "DataObjectTest_Team"."ClassName" = \'DataObjectTest_SubTeam\' THEN 0 ELSE 1 END, "Title" DESC' + ); $this->assertEquals(array( 'Subteam 3', 'Subteam 2', diff --git a/tests/model/DataObjectLazyLoadingTest.php b/tests/model/DataObjectLazyLoadingTest.php index 83bc8263b..00329c7bf 100644 --- a/tests/model/DataObjectLazyLoadingTest.php +++ b/tests/model/DataObjectLazyLoadingTest.php @@ -27,63 +27,62 @@ class DataObjectLazyLoadingTest extends SapphireTest { ); public function testQueriedColumnsID() { - $db = DB::getConn(); + $db = DB::get_conn(); $playerList = new DataList('DataObjectTest_SubTeam'); $playerList = $playerList->setQueriedColumns(array('ID')); - $expected = 'SELECT DISTINCT "DataObjectTest_Team"."ClassName", "DataObjectTest_Team"."Created", ' . - '"DataObjectTest_Team"."LastEdited", "DataObjectTest_Team"."ID", CASE WHEN '. + $expected = 'SELECT DISTINCT "DataObjectTest_Team"."ClassName", "DataObjectTest_Team"."LastEdited", ' . + '"DataObjectTest_Team"."Created", "DataObjectTest_Team"."ID", CASE WHEN '. '"DataObjectTest_Team"."ClassName" IS NOT NULL THEN "DataObjectTest_Team"."ClassName" ELSE ' . - $db->prepStringForDB('DataObjectTest_Team').' END AS "RecordClassName", "DataObjectTest_Team"."Title" '. + $db->quoteString('DataObjectTest_Team').' END AS "RecordClassName", "DataObjectTest_Team"."Title" '. 'FROM "DataObjectTest_Team" ' . 'LEFT JOIN "DataObjectTest_SubTeam" ON "DataObjectTest_SubTeam"."ID" = "DataObjectTest_Team"."ID" ' . - 'WHERE ("DataObjectTest_Team"."ClassName" IN ('.$db->prepStringForDB('DataObjectTest_SubTeam').'))' . + 'WHERE ("DataObjectTest_Team"."ClassName" IN (?))' . ' ORDER BY "DataObjectTest_Team"."Title" ASC'; - $this->assertEquals($expected, $playerList->sql()); + $this->assertSQLEquals($expected, $playerList->sql($parameters)); } public function testQueriedColumnsFromBaseTableAndSubTable() { - $db = DB::getConn(); + $db = DB::get_conn(); $playerList = new DataList('DataObjectTest_SubTeam'); $playerList = $playerList->setQueriedColumns(array('Title', 'SubclassDatabaseField')); - $expected = 'SELECT DISTINCT "DataObjectTest_Team"."ClassName", "DataObjectTest_Team"."Created", ' . - '"DataObjectTest_Team"."LastEdited", "DataObjectTest_Team"."Title", ' . + $expected = 'SELECT DISTINCT "DataObjectTest_Team"."ClassName", "DataObjectTest_Team"."LastEdited", ' . + '"DataObjectTest_Team"."Created", "DataObjectTest_Team"."Title", ' . '"DataObjectTest_SubTeam"."SubclassDatabaseField", "DataObjectTest_Team"."ID", CASE WHEN ' . '"DataObjectTest_Team"."ClassName" IS NOT NULL THEN "DataObjectTest_Team"."ClassName" ELSE ' . - $db->prepStringForDB('DataObjectTest_Team').' END AS "RecordClassName" FROM "DataObjectTest_Team" ' . + $db->quoteString('DataObjectTest_Team').' END AS "RecordClassName" FROM "DataObjectTest_Team" ' . 'LEFT JOIN "DataObjectTest_SubTeam" ON "DataObjectTest_SubTeam"."ID" = "DataObjectTest_Team"."ID" WHERE ' . - '("DataObjectTest_Team"."ClassName" IN ('.$db->prepStringForDB('DataObjectTest_SubTeam').')) ' . + '("DataObjectTest_Team"."ClassName" IN (?)) ' . 'ORDER BY "DataObjectTest_Team"."Title" ASC'; - $this->assertEquals($expected, $playerList->sql()); + $this->assertSQLEquals($expected, $playerList->sql($parameters)); } public function testQueriedColumnsFromBaseTable() { - $db = DB::getConn(); + $db = DB::get_conn(); $playerList = new DataList('DataObjectTest_SubTeam'); $playerList = $playerList->setQueriedColumns(array('Title')); - $expected = 'SELECT DISTINCT "DataObjectTest_Team"."ClassName", "DataObjectTest_Team"."Created", ' . - '"DataObjectTest_Team"."LastEdited", "DataObjectTest_Team"."Title", "DataObjectTest_Team"."ID", ' . + $expected = 'SELECT DISTINCT "DataObjectTest_Team"."ClassName", "DataObjectTest_Team"."LastEdited", ' . + '"DataObjectTest_Team"."Created", "DataObjectTest_Team"."Title", "DataObjectTest_Team"."ID", ' . 'CASE WHEN "DataObjectTest_Team"."ClassName" IS NOT NULL THEN "DataObjectTest_Team"."ClassName" ELSE ' . - $db->prepStringForDB('DataObjectTest_Team').' END AS "RecordClassName" FROM "DataObjectTest_Team" ' . + $db->quoteString('DataObjectTest_Team').' END AS "RecordClassName" FROM "DataObjectTest_Team" ' . 'LEFT JOIN "DataObjectTest_SubTeam" ON "DataObjectTest_SubTeam"."ID" = "DataObjectTest_Team"."ID" WHERE ' . - '("DataObjectTest_Team"."ClassName" IN ('.$db->prepStringForDB('DataObjectTest_SubTeam').')) ' . + '("DataObjectTest_Team"."ClassName" IN (?)) ' . 'ORDER BY "DataObjectTest_Team"."Title" ASC'; - $this->assertEquals($expected, $playerList->sql()); + $this->assertSQLEquals($expected, $playerList->sql($parameters)); } public function testQueriedColumnsFromSubTable() { - $db = DB::getConn(); + $db = DB::get_conn(); $playerList = new DataList('DataObjectTest_SubTeam'); $playerList = $playerList->setQueriedColumns(array('SubclassDatabaseField')); - $expected = 'SELECT DISTINCT "DataObjectTest_Team"."ClassName", "DataObjectTest_Team"."Created", ' . - '"DataObjectTest_Team"."LastEdited", "DataObjectTest_SubTeam"."SubclassDatabaseField", ' . + $expected = 'SELECT DISTINCT "DataObjectTest_Team"."ClassName", "DataObjectTest_Team"."LastEdited", ' . + '"DataObjectTest_Team"."Created", "DataObjectTest_SubTeam"."SubclassDatabaseField", ' . '"DataObjectTest_Team"."ID", CASE WHEN "DataObjectTest_Team"."ClassName" IS NOT NULL THEN ' . - '"DataObjectTest_Team"."ClassName" ELSE '.$db->prepStringForDB('DataObjectTest_Team').' END ' . + '"DataObjectTest_Team"."ClassName" ELSE '.$db->quoteString('DataObjectTest_Team').' END ' . 'AS "RecordClassName", "DataObjectTest_Team"."Title" ' . 'FROM "DataObjectTest_Team" LEFT JOIN "DataObjectTest_SubTeam" ON "DataObjectTest_SubTeam"."ID" = ' . - '"DataObjectTest_Team"."ID" WHERE ("DataObjectTest_Team"."ClassName" IN (' . - $db->prepStringForDB('DataObjectTest_SubTeam').')) ' . + '"DataObjectTest_Team"."ID" WHERE ("DataObjectTest_Team"."ClassName" IN (?)) ' . 'ORDER BY "DataObjectTest_Team"."Title" ASC'; - $this->assertEquals($expected, $playerList->sql()); + $this->assertSQLEquals($expected, $playerList->sql($parameters)); } public function testNoSpecificColumnNamesBaseDataObjectQuery() { @@ -91,15 +90,23 @@ class DataObjectLazyLoadingTest extends SapphireTest { $playerList = new DataList('DataObjectTest_Team'); // Shouldn't be a left join in here. $this->assertEquals(0, - preg_match('/SELECT DISTINCT "DataObjectTest_Team"."ID" .* LEFT JOIN .* FROM "DataObjectTest_Team"/', - $playerList->sql())); + preg_match( + $this->normaliseSQL( + '/SELECT DISTINCT "DataObjectTest_Team"."ID" .* LEFT JOIN .* FROM "DataObjectTest_Team"/' + ), + $this->normaliseSQL($playerList->sql($parameters)) + ) + ); } public function testNoSpecificColumnNamesSubclassDataObjectQuery() { // This queries all columns from base table and subtable $playerList = new DataList('DataObjectTest_SubTeam'); // Should be a left join. - $this->assertEquals(1, preg_match('/SELECT DISTINCT .* LEFT JOIN .* /', $playerList->sql())); + $this->assertEquals(1, preg_match( + $this->normaliseSQL('/SELECT DISTINCT .* LEFT JOIN .* /'), + $this->normaliseSQL($playerList->sql($parameters)) + )); } public function testLazyLoadedFieldsHasField() { diff --git a/tests/model/DataObjectSchemaGenerationTest.php b/tests/model/DataObjectSchemaGenerationTest.php index c33cb7e36..c1e7122cf 100644 --- a/tests/model/DataObjectSchemaGenerationTest.php +++ b/tests/model/DataObjectSchemaGenerationTest.php @@ -10,7 +10,7 @@ class DataObjectSchemaGenerationTest extends SapphireTest { // enable fulltext option on this table Config::inst()->update('DataObjectSchemaGenerationTest_IndexDO', 'create_table_options', - array('MySQLDatabase' => 'ENGINE=MyISAM')); + array(MySQLSchemaManager::ID => 'ENGINE=MyISAM')); parent::setUpOnce(); } @@ -19,25 +19,28 @@ class DataObjectSchemaGenerationTest extends SapphireTest { * Check that once a schema has been generated, then it doesn't need any more updating */ public function testFieldsDontRerequestChanges() { - $db = DB::getConn(); + $schema = DB::get_schema(); + $test = $this; DB::quiet(); // Table will have been initially created by the $extraDataObjects setting // Verify that it doesn't need to be recreated - $db->beginSchemaUpdate(); - $obj = new DataObjectSchemaGenerationTest_DO(); - $obj->requireTable(); - $needsUpdating = $db->doesSchemaNeedUpdating(); - $db->cancelSchemaUpdate(); - $this->assertFalse($needsUpdating); + $schema->schemaUpdate(function() use ($test, $schema) { + $obj = new DataObjectSchemaGenerationTest_DO(); + $obj->requireTable(); + $needsUpdating = $schema->doesSchemaNeedUpdating(); + $schema->cancelSchemaUpdate(); + $test->assertFalse($needsUpdating); + }); } /** * Check that updates to a class fields are reflected in the database */ public function testFieldsRequestChanges() { - $db = DB::getConn(); + $schema = DB::get_schema(); + $test = $this; DB::quiet(); // Table will have been initially created by the $extraDataObjects setting @@ -49,12 +52,13 @@ class DataObjectSchemaGenerationTest extends SapphireTest { )); // Verify that the above extra field triggered a schema update - $db->beginSchemaUpdate(); - $obj = new DataObjectSchemaGenerationTest_DO(); - $obj->requireTable(); - $needsUpdating = $db->doesSchemaNeedUpdating(); - $db->cancelSchemaUpdate(); - $this->assertTrue($needsUpdating); + $schema->schemaUpdate(function() use ($test, $schema) { + $obj = new DataObjectSchemaGenerationTest_DO(); + $obj->requireTable(); + $needsUpdating = $schema->doesSchemaNeedUpdating(); + $schema->cancelSchemaUpdate(); + $test->assertTrue($needsUpdating); + }); // Restore db configuration Config::unnest(); @@ -64,18 +68,20 @@ class DataObjectSchemaGenerationTest extends SapphireTest { * Check that indexes on a newly generated class do not subsequently request modification */ public function testIndexesDontRerequestChanges() { - $db = DB::getConn(); + $schema = DB::get_schema(); + $test = $this; DB::quiet(); // Table will have been initially created by the $extraDataObjects setting // Verify that it doesn't need to be recreated - $db->beginSchemaUpdate(); - $obj = new DataObjectSchemaGenerationTest_IndexDO(); - $obj->requireTable(); - $needsUpdating = $db->doesSchemaNeedUpdating(); - $db->cancelSchemaUpdate(); - $this->assertFalse($needsUpdating); + $schema->schemaUpdate(function() use ($test, $schema) { + $obj = new DataObjectSchemaGenerationTest_IndexDO(); + $obj->requireTable(); + $needsUpdating = $schema->doesSchemaNeedUpdating(); + $schema->cancelSchemaUpdate(); + $test->assertFalse($needsUpdating); + }); // Test with alternate index format, although these indexes are the same Config::nest(); @@ -85,12 +91,13 @@ class DataObjectSchemaGenerationTest extends SapphireTest { ); // Verify that it still doesn't need to be recreated - $db->beginSchemaUpdate(); - $obj2 = new DataObjectSchemaGenerationTest_IndexDO(); - $obj2->requireTable(); - $needsUpdating = $db->doesSchemaNeedUpdating(); - $db->cancelSchemaUpdate(); - $this->assertFalse($needsUpdating); + $schema->schemaUpdate(function() use ($test, $schema) { + $obj2 = new DataObjectSchemaGenerationTest_IndexDO(); + $obj2->requireTable(); + $needsUpdating = $schema->doesSchemaNeedUpdating(); + $schema->cancelSchemaUpdate(); + $test->assertFalse($needsUpdating); + }); // Restore old index format Config::unnest(); @@ -100,7 +107,8 @@ class DataObjectSchemaGenerationTest extends SapphireTest { * Check that updates to a dataobject's indexes are reflected in DDL */ public function testIndexesRerequestChanges() { - $db = DB::getConn(); + $schema = DB::get_schema(); + $test = $this; DB::quiet(); // Table will have been initially created by the $extraDataObjects setting @@ -114,12 +122,13 @@ class DataObjectSchemaGenerationTest extends SapphireTest { )); // Verify that the above index change triggered a schema update - $db->beginSchemaUpdate(); - $obj = new DataObjectSchemaGenerationTest_IndexDO(); - $obj->requireTable(); - $needsUpdating = $db->doesSchemaNeedUpdating(); - $db->cancelSchemaUpdate(); - $this->assertTrue($needsUpdating); + $schema->schemaUpdate(function() use ($test, $schema) { + $obj = new DataObjectSchemaGenerationTest_IndexDO(); + $obj->requireTable(); + $needsUpdating = $schema->doesSchemaNeedUpdating(); + $schema->cancelSchemaUpdate(); + $test->assertTrue($needsUpdating); + }); // Restore old indexes Config::unnest(); diff --git a/tests/model/DataObjectTest.php b/tests/model/DataObjectTest.php index ccfe3bead..849f5229b 100644 --- a/tests/model/DataObjectTest.php +++ b/tests/model/DataObjectTest.php @@ -154,17 +154,25 @@ class DataObjectTest extends SapphireTest { $this->assertEquals('Captain', $captain1->FirstName); // Test get_one() without caching - $comment1 = DataObject::get_one('DataObjectTest_TeamComment', "\"Name\" = 'Joe'", false); + $comment1 = DataObject::get_one('DataObjectTest_TeamComment', array( + '"DataObjectTest_TeamComment"."Name"' => 'Joe' + ), false); $comment1->Comment = "Something Else"; - $comment2 = DataObject::get_one('DataObjectTest_TeamComment', "\"Name\" = 'Joe'", false); + $comment2 = DataObject::get_one('DataObjectTest_TeamComment', array( + '"DataObjectTest_TeamComment"."Name"' => 'Joe' + ), false); $this->assertNotEquals($comment1->Comment, $comment2->Comment); // Test get_one() with caching - $comment1 = DataObject::get_one('DataObjectTest_TeamComment', "\"Name\" = 'Bob'", true); + $comment1 = DataObject::get_one('DataObjectTest_TeamComment', array( + '"DataObjectTest_TeamComment"."Name"' => 'Bob' + ), true); $comment1->Comment = "Something Else"; - $comment2 = DataObject::get_one('DataObjectTest_TeamComment', "\"Name\" = 'Bob'", true); + $comment2 = DataObject::get_one('DataObjectTest_TeamComment', array( + '"DataObjectTest_TeamComment"."Name"' => 'Bob' + ), true); $this->assertEquals((string)$comment1->Comment, (string)$comment2->Comment); // Test get_one() with order by without caching @@ -179,9 +187,20 @@ class DataObjectTest extends SapphireTest { $this->assertEquals('Bob', $comment->Name); $comment = DataObject::get_one('DataObjectTest_TeamComment', '', true, '"Name" DESC'); $this->assertEquals('Phil', $comment->Name); - + } + + public function testGetCaseInsensitive() { // Test get_one() with bad case on the classname - $subteam1 = DataObject::get_one('dataobjecttest_subteam', "\"Title\" = 'Subteam 1'", true); + // Note: This will succeed only if the underlying DB server supports case-insensitive + // table names (e.g. such as MySQL, but not SQLite3) + if(!(DB::get_conn() instanceof MySQLDatabase)) { + $this->markTestSkipped('MySQL only'); + } + + $subteam1 = DataObject::get_one('dataobjecttest_subteam', array( + '"DataObjectTest_Team"."Title"' => 'Subteam 1' + ), true); + $this->assertNotEmpty($subteam1); $this->assertEquals($subteam1->Title, "Subteam 1"); } @@ -545,10 +564,10 @@ class DataObjectTest extends SapphireTest { $this->assertFalse($obj->isChanged()); /* If we perform the same random query twice, it shouldn't return the same results */ - $itemsA = DataObject::get("DataObjectTest_TeamComment", "", DB::getConn()->random()); - $itemsB = DataObject::get("DataObjectTest_TeamComment", "", DB::getConn()->random()); - $itemsC = DataObject::get("DataObjectTest_TeamComment", "", DB::getConn()->random()); - $itemsD = DataObject::get("DataObjectTest_TeamComment", "", DB::getConn()->random()); + $itemsA = DataObject::get("DataObjectTest_TeamComment", "", DB::get_conn()->random()); + $itemsB = DataObject::get("DataObjectTest_TeamComment", "", DB::get_conn()->random()); + $itemsC = DataObject::get("DataObjectTest_TeamComment", "", DB::get_conn()->random()); + $itemsD = DataObject::get("DataObjectTest_TeamComment", "", DB::get_conn()->random()); foreach($itemsA as $item) $keysA[] = $item->ID; foreach($itemsB as $item) $keysB[] = $item->ID; foreach($itemsC as $item) $keysC[] = $item->ID; @@ -742,12 +761,12 @@ class DataObjectTest extends SapphireTest { ); $this->assertEquals( - array_keys(DataObject::database_fields('DataObjectTest_Team')), + array_keys(DataObject::database_fields('DataObjectTest_Team', false)), array( //'ID', 'ClassName', - 'Created', 'LastEdited', + 'Created', 'Title', 'DatabaseField', 'ExtendedDatabaseField', @@ -778,7 +797,7 @@ class DataObjectTest extends SapphireTest { ); $this->assertEquals( - array_keys(DataObject::database_fields('DataObjectTest_SubTeam')), + array_keys(DataObject::database_fields('DataObjectTest_SubTeam', false)), array( 'SubclassDatabaseField', 'ParentTeamID', @@ -900,7 +919,7 @@ class DataObjectTest extends SapphireTest { public function testForceInsert() { /* If you set an ID on an object and pass forceInsert = true, then the object should be correctly created */ - $conn = DB::getConn(); + $conn = DB::get_conn(); if(method_exists($conn, 'allowPrimaryKeyEditing')) $conn->allowPrimaryKeyEditing('DataObjectTest_Team', true); $obj = new DataObjectTest_SubTeam(); $obj->ID = 1001; @@ -1265,6 +1284,7 @@ class DataObjectTest extends SapphireTest { $this->assertEquals($ceo->ID, $ceo->Company()->CEOID, 'Remote IDs are automatically set.'); // Write object with components + $ceo->Name = 'Edward Scissorhands'; $ceo->write(false, false, false, true); $this->assertTrue($ceo->Company()->isInDB(), 'write() writes belongs_to components to the database.'); diff --git a/tests/model/DataQueryTest.php b/tests/model/DataQueryTest.php index 09454c044..38ce7b879 100644 --- a/tests/model/DataQueryTest.php +++ b/tests/model/DataQueryTest.php @@ -18,13 +18,13 @@ class DataQueryTest extends SapphireTest { public function testJoins() { $dq = new DataQuery('Member'); $dq->innerJoin("Group_Members", "\"Group_Members\".\"MemberID\" = \"Member\".\"ID\""); - $this->assertContains("INNER JOIN \"Group_Members\" ON \"Group_Members\".\"MemberID\" = \"Member\".\"ID\"", - $dq->sql()); + $this->assertSQLContains("INNER JOIN \"Group_Members\" ON \"Group_Members\".\"MemberID\" = \"Member\".\"ID\"", + $dq->sql($parameters)); $dq = new DataQuery('Member'); $dq->leftJoin("Group_Members", "\"Group_Members\".\"MemberID\" = \"Member\".\"ID\""); - $this->assertContains("LEFT JOIN \"Group_Members\" ON \"Group_Members\".\"MemberID\" = \"Member\".\"ID\"", - $dq->sql()); + $this->assertSQLContains("LEFT JOIN \"Group_Members\" ON \"Group_Members\".\"MemberID\" = \"Member\".\"ID\"", + $dq->sql($parameters)); } public function testRelationReturn() { @@ -58,9 +58,9 @@ class DataQueryTest extends SapphireTest { $subDq->where('DataQueryTest_A.Name = \'John\''); $subDq->where('DataQueryTest_A.Name = \'Bob\''); - $this->assertContains( + $this->assertSQLContains( "WHERE (DataQueryTest_A.ID = 2) AND ((DataQueryTest_A.Name = 'John') OR (DataQueryTest_A.Name = 'Bob'))", - $dq->sql() + $dq->sql($parameters) ); } @@ -72,12 +72,15 @@ class DataQueryTest extends SapphireTest { $subDq->where('DataQueryTest_A.Name = \'John\''); $subDq->where('DataQueryTest_A.Name = \'Bob\''); - $this->assertContains( + $this->assertSQLContains( "WHERE (DataQueryTest_A.ID = 2) AND ((DataQueryTest_A.Name = 'John') AND (DataQueryTest_A.Name = 'Bob'))", - $dq->sql() + $dq->sql($parameters) ); } + /** + * @todo Test paramaterised + */ public function testNestedGroups() { $dq = new DataQuery('DataQueryTest_A'); @@ -89,10 +92,10 @@ class DataQueryTest extends SapphireTest { $subSubDq->where('DataQueryTest_A.Age = 50'); $subDq->where('DataQueryTest_A.Name = \'Bob\''); - $this->assertContains( + $this->assertSQLContains( "WHERE (DataQueryTest_A.ID = 2) AND ((DataQueryTest_A.Name = 'John') OR ((DataQueryTest_A.Age = 18) " . "AND (DataQueryTest_A.Age = 50)) OR (DataQueryTest_A.Name = 'Bob'))", - $dq->sql() + $dq->sql($parameters) ); } @@ -100,7 +103,8 @@ class DataQueryTest extends SapphireTest { $dq = new DataQuery('DataQueryTest_A'); $dq->conjunctiveGroup(); - $this->assertContains('WHERE (1=1)', $dq->sql()); + // Empty groups should have no where condition at all + $this->assertSQLNotContains('WHERE', $dq->sql($parameters)); } public function testSubgroupHandoff() { @@ -112,20 +116,20 @@ class DataQueryTest extends SapphireTest { $subDq->sort('"DataQueryTest_A"."Name"'); $orgDq->sort('"DataQueryTest_A"."Name"'); - $this->assertEquals($dq->sql(), $orgDq->sql()); + $this->assertSQLEquals($dq->sql($parameters), $orgDq->sql($parameters)); $subDq->limit(5, 7); $orgDq->limit(5, 7); - $this->assertEquals($dq->sql(), $orgDq->sql()); + $this->assertSQLEquals($dq->sql($parameters), $orgDq->sql($parameters)); } public function testOrderByMultiple() { $dq = new DataQuery('SQLQueryTest_DO'); $dq = $dq->sort('"Name" ASC, MID("Name", 8, 1) DESC'); $this->assertContains( - 'ORDER BY "Name" ASC, "_SortColumn0" DESC', - $dq->sql() + 'ORDER BY "SQLQueryTest_DO"."Name" ASC, "_SortColumn0" DESC', + $dq->sql($parameters) ); } diff --git a/tests/model/DatabaseTest.php b/tests/model/DatabaseTest.php index a4cfdffa1..d8ac179ed 100644 --- a/tests/model/DatabaseTest.php +++ b/tests/model/DatabaseTest.php @@ -12,17 +12,17 @@ class DatabaseTest extends SapphireTest { protected $usesDatabase = true; public function testDontRequireField() { - $conn = DB::getConn(); + $schema = DB::get_schema(); $this->assertArrayHasKey( 'MyField', - $conn->fieldList('DatabaseTest_MyObject') + $schema->fieldList('DatabaseTest_MyObject') ); - $conn->dontRequireField('DatabaseTest_MyObject', 'MyField'); + $schema->dontRequireField('DatabaseTest_MyObject', 'MyField'); $this->assertArrayHasKey( '_obsolete_MyField', - $conn->fieldList('DatabaseTest_MyObject'), + $schema->fieldList('DatabaseTest_MyObject'), 'Field is renamed to _obsolete_ through dontRequireField()' ); @@ -30,20 +30,20 @@ class DatabaseTest extends SapphireTest { } public function testRenameField() { - $conn = DB::getConn(); + $schema = DB::get_schema(); - $conn->clearCachedFieldlist(); + $schema->clearCachedFieldlist(); - $conn->renameField('DatabaseTest_MyObject', 'MyField', 'MyRenamedField'); + $schema->renameField('DatabaseTest_MyObject', 'MyField', 'MyRenamedField'); $this->assertArrayHasKey( 'MyRenamedField', - $conn->fieldList('DatabaseTest_MyObject'), + $schema->fieldList('DatabaseTest_MyObject'), 'New fieldname is set through renameField()' ); $this->assertArrayNotHasKey( 'MyField', - $conn->fieldList('DatabaseTest_MyObject'), + $schema->fieldList('DatabaseTest_MyObject'), 'Old fieldname isnt preserved through renameField()' ); @@ -51,7 +51,7 @@ class DatabaseTest extends SapphireTest { } public function testMySQLCreateTableOptions() { - if(!(DB::getConn() instanceof MySQLDatabase)) { + if(!(DB::get_conn() instanceof MySQLDatabase)) { $this->markTestSkipped('MySQL only'); } @@ -66,44 +66,49 @@ class DatabaseTest extends SapphireTest { } function testIsSchemaUpdating() { - $db = DB::getConn(); + $schema = DB::get_schema(); - $this->assertFalse($db->isSchemaUpdating(), 'Before the transaction the flag is false.'); + $this->assertFalse($schema->isSchemaUpdating(), 'Before the transaction the flag is false.'); - $db->beginSchemaUpdate(); - $this->assertTrue($db->isSchemaUpdating(), 'During the transaction the flag is true.'); + // Test complete schema update + $test = $this; + $schema->schemaUpdate(function() use ($test, $schema) { + $test->assertTrue($schema->isSchemaUpdating(), 'During the transaction the flag is true.'); + }); + $this->assertFalse($schema->isSchemaUpdating(), 'After the transaction the flag is false.'); - $db->endSchemaUpdate(); - $this->assertFalse($db->isSchemaUpdating(), 'After the transaction the flag is false.'); - - $db->beginSchemaUpdate(); - $db->cancelSchemaUpdate(); - $this->assertFalse($db->doesSchemaNeedUpdating(), 'After cancelling the transaction the flag is false'); + // Test cancelled schema update + $schema->schemaUpdate(function() use ($test, $schema) { + $schema->cancelSchemaUpdate(); + $test->assertFalse($schema->doesSchemaNeedUpdating(), 'After cancelling the transaction the flag is false'); + }); } public function testSchemaUpdateChecking() { - $db = DB::getConn(); + $schema = DB::get_schema(); // Initially, no schema changes necessary - $db->beginSchemaUpdate(); - $this->assertFalse($db->doesSchemaNeedUpdating()); + $test = $this; + $schema->schemaUpdate(function() use ($test, $schema) { + $test->assertFalse($schema->doesSchemaNeedUpdating()); - // If we make a change, then the schema will need updating - $db->transCreateTable("TestTable"); - $this->assertTrue($db->doesSchemaNeedUpdating()); + // If we make a change, then the schema will need updating + $schema->transCreateTable("TestTable"); + $test->assertTrue($schema->doesSchemaNeedUpdating()); - // If we make cancel the change, then schema updates are no longer necessary - $db->cancelSchemaUpdate(); - $this->assertFalse($db->doesSchemaNeedUpdating()); + // If we make cancel the change, then schema updates are no longer necessary + $schema->cancelSchemaUpdate(); + $test->assertFalse($schema->doesSchemaNeedUpdating()); + }); } public function testHasTable() { - $this->assertTrue(DB::getConn()->hasTable('DatabaseTest_MyObject')); - $this->assertFalse(DB::getConn()->hasTable('asdfasdfasdf')); + $this->assertTrue(DB::get_schema()->hasTable('DatabaseTest_MyObject')); + $this->assertFalse(DB::get_schema()->hasTable('asdfasdfasdf')); } public function testGetAndReleaseLock() { - $db = DB::getConn(); + $db = DB::get_conn(); if(!$db->supportsLocks()) { return $this->markTestSkipped('Tested database doesn\'t support application locks'); @@ -129,7 +134,7 @@ class DatabaseTest extends SapphireTest { } public function testCanLock() { - $db = DB::getConn(); + $db = DB::get_conn(); if(!$db->supportsLocks()) { return $this->markTestSkipped('Database doesn\'t support locks'); @@ -150,7 +155,7 @@ class DatabaseTest extends SapphireTest { class DatabaseTest_MyObject extends DataObject implements TestOnly { - private static $create_table_options = array('MySQLDatabase' => 'ENGINE=InnoDB'); + private static $create_table_options = array(MySQLSchemaManager::ID => 'ENGINE=InnoDB'); private static $db = array( 'MyField' => 'Varchar' diff --git a/tests/model/DbDatetimeTest.php b/tests/model/DbDatetimeTest.php index 630c2103b..6dde6bdd9 100644 --- a/tests/model/DbDatetimeTest.php +++ b/tests/model/DbDatetimeTest.php @@ -36,7 +36,7 @@ class DbDatetimeTest extends FunctionalTest { */ private function checkPreconditions() { // number of seconds of php and db time are out of sync - $offset = time() - strtotime(DB::query('SELECT ' . DB::getConn()->now())->value()); + $offset = time() - strtotime(DB::query('SELECT ' . DB::get_conn()->now())->value()); $threshold = 5; // seconds if($offset > 5) { @@ -53,7 +53,7 @@ class DbDatetimeTest extends FunctionalTest { public function setUp() { parent::setUp(); - $this->adapter = DB::getConn(); + $this->adapter = DB::get_conn(); } public function testCorrectNow() { @@ -95,7 +95,7 @@ class DbDatetimeTest extends FunctionalTest { $this->matchesRoughly($result, date('Y-m-d H:i:s', strtotime('+1 Day', $this->getDbNow())), 'tomorrow', $offset); - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setSelect(array()); $query->selectField($this->adapter->datetimeIntervalClause('"Created"', '-15 Minutes'), 'test') ->setFrom('"DbDateTimeTest_Team"') @@ -123,7 +123,7 @@ class DbDatetimeTest extends FunctionalTest { $result = DB::query('SELECT ' . $clause)->value(); $this->matchesRoughly($result, -45 * 60, 'now - 45 minutes ahead', $offset); - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setSelect(array()); $query->selectField($this->adapter->datetimeDifferenceClause('"LastEdited"', '"Created"'), 'test') ->setFrom('"DbDateTimeTest_Team"') diff --git a/tests/model/MySQLDatabaseTest.php b/tests/model/MySQLDatabaseTest.php index 83f7fb8f4..567dcbb24 100644 --- a/tests/model/MySQLDatabaseTest.php +++ b/tests/model/MySQLDatabaseTest.php @@ -10,7 +10,7 @@ class MySQLDatabaseTest extends SapphireTest { ); public function setUp() { - if(DB::getConn() instanceof MySQLDatabase) { + if(DB::get_conn() instanceof MySQLDatabase) { MySQLDatabaseTest_DO::config()->db = array( 'MultiEnum1' => 'MultiEnum("A, B, C, D","")', 'MultiEnum2' => 'MultiEnum("A, B, C, D","A")', @@ -26,18 +26,20 @@ class MySQLDatabaseTest extends SapphireTest { */ public function testFieldsDontRerequestChanges() { // These are MySQL specific :-S - if(DB::getConn() instanceof MySQLDatabase) { - $db = DB::getConn(); + if(DB::get_conn() instanceof MySQLDatabase) { + $schema = DB::get_schema(); + $test = $this; DB::quiet(); // Verify that it doesn't need to be recreated - $db->beginSchemaUpdate(); - $obj = new MySQLDatabaseTest_DO(); - $obj->requireTable(); - $needsUpdating = $db->doesSchemaNeedUpdating(); - $db->cancelSchemaUpdate(); + $schema->schemaUpdate(function() use ($test, $schema) { + $obj = new MySQLDatabaseTest_DO(); + $obj->requireTable(); + $needsUpdating = $schema->doesSchemaNeedUpdating(); + $schema->cancelSchemaUpdate(); - $this->assertFalse($needsUpdating); + $test->assertFalse($needsUpdating); + }); } } } diff --git a/tests/model/PaginatedListTest.php b/tests/model/PaginatedListTest.php index 334230f8d..8315a9ede 100644 --- a/tests/model/PaginatedListTest.php +++ b/tests/model/PaginatedListTest.php @@ -41,7 +41,7 @@ class PaginatedListTest extends SapphireTest { } public function testSetPaginationFromQuery() { - $query = $this->getMock('SQLQuery'); + $query = $this->getMock('SQLSelect'); $query->expects($this->once()) ->method('getLimit') ->will($this->returnValue(array('limit' => 15, 'start' => 30))); diff --git a/tests/model/SQLInsertTest.php b/tests/model/SQLInsertTest.php new file mode 100644 index 000000000..1ee2c2523 --- /dev/null +++ b/tests/model/SQLInsertTest.php @@ -0,0 +1,94 @@ +assertSQLEquals('', $query->sql($parameters)); + } + + public function testBasicInsert() { + $query = SQLInsert::create() + ->setInto('"SQLInsertTestBase"') + ->assign('"Title"', 'My Object') + ->assign('"HasFun"', 1) + ->assign('"Age"', 10) + ->assign('"Description"', 'No description'); + $sql = $query->sql($parameters); + // Only test this case if using the default query builder + if(get_class(DB::get_conn()->getQueryBuilder()) === 'DBQueryBuilder') { + $this->assertSQLEquals( + 'INSERT INTO "SQLInsertTestBase" ("Title", "HasFun", "Age", "Description") VALUES (?, ?, ?, ?)', + $sql + ); + } + $this->assertEquals(array('My Object', 1, 10, 'No description'), $parameters); + $query->execute(); + $this->assertEquals(1, DB::affected_rows()); + + // Check inserted object is correct + $firstObject = DataObject::get_one('SQLInsertTestBase', array('"Title"' => 'My Object'), false); + $this->assertNotEmpty($firstObject); + $this->assertEquals($firstObject->Title, 'My Object'); + $this->assertNotEmpty($firstObject->HasFun); + $this->assertEquals($firstObject->Age, 10); + $this->assertEquals($firstObject->Description, 'No description'); + } + + public function testMultipleRowInsert() { + $query = SQLInsert::create('"SQLInsertTestBase"'); + $query->addRow(array( + '"Title"' => 'First Object', + '"Age"' => 10, // Can't insert non-null values into only one row in a multi-row insert + '"Description"' => 'First the worst' // Nullable field, can be present in some rows + )); + $query->addRow(array( + '"Title"' => 'Second object', + '"Age"' => 12 + )); + $sql = $query->sql($parameters); + // Only test this case if using the default query builder + if(get_class(DB::get_conn()->getQueryBuilder()) === 'DBQueryBuilder') { + $this->assertSQLEquals( + 'INSERT INTO "SQLInsertTestBase" ("Title", "Age", "Description") VALUES (?, ?, ?), (?, ?, ?)', + $sql + ); + } + $this->assertEquals(array('First Object', 10, 'First the worst', 'Second object', 12, null), $parameters); + $query->execute(); + $this->assertEquals(2, DB::affected_rows()); + + // Check inserted objects are correct + $firstObject = DataObject::get_one('SQLInsertTestBase', array('"Title"' => 'First Object'), false); + $this->assertNotEmpty($firstObject); + $this->assertEquals($firstObject->Title, 'First Object'); + $this->assertEquals($firstObject->Age, 10); + $this->assertEquals($firstObject->Description, 'First the worst'); + + $secondObject = DataObject::get_one('SQLInsertTestBase', array('"Title"' => 'Second object'), false); + $this->assertNotEmpty($secondObject); + $this->assertEquals($secondObject->Title, 'Second object'); + $this->assertEquals($secondObject->Age, 12); + $this->assertEmpty($secondObject->Description); + } +} + +class SQLInsertTestBase extends DataObject implements TestOnly { + private static $db = array( + 'Title' => 'Varchar(255)', + 'HasFun' => 'Boolean', + 'Age' => 'Int', + 'Description' => 'Text', + ); +} diff --git a/tests/model/SQLQueryTest.php b/tests/model/SQLQueryTest.php index e5f5470c3..9d5cae5a9 100755 --- a/tests/model/SQLQueryTest.php +++ b/tests/model/SQLQueryTest.php @@ -1,5 +1,9 @@ assertEquals('', $query->sql()); + $query = new SQLSelect(); + $this->assertSQLEquals('', $query->sql($parameters)); } public function testSelectFromBasicTable() { - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setFrom('MyTable'); - $this->assertEquals("SELECT * FROM MyTable", $query->sql()); + $this->assertSQLEquals("SELECT * FROM MyTable", $query->sql($parameters)); $query->addFrom('MyJoin'); - $this->assertEquals("SELECT * FROM MyTable MyJoin", $query->sql()); + $this->assertSQLEquals("SELECT * FROM MyTable MyJoin", $query->sql($parameters)); } public function testSelectFromUserSpecifiedFields() { - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setSelect(array("Name", "Title", "Description")); $query->setFrom("MyTable"); - $this->assertEquals("SELECT Name, Title, Description FROM MyTable", $query->sql()); + $this->assertSQLEquals("SELECT Name, Title, Description FROM MyTable", $query->sql($parameters)); } public function testSelectWithWhereClauseFilter() { - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setSelect(array("Name","Meta")); $query->setFrom("MyTable"); $query->setWhere("Name = 'Name'"); $query->addWhere("Meta = 'Test'"); - $this->assertEquals("SELECT Name, Meta FROM MyTable WHERE (Name = 'Name') AND (Meta = 'Test')", $query->sql()); + $this->assertSQLEquals( + "SELECT Name, Meta FROM MyTable WHERE (Name = 'Name') AND (Meta = 'Test')", + $query->sql($parameters) + ); } public function testSelectWithConstructorParameters() { - $query = new SQLQuery(array("Foo", "Bar"), "FooBarTable"); - $this->assertEquals("SELECT Foo, Bar FROM FooBarTable", $query->sql()); - $query = new SQLQuery(array("Foo", "Bar"), "FooBarTable", array("Foo = 'Boo'")); - $this->assertEquals("SELECT Foo, Bar FROM FooBarTable WHERE (Foo = 'Boo')", $query->sql()); + $query = new SQLSelect(array("Foo", "Bar"), "FooBarTable"); + $this->assertSQLEquals("SELECT Foo, Bar FROM FooBarTable", $query->sql($parameters)); + $query = new SQLSelect(array("Foo", "Bar"), "FooBarTable", array("Foo = 'Boo'")); + $this->assertSQLEquals("SELECT Foo, Bar FROM FooBarTable WHERE (Foo = 'Boo')", $query->sql($parameters)); } public function testSelectWithChainedMethods() { - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setSelect("Name","Meta")->setFrom("MyTable")->setWhere("Name = 'Name'")->addWhere("Meta = 'Test'"); - $this->assertEquals("SELECT Name, Meta FROM MyTable WHERE (Name = 'Name') AND (Meta = 'Test')", $query->sql()); + $this->assertSQLEquals( + "SELECT Name, Meta FROM MyTable WHERE (Name = 'Name') AND (Meta = 'Test')", + $query->sql($parameters) + ); } public function testCanSortBy() { - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setSelect("Name","Meta")->setFrom("MyTable")->setWhere("Name = 'Name'")->addWhere("Meta = 'Test'"); $this->assertTrue($query->canSortBy('Name ASC')); $this->assertTrue($query->canSortBy('Name')); } public function testSelectWithChainedFilterParameters() { - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setSelect(array("Name","Meta"))->setFrom("MyTable"); $query->setWhere("Name = 'Name'")->addWhere("Meta = 'Test'")->addWhere("Beta != 'Gamma'"); - $this->assertEquals( + $this->assertSQLEquals( "SELECT Name, Meta FROM MyTable WHERE (Name = 'Name') AND (Meta = 'Test') AND (Beta != 'Gamma')", - $query->sql()); + $query->sql($parameters) + ); } public function testSelectWithLimitClause() { - if(!(DB::getConn() instanceof MySQLDatabase || DB::getConn() instanceof SQLite3Database - || DB::getConn() instanceof PostgreSQLDatabase)) { + if(!(DB::get_conn() instanceof MySQLDatabase || DB::get_conn() instanceof SQLite3Database + || DB::get_conn() instanceof PostgreSQLDatabase)) { $this->markTestIncomplete(); } - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setFrom("MyTable"); $query->setLimit(99); - $this->assertEquals("SELECT * FROM MyTable LIMIT 99", $query->sql()); + $this->assertSQLEquals("SELECT * FROM MyTable LIMIT 99", $query->sql($parameters)); // array limit with start (MySQL specific) - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setFrom("MyTable"); $query->setLimit(99, 97); - $this->assertEquals("SELECT * FROM MyTable LIMIT 99 OFFSET 97", $query->sql()); + $this->assertSQLEquals("SELECT * FROM MyTable LIMIT 99 OFFSET 97", $query->sql($parameters)); } public function testSelectWithOrderbyClause() { - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setFrom("MyTable"); $query->setOrderBy('MyName'); - $this->assertEquals('SELECT * FROM MyTable ORDER BY MyName ASC', $query->sql()); + $this->assertSQLEquals('SELECT * FROM MyTable ORDER BY MyName ASC', $query->sql($parameters)); - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setFrom("MyTable"); $query->setOrderBy('MyName desc'); - $this->assertEquals('SELECT * FROM MyTable ORDER BY MyName DESC', $query->sql()); + $this->assertSQLEquals('SELECT * FROM MyTable ORDER BY MyName DESC', $query->sql($parameters)); - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setFrom("MyTable"); $query->setOrderBy('MyName ASC, Color DESC'); - $this->assertEquals('SELECT * FROM MyTable ORDER BY MyName ASC, Color DESC', $query->sql()); + $this->assertSQLEquals('SELECT * FROM MyTable ORDER BY MyName ASC, Color DESC', $query->sql($parameters)); - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setFrom("MyTable"); $query->setOrderBy('MyName ASC, Color'); - $this->assertEquals('SELECT * FROM MyTable ORDER BY MyName ASC, Color ASC', $query->sql()); + $this->assertSQLEquals('SELECT * FROM MyTable ORDER BY MyName ASC, Color ASC', $query->sql($parameters)); - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setFrom("MyTable"); $query->setOrderBy(array('MyName' => 'desc')); - $this->assertEquals('SELECT * FROM MyTable ORDER BY MyName DESC', $query->sql()); + $this->assertSQLEquals('SELECT * FROM MyTable ORDER BY MyName DESC', $query->sql($parameters)); - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setFrom("MyTable"); $query->setOrderBy(array('MyName' => 'desc', 'Color')); - $this->assertEquals('SELECT * FROM MyTable ORDER BY MyName DESC, Color ASC', $query->sql()); + $this->assertSQLEquals('SELECT * FROM MyTable ORDER BY MyName DESC, Color ASC', $query->sql($parameters)); - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setFrom("MyTable"); $query->setOrderBy('implode("MyName","Color")'); - $this->assertEquals( + $this->assertSQLEquals( 'SELECT *, implode("MyName","Color") AS "_SortColumn0" FROM MyTable ORDER BY "_SortColumn0" ASC', - $query->sql()); + $query->sql($parameters)); - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setFrom("MyTable"); $query->setOrderBy('implode("MyName","Color") DESC'); - $this->assertEquals( + $this->assertSQLEquals( 'SELECT *, implode("MyName","Color") AS "_SortColumn0" FROM MyTable ORDER BY "_SortColumn0" DESC', - $query->sql()); + $query->sql($parameters)); - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setFrom("MyTable"); $query->setOrderBy('RAND()'); - $this->assertEquals( + $this->assertSQLEquals( 'SELECT *, RAND() AS "_SortColumn0" FROM MyTable ORDER BY "_SortColumn0" ASC', - $query->sql()); + $query->sql($parameters)); - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setFrom("MyTable"); $query->addFrom('INNER JOIN SecondTable USING (ID)'); $query->addFrom('INNER JOIN ThirdTable USING (ID)'); $query->setOrderBy('MyName'); - $this->assertEquals( + $this->assertSQLEquals( 'SELECT * FROM MyTable ' . 'INNER JOIN SecondTable USING (ID) ' . 'INNER JOIN ThirdTable USING (ID) ' . 'ORDER BY MyName ASC', - $query->sql()); + $query->sql($parameters)); } public function testNullLimit() { - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setFrom("MyTable"); $query->setLimit(null); - $this->assertEquals( + $this->assertSQLEquals( 'SELECT * FROM MyTable', - $query->sql() + $query->sql($parameters) ); } public function testZeroLimit() { - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setFrom("MyTable"); $query->setLimit(0); - $this->assertEquals( + $this->assertSQLEquals( 'SELECT * FROM MyTable', - $query->sql() + $query->sql($parameters) ); } public function testZeroLimitWithOffset() { - if(!(DB::getConn() instanceof MySQLDatabase || DB::getConn() instanceof SQLite3Database - || DB::getConn() instanceof PostgreSQLDatabase)) { + if(!(DB::get_conn() instanceof MySQLDatabase || DB::get_conn() instanceof SQLite3Database + || DB::get_conn() instanceof PostgreSQLDatabase)) { $this->markTestIncomplete(); } - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setFrom("MyTable"); $query->setLimit(0, 99); - $this->assertEquals( + $this->assertSQLEquals( 'SELECT * FROM MyTable LIMIT 0 OFFSET 99', - $query->sql() + $query->sql($parameters) ); } @@ -191,7 +202,7 @@ class SQLQueryTest extends SapphireTest { * @expectedException InvalidArgumentException */ public function testNegativeLimit() { - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setLimit(-10); } @@ -199,7 +210,7 @@ class SQLQueryTest extends SapphireTest { * @expectedException InvalidArgumentException */ public function testNegativeOffset() { - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setLimit(1, -10); } @@ -207,80 +218,80 @@ class SQLQueryTest extends SapphireTest { * @expectedException InvalidArgumentException */ public function testNegativeOffsetAndLimit() { - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setLimit(-10, -10); } public function testReverseOrderBy() { - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setFrom('MyTable'); // default is ASC $query->setOrderBy("Name"); $query->reverseOrderBy(); - $this->assertEquals('SELECT * FROM MyTable ORDER BY Name DESC',$query->sql()); + $this->assertSQLEquals('SELECT * FROM MyTable ORDER BY Name DESC',$query->sql($parameters)); $query->setOrderBy("Name DESC"); $query->reverseOrderBy(); - $this->assertEquals('SELECT * FROM MyTable ORDER BY Name ASC',$query->sql()); + $this->assertSQLEquals('SELECT * FROM MyTable ORDER BY Name ASC',$query->sql($parameters)); $query->setOrderBy(array("Name" => "ASC")); $query->reverseOrderBy(); - $this->assertEquals('SELECT * FROM MyTable ORDER BY Name DESC',$query->sql()); + $this->assertSQLEquals('SELECT * FROM MyTable ORDER BY Name DESC',$query->sql($parameters)); $query->setOrderBy(array("Name" => 'DESC', 'Color' => 'asc')); $query->reverseOrderBy(); - $this->assertEquals('SELECT * FROM MyTable ORDER BY Name ASC, Color DESC',$query->sql()); + $this->assertSQLEquals('SELECT * FROM MyTable ORDER BY Name ASC, Color DESC',$query->sql($parameters)); $query->setOrderBy('implode("MyName","Color") DESC'); $query->reverseOrderBy(); - $this->assertEquals( + $this->assertSQLEquals( 'SELECT *, implode("MyName","Color") AS "_SortColumn0" FROM MyTable ORDER BY "_SortColumn0" ASC', - $query->sql()); + $query->sql($parameters)); } public function testFiltersOnID() { - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setWhere("ID = 5"); $this->assertTrue( $query->filtersOnID(), "filtersOnID() is true with simple unquoted column name" ); - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setWhere("ID=5"); $this->assertTrue( $query->filtersOnID(), "filtersOnID() is true with simple unquoted column name and no spaces in equals sign" ); - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setWhere("Identifier = 5"); $this->assertFalse( $query->filtersOnID(), "filtersOnID() is false with custom column name (starting with 'id')" ); - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setWhere("ParentID = 5"); $this->assertFalse( $query->filtersOnID(), "filtersOnID() is false with column name ending in 'ID'" ); - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setWhere("MyTable.ID = 5"); $this->assertTrue( $query->filtersOnID(), "filtersOnID() is true with table and column name" ); - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setWhere("MyTable.ID = 5"); $this->assertTrue( $query->filtersOnID(), @@ -289,28 +300,28 @@ class SQLQueryTest extends SapphireTest { } public function testFiltersOnFK() { - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setWhere("ID = 5"); $this->assertFalse( $query->filtersOnFK(), "filtersOnFK() is true with simple unquoted column name" ); - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setWhere("Identifier = 5"); $this->assertFalse( $query->filtersOnFK(), "filtersOnFK() is false with custom column name (starting with 'id')" ); - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setWhere("MyTable.ParentID = 5"); $this->assertTrue( $query->filtersOnFK(), "filtersOnFK() is true with table and column name" ); - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setWhere("MyTable.`ParentID`= 5"); $this->assertTrue( $query->filtersOnFK(), @@ -319,40 +330,45 @@ class SQLQueryTest extends SapphireTest { } public function testInnerJoin() { - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setFrom('MyTable'); $query->addInnerJoin('MyOtherTable', 'MyOtherTable.ID = 2'); $query->addLeftJoin('MyLastTable', 'MyOtherTable.ID = MyLastTable.ID'); - $this->assertEquals('SELECT * FROM MyTable '. + $this->assertSQLEquals('SELECT * FROM MyTable '. 'INNER JOIN "MyOtherTable" ON MyOtherTable.ID = 2 '. 'LEFT JOIN "MyLastTable" ON MyOtherTable.ID = MyLastTable.ID', - $query->sql() + $query->sql($parameters) ); - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setFrom('MyTable'); $query->addInnerJoin('MyOtherTable', 'MyOtherTable.ID = 2', 'table1'); $query->addLeftJoin('MyLastTable', 'MyOtherTable.ID = MyLastTable.ID', 'table2'); - $this->assertEquals('SELECT * FROM MyTable '. + $this->assertSQLEquals('SELECT * FROM MyTable '. 'INNER JOIN "MyOtherTable" AS "table1" ON MyOtherTable.ID = 2 '. 'LEFT JOIN "MyLastTable" AS "table2" ON MyOtherTable.ID = MyLastTable.ID', - $query->sql() + $query->sql($parameters) ); } public function testSetWhereAny() { - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setFrom('MyTable'); - $query->setWhereAny(array("Monkey = 'Chimp'", "Color = 'Brown'")); - $this->assertEquals("SELECT * FROM MyTable WHERE (Monkey = 'Chimp' OR Color = 'Brown')",$query->sql()); + $query->setWhereAny(array( + 'Monkey' => 'Chimp', + 'Color' => 'Brown' + )); + $sql = $query->sql($parameters); + $this->assertSQLEquals("SELECT * FROM MyTable WHERE ((Monkey = ?) OR (Color = ?))", $sql); + $this->assertEquals(array('Chimp', 'Brown'), $parameters); } public function testSelectFirst() { // Test first from sequence - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setFrom('"SQLQueryTest_DO"'); $query->setOrderBy('"Name"'); $result = $query->firstRow()->execute(); @@ -366,10 +382,10 @@ class SQLQueryTest extends SapphireTest { $this->assertEquals('Object 1', $records[0]['Name']); // Test first from empty sequence - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setFrom('"SQLQueryTest_DO"'); $query->setOrderBy('"Name"'); - $query->setWhere(array("\"Name\" = 'Nonexistent Object'")); + $query->setWhere(array('"Name"' => 'Nonexistent Object')); $result = $query->firstRow()->execute(); $records = array(); @@ -380,7 +396,7 @@ class SQLQueryTest extends SapphireTest { $this->assertCount(0, $records); // Test that given the last item, the 'first' in this list matches the last - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setFrom('"SQLQueryTest_DO"'); $query->setOrderBy('"Name"'); $query->setLimit(1, 1); @@ -397,7 +413,7 @@ class SQLQueryTest extends SapphireTest { public function testSelectLast() { // Test last in sequence - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setFrom('"SQLQueryTest_DO"'); $query->setOrderBy('"Name"'); $result = $query->lastRow()->execute(); @@ -411,7 +427,7 @@ class SQLQueryTest extends SapphireTest { $this->assertEquals('Object 2', $records[0]['Name']); // Test last from empty sequence - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setFrom('"SQLQueryTest_DO"'); $query->setOrderBy('"Name"'); $query->setWhere(array("\"Name\" = 'Nonexistent Object'")); @@ -425,7 +441,7 @@ class SQLQueryTest extends SapphireTest { $this->assertCount(0, $records); // Test that given the first item, the 'last' in this list matches the first - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setFrom('"SQLQueryTest_DO"'); $query->setOrderBy('"Name"'); $query->setLimit(1); @@ -444,7 +460,7 @@ class SQLQueryTest extends SapphireTest { * Tests aggregate() function */ public function testAggregate() { - $query = new SQLQuery(); + $query = new SQLSelect('"Common"'); $query->setFrom('"SQLQueryTest_DO"'); $query->setGroupBy('"Common"'); @@ -457,7 +473,7 @@ class SQLQueryTest extends SapphireTest { * Tests that an ORDER BY is only added if a LIMIT is set. */ public function testAggregateNoOrderByIfNoLimit() { - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setFrom('"SQLQueryTest_DO"'); $query->setOrderBy('Common'); $query->setLimit(array()); @@ -467,7 +483,7 @@ class SQLQueryTest extends SapphireTest { $this->assertEquals(array(), $aggregate->getOrderBy()); $this->assertEquals(array(), $limit); - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setFrom('"SQLQueryTest_DO"'); $query->setOrderBy('Common'); $query->setLimit(2); @@ -485,7 +501,7 @@ class SQLQueryTest extends SapphireTest { * because a subselect needs to be done to query paginated data. */ public function testOrderByContainingAggregateAndLimitOffset() { - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setSelect(array('"Name"', '"Meta"')); $query->setFrom('"SQLQueryTest_DO"'); $query->setOrderBy(array('MAX("Date")')); @@ -507,8 +523,8 @@ class SQLQueryTest extends SapphireTest { * Test that multiple order elements are maintained in the given order */ public function testOrderByMultiple() { - if(DB::getConn() instanceof MySQLDatabase) { - $query = new SQLQuery(); + if(DB::get_conn() instanceof MySQLDatabase) { + $query = new SQLSelect(); $query->setSelect(array('"Name"', '"Meta"')); $query->setFrom('"SQLQueryTest_DO"'); $query->setOrderBy(array('MID("Name", 8, 1) DESC', '"Name" ASC')); @@ -532,7 +548,7 @@ class SQLQueryTest extends SapphireTest { * Test passing in a LIMIT with OFFSET clause string. */ public function testLimitSetFromClauseString() { - $query = new SQLQuery(); + $query = new SQLSelect(); $query->setSelect('*'); $query->setFrom('"SQLQueryTest_DO"'); diff --git a/tests/model/SQLUpdateTest.php b/tests/model/SQLUpdateTest.php new file mode 100644 index 000000000..b1057411b --- /dev/null +++ b/tests/model/SQLUpdateTest.php @@ -0,0 +1,56 @@ +assertSQLEquals('', $query->sql($parameters)); + } + + public function testBasicUpdate() { + $query = SQLUpdate::create() + ->setTable('"SQLUpdateTestBase"') + ->assign('"Description"', 'Description 1a') + ->addWhere(array('"Title" = ?' => 'Object 1')); + $sql = $query->sql($parameters); + + // Check SQL + $this->assertSQLEquals('UPDATE "SQLUpdateTestBase" SET "Description" = ? WHERE ("Title" = ?)', $sql); + $this->assertEquals(array('Description 1a', 'Object 1'), $parameters); + + // Check affected rows + $query->execute(); + $this->assertEquals(1, DB::affected_rows()); + + // Check item updated + $item = DataObject::get_one('SQLUpdateTestBase', array('"Title"' => 'Object 1')); + $this->assertEquals('Description 1a', $item->Description); + } +} + +class SQLUpdateTestBase extends DataObject implements TestOnly { + private static $db = array( + 'Title' => 'Varchar(255)', + 'Description' => 'Text' + ); +} + +class SQLUpdateChild extends SQLUpdateTestBase { + private static $db = array( + 'Details' => 'Varchar(255)' + ); +} diff --git a/tests/model/SQLUpdateTest.yml b/tests/model/SQLUpdateTest.yml new file mode 100644 index 000000000..f1f4729a4 --- /dev/null +++ b/tests/model/SQLUpdateTest.yml @@ -0,0 +1,12 @@ +SQLUpdateTestBase: + test1: + Title: 'Object 1' + Description: 'Description 1' + test2: + Title: 'Object 2' + Description: 'Description 2' +SQLUpdateChild: + test3: + Title: 'Object 3' + Description: 'Description 3' + Details: 'Details 3' diff --git a/tests/model/TransactionTest.php b/tests/model/TransactionTest.php index 92ffc2688..3cee18f66 100644 --- a/tests/model/TransactionTest.php +++ b/tests/model/TransactionTest.php @@ -11,8 +11,8 @@ class TransactionTest extends SapphireTest { public function testCreateWithTransaction() { - if(DB::getConn()->supportsTransactions()==true){ - DB::getConn()->transactionStart(); + if(DB::get_conn()->supportsTransactions()==true){ + DB::get_conn()->transactionStart(); $obj=new TransactionTest_Object(); $obj->Title='First page'; $obj->write(); @@ -22,7 +22,7 @@ class TransactionTest extends SapphireTest { $obj->write(); //Create a savepoint here: - DB::getConn()->transactionSavepoint('rollback'); + DB::get_conn()->transactionSavepoint('rollback'); $obj=new TransactionTest_Object(); $obj->Title='Third page'; @@ -33,9 +33,9 @@ class TransactionTest extends SapphireTest { $obj->write(); //Revert to a savepoint: - DB::getConn()->transactionRollback('rollback'); + DB::get_conn()->transactionRollback('rollback'); - DB::getConn()->transactionEnd(); + DB::get_conn()->transactionEnd(); $first=DataObject::get('TransactionTest_Object', "\"Title\"='First page'"); $second=DataObject::get('TransactionTest_Object', "\"Title\"='Second page'"); diff --git a/tests/model/VersionedTest.php b/tests/model/VersionedTest.php index 9ddec388f..671b1c1f4 100644 --- a/tests/model/VersionedTest.php +++ b/tests/model/VersionedTest.php @@ -22,55 +22,35 @@ class VersionedTest extends SapphireTest { ); public function testUniqueIndexes() { - $table_expectations = array( + $tableExpectations = array( 'VersionedTest_WithIndexes' => - array('value' => 1, 'message' => 'Unique indexes are unique in main table'), + array('value' => true, 'message' => 'Unique indexes are unique in main table'), 'VersionedTest_WithIndexes_versions' => - array('value' => 0, 'message' => 'Unique indexes are no longer unique in _versions table'), + array('value' => false, 'message' => 'Unique indexes are no longer unique in _versions table'), 'VersionedTest_WithIndexes_Live' => - array('value' => 0, 'message' => 'Unique indexes are no longer unique in _Live table'), + array('value' => false, 'message' => 'Unique indexes are no longer unique in _Live table'), ); - // Check for presence of all unique indexes - $db = DB::getConn(); - $db_class = get_class($db); - $tables = array_keys($table_expectations); - switch ($db_class) { - case 'MySQLDatabase': - $our_indexes = array('UniqA_idx', 'UniqS_idx'); - foreach ($tables as $t) { - $indexes = array_keys($db->indexList($t)); - sort($indexes); - $this->assertEquals( - array_values($our_indexes), array_values(array_intersect($indexes, $our_indexes)), - "$t has both indexes"); - } - break; - case 'SQLite3Database': - $our_indexes = array('"UniqA"', '"UniqS"'); - foreach ($tables as $t) { - $indexes = array_values($db->indexList($t)); - sort($indexes); - $this->assertEquals(array_values($our_indexes), - array_values(array_intersect(array_values($indexes), $our_indexes)), "$t has both indexes"); - } - break; - default: - $this->markTestSkipped("Test for DBMS $db_class not implemented; skipped."); - break; - } + // Test each table's performance + foreach ($tableExpectations as $tableName => $expectation) { + $indexes = DB::get_schema()->indexList($tableName); + + // Check for presence of all unique indexes + $indexColumns = array_map(function($index) { + return $index['value']; + }, $indexes); + sort($indexColumns); + $expectedColumns = array('"UniqA"', '"UniqS"'); + $this->assertEquals( + array_values($expectedColumns), + array_values(array_intersect($indexColumns, $expectedColumns)), + "$tableName has both indexes"); - // Check unique -> non-unique conversion - foreach ($table_expectations as $table_name => $expectation) { - $indexes = $db->indexList($table_name); - - foreach ($indexes as $idx_name => $idx_value) { - if (in_array($idx_name, $our_indexes)) { - $match_value = preg_match('/unique/', $idx_value); - if (false === $match_value) { - user_error('preg_match failure'); - } - $this->assertEquals($match_value, $expectation['value'], $expectation['message']); + // Check unique -> non-unique conversion + foreach ($indexes as $indexKey => $indexSpec) { + if (in_array($indexSpec['value'], $expectedColumns)) { + $isUnique = $indexSpec['type'] === 'unique'; + $this->assertEquals($isUnique, $expectation['value'], $expectation['message']); } } } @@ -270,12 +250,14 @@ class VersionedTest extends SapphireTest { $page->URLSegment = "testWritingNewToStage"; $page->write(); - $live = Versioned::get_by_stage('VersionedTest_DataObject', 'Live', - "\"VersionedTest_DataObject_Live\".\"ID\"='$page->ID'"); + $live = Versioned::get_by_stage('VersionedTest_DataObject', 'Live', array( + '"VersionedTest_DataObject_Live"."ID"' => $page->ID + )); $this->assertEquals(0, $live->count()); - $stage = Versioned::get_by_stage('VersionedTest_DataObject', 'Stage', - "\"VersionedTest_DataObject\".\"ID\"='$page->ID'"); + $stage = Versioned::get_by_stage('VersionedTest_DataObject', 'Stage',array( + '"VersionedTest_DataObject"."ID"' => $page->ID + )); $this->assertEquals(1, $stage->count()); $this->assertEquals($stage->First()->Title, 'testWritingNewToStage'); @@ -297,13 +279,15 @@ class VersionedTest extends SapphireTest { $page->URLSegment = "testWritingNewToLive"; $page->write(); - $live = Versioned::get_by_stage('VersionedTest_DataObject', 'Live', - "\"VersionedTest_DataObject_Live\".\"ID\"='$page->ID'"); + $live = Versioned::get_by_stage('VersionedTest_DataObject', 'Live',array( + '"VersionedTest_DataObject_Live"."ID"' => $page->ID + )); $this->assertEquals(1, $live->count()); $this->assertEquals($live->First()->Title, 'testWritingNewToLive'); - $stage = Versioned::get_by_stage('VersionedTest_DataObject', 'Stage', - "\"VersionedTest_DataObject\".\"ID\"='$page->ID'"); + $stage = Versioned::get_by_stage('VersionedTest_DataObject', 'Stage',array( + '"VersionedTest_DataObject"."ID"' => $page->ID + )); $this->assertEquals(0, $stage->count()); Versioned::reading_stage($origStage); @@ -337,7 +321,7 @@ class VersionedTest extends SapphireTest { } /** - * Test that SQLQuery::queriedTables() applies the version-suffixes properly. + * Test that SQLSelect::queriedTables() applies the version-suffixes properly. */ public function testQueriedTables() { Versioned::reading_stage('Live'); @@ -510,25 +494,25 @@ class VersionedTest extends SapphireTest { } public function testVersionedWithSingleStage() { - $tables = DB::tableList(); + $tables = DB::table_list(); $this->assertContains( - 'VersionedTest_SingleStage', - array_values($tables), + 'versionedtest_singlestage', + array_keys($tables), 'Contains base table' ); $this->assertContains( - 'VersionedTest_SingleStage_versions', - array_values($tables), + 'versionedtest_singlestage_versions', + array_keys($tables), 'Contains versions table' ); $this->assertNotContains( - 'VersionedTest_SingleStage_Live', - array_values($tables), + 'versionedtest_singlestage_live', + array_keys($tables), 'Does not contain separate table with _Live suffix' ); $this->assertNotContains( - 'VersionedTest_SingleStage_Stage', - array_values($tables), + 'versionedtest_singlestage_stage', + array_keys($tables), 'Does not contain separate table with _Stage suffix' ); @@ -733,4 +717,4 @@ class VersionedTest_SingleStage extends DataObject implements TestOnly { private static $extensions = array( 'Versioned("Stage")' ); -} \ No newline at end of file +} diff --git a/tests/security/BasicAuthTest.php b/tests/security/BasicAuthTest.php index 686db0b50..ac88fa7e7 100644 --- a/tests/security/BasicAuthTest.php +++ b/tests/security/BasicAuthTest.php @@ -16,6 +16,7 @@ class BasicAuthTest extends FunctionalTest { // Fixtures assume Email is the field used to identify the log in identity self::$original_unique_identifier_field = Member::config()->unique_identifier_field; Member::config()->unique_identifier_field = 'Email'; + Security::$force_database_is_ready = true; // Prevents Member test subclasses breaking ready test } public function tearDown() { @@ -23,6 +24,7 @@ class BasicAuthTest extends FunctionalTest { BasicAuth::protect_entire_site(false); Member::config()->unique_identifier_field = self::$original_unique_identifier_field; + Security::$force_database_is_ready = null; } public function testBasicAuthEnabledWithoutLogin() { diff --git a/tests/security/MemberCsvBulkLoaderTest.php b/tests/security/MemberCsvBulkLoaderTest.php index 400103f61..a2c440757 100644 --- a/tests/security/MemberCsvBulkLoaderTest.php +++ b/tests/security/MemberCsvBulkLoaderTest.php @@ -54,7 +54,9 @@ class MemberCsvBulkLoaderTest extends SapphireTest { $loader = new MemberCsvBulkLoader(); $results = $loader->load($this->getCurrentRelativePath() . '/MemberCsvBulkLoaderTest_withGroups.csv'); - $newgroup = DataObject::get_one('Group', sprintf('"Code" = \'%s\'', 'newgroup')); + $newgroup = DataObject::get_one('Group', array( + '"Group"."Code"' => 'newgroup' + )); $this->assertEquals($newgroup->Title, 'newgroup'); $created = $results->Created()->toArray(); diff --git a/tests/security/MemberTest.php b/tests/security/MemberTest.php index 237dc31e2..c7e34db33 100644 --- a/tests/security/MemberTest.php +++ b/tests/security/MemberTest.php @@ -367,7 +367,9 @@ class MemberTest extends FunctionalTest { $grouplessMember->addToGroupByCode('somegroupthatwouldneverexist', 'New Group'); $this->assertEquals($grouplessMember->Groups()->Count(), 2); - $group = DataObject::get_one('Group', "\"Code\" = 'somegroupthatwouldneverexist'"); + $group = DataObject::get_one('Group', array( + '"Group"."Code"' => 'somegroupthatwouldneverexist' + )); $this->assertNotNull($group); $this->assertEquals($group->Code, 'somegroupthatwouldneverexist'); $this->assertEquals($group->Title, 'New Group'); diff --git a/tests/security/SecurityTest.php b/tests/security/SecurityTest.php index d8259d599..d50704785 100644 --- a/tests/security/SecurityTest.php +++ b/tests/security/SecurityTest.php @@ -399,16 +399,22 @@ class SecurityTest extends FunctionalTest { /* UNSUCCESSFUL ATTEMPTS WITH WRONG PASSWORD FOR EXISTING USER ARE LOGGED */ $this->doTestLoginForm('sam@silverstripe.com', 'wrongpassword'); - $attempt = DataObject::get_one('LoginAttempt', "\"Email\" = 'sam@silverstripe.com'"); + $attempt = DataObject::get_one('LoginAttempt', array( + '"LoginAttempt"."Email"' => 'sam@silverstripe.com' + )); $this->assertTrue(is_object($attempt)); - $member = DataObject::get_one('Member', "\"Email\" = 'sam@silverstripe.com'"); + $member = DataObject::get_one('Member', array( + '"Member"."Email"' => 'sam@silverstripe.com' + )); $this->assertEquals($attempt->Status, 'Failure'); $this->assertEquals($attempt->Email, 'sam@silverstripe.com'); $this->assertEquals($attempt->Member(), $member); /* UNSUCCESSFUL ATTEMPTS WITH NONEXISTING USER ARE LOGGED */ $this->doTestLoginForm('wronguser@silverstripe.com', 'wrongpassword'); - $attempt = DataObject::get_one('LoginAttempt', "\"Email\" = 'wronguser@silverstripe.com'"); + $attempt = DataObject::get_one('LoginAttempt', array( + '"LoginAttempt"."Email"' => 'wronguser@silverstripe.com' + )); $this->assertTrue(is_object($attempt)); $this->assertEquals($attempt->Status, 'Failure'); $this->assertEquals($attempt->Email, 'wronguser@silverstripe.com'); @@ -422,8 +428,12 @@ class SecurityTest extends FunctionalTest { /* SUCCESSFUL ATTEMPTS ARE LOGGED */ $this->doTestLoginForm('sam@silverstripe.com', '1nitialPassword'); - $attempt = DataObject::get_one('LoginAttempt', "\"Email\" = 'sam@silverstripe.com'"); - $member = DataObject::get_one('Member', "\"Email\" = 'sam@silverstripe.com'"); + $attempt = DataObject::get_one('LoginAttempt', array( + '"LoginAttempt"."Email"' => 'sam@silverstripe.com' + )); + $member = DataObject::get_one('Member', array( + '"Member"."Email"' => 'sam@silverstripe.com' + )); $this->assertTrue(is_object($attempt)); $this->assertEquals($attempt->Status, 'Success'); $this->assertEquals($attempt->Email, 'sam@silverstripe.com'); @@ -438,7 +448,7 @@ class SecurityTest extends FunctionalTest { // Assumption: The database has been built correctly by the test runner, // and has all columns present in the ORM - DB::getConn()->renameField('Member', 'Email', 'Email_renamed'); + DB::get_schema()->renameField('Member', 'Email', 'Email_renamed'); // Email column is now missing, which means we're not ready to do permission checks $this->assertFalse(Security::database_is_ready());