diff --git a/.travis.yml b/.travis.yml index bd0662183..97b8b7b1b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,10 +7,6 @@ addons: packages: - tidy -cache: - directories: - - $HOME/.composer/cache - php: - 5.4 diff --git a/admin/code/LeftAndMain.php b/admin/code/LeftAndMain.php index 049e6a3f5..3a3d3b066 100644 --- a/admin/code/LeftAndMain.php +++ b/admin/code/LeftAndMain.php @@ -76,7 +76,7 @@ class LeftAndMain extends Controller implements PermissionProvider { * @config * @var string */ - private static $help_link = 'http://userhelp.silverstripe.org/en/3.2/'; + private static $help_link = '//userhelp.silverstripe.org/framework/en/3.2'; /** * @var array @@ -1618,7 +1618,7 @@ class LeftAndMain extends Controller implements PermissionProvider { * @config * @var String */ - private static $application_link = 'http://www.silverstripe.org/'; + private static $application_link = '//www.silverstripe.org/'; /** * Sets the href for the anchor on the Silverstripe logo in the menu diff --git a/admin/tests/LeftAndMainTest.php b/admin/tests/LeftAndMainTest.php index 1940c18af..f594b43a8 100644 --- a/admin/tests/LeftAndMainTest.php +++ b/admin/tests/LeftAndMainTest.php @@ -135,7 +135,7 @@ class LeftAndMainTest extends FunctionalTest { $link = $menuItem->Link; // don't test external links - if(preg_match('/^https?:\/\//',$link)) continue; + if(preg_match('/^(https?:)?\/\//',$link)) continue; $response = $this->get($link); diff --git a/control/injector/SilverStripeServiceConfigurationLocator.php b/control/injector/SilverStripeServiceConfigurationLocator.php index 51cf31538..b26b6fcd9 100644 --- a/control/injector/SilverStripeServiceConfigurationLocator.php +++ b/control/injector/SilverStripeServiceConfigurationLocator.php @@ -11,21 +11,22 @@ class SilverStripeServiceConfigurationLocator extends ServiceConfigurationLocato /** * List of Injector configurations cached from Config in class => config format. - * If any config is false, this denotes that this class and all its parents + * If any config is false, this denotes that this class and all its parents * have no configuration specified. - * + * * @var array */ protected $configs = array(); public function locateConfigFor($name) { + // Check direct or cached result $config = $this->configFor($name); if($config !== null) return $config; - + // do parent lookup if it's a class if (class_exists($name)) { - $parents = array_reverse(array_keys(ClassInfo::ancestry($name))); + $parents = array_reverse(array_values(ClassInfo::ancestry($name))); array_shift($parents); foreach ($parents as $parent) { @@ -38,27 +39,27 @@ class SilverStripeServiceConfigurationLocator extends ServiceConfigurationLocato } } } - + // there is no parent config, so we'll record that as false so we don't do the expensive // lookup through parents again $this->configs[$name] = false; } - + /** * Retrieves the config for a named service without performing a hierarchy walk - * + * * @param string $name Name of service - * @return mixed Returns either the configuration data, if there is any. A missing config is denoted + * @return mixed Returns either the configuration data, if there is any. A missing config is denoted * by a value of either null (there is no direct config assigned and a hierarchy walk is necessary) - * or false (there is no config for this class, nor within the hierarchy for this class). + * or false (there is no config for this class, nor within the hierarchy for this class). */ protected function configFor($name) { - + // Return cached result if (isset($this->configs[$name])) { return $this->configs[$name]; // Potentially false } - + $config = Config::inst()->get('Injector', $name); if ($config) { $this->configs[$name] = $config; @@ -67,4 +68,4 @@ class SilverStripeServiceConfigurationLocator extends ServiceConfigurationLocato return null; } } -} \ No newline at end of file +} diff --git a/core/ClassInfo.php b/core/ClassInfo.php index a7bab59bb..b6aac6a56 100644 --- a/core/ClassInfo.php +++ b/core/ClassInfo.php @@ -3,8 +3,8 @@ /** * Provides introspection information about the class tree. * - * It's a cached wrapper around the built-in class functions. SilverStripe uses - * class introspection heavily and without the caching it creates an unfortunate + * It's a cached wrapper around the built-in class functions. SilverStripe uses + * class introspection heavily and without the caching it creates an unfortunate * performance hit. * * @package framework @@ -61,6 +61,7 @@ class ClassInfo { * @return array List of subclasses */ public static function getValidSubClasses($class = 'SiteTree', $includeUnbacked = false) { + $class = self::class_name($class); $classes = DB::get_schema()->enumValuesForField($class, 'ClassName'); if (!$includeUnbacked) $classes = array_filter($classes, array('ClassInfo', 'exists')); return $classes; @@ -77,9 +78,7 @@ class ClassInfo { public static function dataClassesFor($class) { $result = array(); - if (is_object($class)) { - $class = get_class($class); - } + $class = self::class_name($class); $classes = array_merge( self::ancestry($class), @@ -101,7 +100,7 @@ class ClassInfo { * @return string */ public static function baseDataClass($class) { - if (is_object($class)) $class = get_class($class); + $class = self::class_name($class); if (!is_subclass_of($class, 'DataObject')) { throw new InvalidArgumentException("$class is not a subclass of DataObject"); @@ -125,7 +124,7 @@ class ClassInfo { * * ClassInfo::subclassesFor('BaseClass'); * array( - * 0 => 'BaseClass', + * 'BaseClass' => 'BaseClass', * 'ChildClass' => 'ChildClass', * 'GrandChildClass' => 'GrandChildClass' * ) @@ -135,8 +134,10 @@ class ClassInfo { * @return array Names of all subclasses as an associative array. */ public static function subclassesFor($class) { + //normalise class case + $className = self::class_name($class); $descendants = SS_ClassLoader::instance()->getManifest()->getDescendantsOf($class); - $result = array($class => $class); + $result = array($className => $className); if ($descendants) { return $result + ArrayLib::valuekey($descendants); @@ -145,6 +146,23 @@ class ClassInfo { } } + /** + * Convert a class name in any case and return it as it was defined in PHP + * + * eg: self::class_name('dataobJEct'); //returns 'DataObject' + * + * @param string|object $nameOrObject The classname or object you want to normalise + * + * @return string The normalised class name + */ + public static function class_name($nameOrObject) { + if (is_object($nameOrObject)) { + return get_class($nameOrObject); + } + $reflection = new ReflectionClass($nameOrObject); + return $reflection->getName(); + } + /** * Returns the passed class name along with all its parent class names in an * array, sorted with the root class first. @@ -154,9 +172,11 @@ class ClassInfo { * @return array */ public static function ancestry($class, $tablesOnly = false) { - if (!is_string($class)) $class = get_class($class); + $class = self::class_name($class); - $cacheKey = $class . '_' . (string)$tablesOnly; + $lClass = strtolower($class); + + $cacheKey = $lClass . '_' . (string)$tablesOnly; $parent = $class; if(!isset(self::$_cache_ancestry[$cacheKey])) { $ancestry = array(); @@ -183,7 +203,7 @@ class ClassInfo { * Returns true if the given class implements the given interface */ public static function classImplements($className, $interfaceName) { - return in_array($className, SS_ClassLoader::instance()->getManifest()->getImplementorsOf($interfaceName)); + return in_array($className, self::implementorsOf($interfaceName)); } /** @@ -232,24 +252,28 @@ class ClassInfo { private static $method_from_cache = array(); public static function has_method_from($class, $method, $compclass) { - if (!isset(self::$method_from_cache[$class])) self::$method_from_cache[$class] = array(); + $lClass = strtolower($class); + $lMethod = strtolower($method); + $lCompclass = strtolower($compclass); + if (!isset(self::$method_from_cache[$lClass])) self::$method_from_cache[$lClass] = array(); - if (!array_key_exists($method, self::$method_from_cache[$class])) { - self::$method_from_cache[$class][$method] = false; + if (!array_key_exists($lMethod, self::$method_from_cache[$lClass])) { + self::$method_from_cache[$lClass][$lMethod] = false; $classRef = new ReflectionClass($class); if ($classRef->hasMethod($method)) { $methodRef = $classRef->getMethod($method); - self::$method_from_cache[$class][$method] = $methodRef->getDeclaringClass()->getName(); + self::$method_from_cache[$lClass][$lMethod] = $methodRef->getDeclaringClass()->getName(); } } - return self::$method_from_cache[$class][$method] == $compclass; + return strtolower(self::$method_from_cache[$lClass][$lMethod]) == $lCompclass; } + /** - * Returns the table name in the class hierarchy which contains a given + * Returns the table name in the class hierarchy which contains a given * field column for a {@link DataObject}. If the field does not exist, this * will return null. * @@ -259,23 +283,26 @@ class ClassInfo { * @return string */ public static function table_for_object_field($candidateClass, $fieldName) { - if(!$candidateClass || !$fieldName) { + if(!$candidateClass || !$fieldName || !is_subclass_of($candidateClass, 'DataObject')) { return null; } - $exists = class_exists($candidateClass); + //normalise class name + $candidateClass = self::class_name($candidateClass); + + $exists = self::exists($candidateClass); while($candidateClass && $candidateClass != 'DataObject' && $exists) { if(DataObject::has_own_table($candidateClass)) { $inst = singleton($candidateClass); - + if($inst->hasOwnTableDatabaseField($fieldName)) { break; } } $candidateClass = get_parent_class($candidateClass); - $exists = class_exists($candidateClass); + $exists = $candidateClass && self::exists($candidateClass); } if(!$candidateClass || !$exists) { diff --git a/docs/en/02_Developer_Guides/12_Search/02_FulltextSearch.md b/docs/en/02_Developer_Guides/12_Search/02_FulltextSearch.md index 11fdb7c20..82153ca2d 100644 --- a/docs/en/02_Developer_Guides/12_Search/02_FulltextSearch.md +++ b/docs/en/02_Developer_Guides/12_Search/02_FulltextSearch.md @@ -39,6 +39,43 @@ records and cannot easily be adapted to include custom `DataObject` instances. T default site search, have a look at those extensions and modify as required. +### Fulltext Filter + +SilverStripe provides a `[api:FulltextFiler]` which you can use to perform custom fulltext searches on +`[api:DataList]`'s. + +Example DataObject: + + :::php + class SearchableDataObject extends DataObject { + + private static $db = array( + "Title" => "Varchar(255)", + "Content" => "HTMLText", + ); + + private static $indexes = array( + 'SearchFields' => array( + 'type' => 'fulltext', + 'name' => 'SearchFields', + 'value' => '"Title", "Content"', + ) + ); + + private static $create_table_options = array( + 'MySQLDatabase' => 'ENGINE=MyISAM' + ); + + } + +Performing the search: + + :::php + SearchableDataObject::get()->filter('SearchFields:fulltext', 'search term'); + +If your search index is a single field size, then you may also specify the search filter by the name of the +field instead of the index. + ## API Documentation * [api:FulltextSearchable] \ No newline at end of file diff --git a/docs/en/05_Contributing/02_Release_Process.md b/docs/en/05_Contributing/02_Release_Process.md index a85ab1699..d7ee1faf3 100644 --- a/docs/en/05_Contributing/02_Release_Process.md +++ b/docs/en/05_Contributing/02_Release_Process.md @@ -2,7 +2,7 @@ summary: Describes the process followed for "core" releases. # Release Process -Describes the process followed for "core" releases (mainly the `framework` and `cms` modules). +This page describes the process followed for "core" releases (mainly the `framework` and `cms` modules). ## Release Maintainer @@ -19,13 +19,13 @@ Release dates are usually not published prior to the release, but you can get a reviewing the release milestone on github.com. Releases will be announced on the [release announcements mailing list](http://groups.google.com/group/silverstripe-announce). -Releases of the *cms* and *framework* modules are coupled at the moment, they follow the same numbering scheme. +Releases of the *cms* and *framework* modules are coupled at the moment, and they follow the same numbering scheme. ## Release Numbering SilverStripe follows [Semantic Versioning](http://semver.org). -Note: Until November 2014, the project didn't adhere to Semantic Versioning. Instead. a "minor release" in semver terminology +Note: Until November 2014, the project didn't adhere to Semantic Versioning. Instead, a "minor release" in semver terminology was treated as a "major release" in SilverStripe. For example, the *3.1.0* release contained API breaking changes, and the *3.1.1* release contained new features rather than just bugfixes. @@ -43,7 +43,7 @@ patch release ## Deprecation Needs of developers (both on core framework and custom projects) can outgrow the capabilities -of a certain API. Existing APIs might turn out to be hard to understand, maintain, test or stabilize. +of a certain API. Existing APIs might turn out to be hard to understand, maintain, test or stabilise. In these cases, it is best practice to "refactor" these APIs into something more useful. SilverStripe acknowledges that developers have built a lot of code on top of existing APIs, so we strive for giving ample warning on any upcoming changes through a "deprecation cycle". @@ -53,17 +53,15 @@ How to deprecate an API: * Add a `@deprecated` item to the docblock tag, with a `{@link }` item pointing to the new API to use. * Update the deprecated code to throw a `[api:Deprecation::notice()]` error. * Both the docblock and error message should contain the **target version** where the functionality is removed. - So if you're committing the change to a *3.1* minor release, the target version will be *4.0*. + So, if you're committing the change to a *3.1* minor release, the target version will be *4.0*. * Deprecations should not be committed to patch releases -* Deprecations should just be committed to pre-release branches, ideally before they enter the "beta" phase. +* Deprecations should only be committed to pre-release branches, ideally before they enter the "beta" phase. If deprecations are introduced after this point, their target version needs to be increased by one. * Make sure that the old deprecated function works by calling the new function - don't have duplicated code! * The commit message should contain an `API` prefix (see ["commit message format"](code#commit-messages)) * Document the change in the [changelog](/changelogs) for the next release -* Deprecated APIs can be removed after developers had a chance to react to the changes. As a rule of thumb, leave the -code with the deprecation warning in for at least three micro releases. Only remove code in a minor or major release. -* Exceptions to the deprecation cycle are APIs that have been moved into their own module, and continue to work with the -new minor release. These changes can be performed in a single minor release without a deprecation period. +* Deprecated APIs can be removed only after developers have had sufficient time to react to the changes. Hence, deprecated APIs should be removed in MAJOR releases only. Between MAJOR releases, leave the code in place with a deprecation warning. +* Exceptions to the deprecation cycle are APIs that have been moved into their own module, and continue to work with the new minor release. These changes can be performed in a single minor release without a deprecation period. Here's an example for replacing `Director::isDev()` with a (theoretical) `Env::is_dev()`: @@ -77,8 +75,8 @@ Here's an example for replacing `Director::isDev()` with a (theoretical) `Env::i return Env::is_dev(); } -This change could be committed to a minor release like *3.2.0*, and stays deprecated in all following minor releases -(e.g. *3.3.0*, *3.4.0*), until a new major release (e.g. *4.0.0*) where it gets removed from the codebase. +This change could be committed to a minor release like *3.2.0*, and remains deprecated in all subsequent minor releases +(e.g. *3.3.0*, *3.4.0*), until a new major release (e.g. *4.0.0*), at which point it gets removed from the codebase. Deprecation notices are enabled by default on dev environment, but can be turned off via either _ss_environment.php or in your _config.php. Deprecation @@ -118,7 +116,7 @@ previous major release (if applicable). [new release](http://silverstripe.org/security-releases/) publically. You can help us determine the problem and speed up responses by providing us with more information on how to reproduce -the issue: SilverStripe version (incl. any installed modules), PHP/webserver version and configuration, anonymized +the issue: SilverStripe version (incl. any installed modules), PHP/webserver version and configuration, anonymised webserver access logs (if a hack is suspected), any other services and web packages running on the same server. ### Severity rating @@ -128,7 +126,7 @@ each vulnerability. The rating indicates how important an update is: | Severity | Description | |---------------|-------------| -| **Critical** | Critical releases require immediate actions. Such vulnerabilities allow attackers to take control of your site and you should upgrade on the day of release. *Example: Directory traversal, privilege escalation* | +| **Critical** | Critical releases require immediate action. Such vulnerabilities allow attackers to take control of your site and you should upgrade on the day of release. *Example: Directory traversal, privilege escalation* | | **Important** | Important releases should be evaluated immediately. These issues allow an attacker to compromise a site's data and should be fixed within days. *Example: SQL injection.* | | **Moderate** | Releases of moderate severity should be applied as soon as possible. They allow the unauthorized editing or creation of content. *Examples: Cross Site Scripting (XSS) in template helpers.* | -| **Low** | Low risk releases fix information disclosure and read-only privilege escalation vulnerabilities. These updates should also be applied as soon as possible, but with an impact-dependent priority. *Example: Exposure of the core version number, Cross Site Scripting (XSS) limited to the admin interface.* | +| **Low** | Low risk releases fix information disclosure and read-only privilege escalation vulnerabilities. These updates should also be applied as soon as possible, but according to an impact-dependent priority. *Example: Exposure of the core version number, Cross Site Scripting (XSS) limited to the admin interface.* | diff --git a/filesystem/File.php b/filesystem/File.php index 883975dff..2830832f3 100644 --- a/filesystem/File.php +++ b/filesystem/File.php @@ -841,7 +841,7 @@ class File extends DataObject { 'htm' => _t('File.HtmlType', 'HTML file') ); - $ext = $this->getExtension(); + $ext = strtolower($this->getExtension()); return isset($types[$ext]) ? $types[$ext] : 'unknown'; } diff --git a/main.php b/main.php index b5cb17132..0883be1d4 100644 --- a/main.php +++ b/main.php @@ -67,37 +67,44 @@ if(!empty($_SERVER['HTTP_X_ORIGINAL_URL'])) { */ global $url; -// PHP 5.4's built-in webserver uses this -if (php_sapi_name() == 'cli-server') { - $url = $_SERVER['REQUEST_URI']; +// Helper to safely parse and load a querystring fragment +$parseQuery = function($query) { + parse_str($query, $_GET); + if ($_GET) $_REQUEST = array_merge((array)$_REQUEST, (array)$_GET); +}; - // Querystring args need to be explicitly parsed - if(strpos($url,'?') !== false) { - list($url, $query) = explode('?',$url,2); - parse_str($query, $_GET); - if ($_GET) $_REQUEST = array_merge((array)$_REQUEST, (array)$_GET); +// Apache rewrite rules and IIS use this +if (isset($_GET['url']) && php_sapi_name() !== 'cli-server') { + + // Prevent injection of url= querystring argument by prioritising any leading url argument + if(isset($_SERVER['QUERY_STRING']) && + preg_match('/^(?url=[^&?]*)(?.*[&?]url=.*)$/', $_SERVER['QUERY_STRING'], $results) + ) { + $queryString = $results['query'].'&'.$results['url']; + $parseQuery($queryString); } - // Pass back to the webserver for files that exist - if(file_exists(BASE_PATH . $url) && is_file(BASE_PATH . $url)) return false; - - // Apache rewrite rules use this -} else if (isset($_GET['url'])) { $url = $_GET['url']; + // IIS includes get variables in url $i = strpos($url, '?'); if($i !== false) { $url = substr($url, 0, $i); } - // Lighttpd uses this + // Lighttpd and PHP 5.4's built-in webserver use this } else { - if(strpos($_SERVER['REQUEST_URI'],'?') !== false) { - list($url, $query) = explode('?', $_SERVER['REQUEST_URI'], 2); - parse_str($query, $_GET); - if ($_GET) $_REQUEST = array_merge((array)$_REQUEST, (array)$_GET); - } else { - $url = $_SERVER["REQUEST_URI"]; + $url = $_SERVER['REQUEST_URI']; + + // Querystring args need to be explicitly parsed + if(strpos($url,'?') !== false) { + list($url, $query) = explode('?',$url,2); + $parseQuery($query); + } + + // Pass back to the webserver for files that exist + if(php_sapi_name() === 'cli-server' && file_exists(BASE_PATH . $url) && is_file(BASE_PATH . $url)) { + return false; } } diff --git a/model/DataObject.php b/model/DataObject.php index 67f180a50..fc652bad8 100644 --- a/model/DataObject.php +++ b/model/DataObject.php @@ -369,7 +369,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity if(!isset(DataObject::$_cache_composite_fields[$class])) { self::cache_composite_fields($class); } - + if(isset(DataObject::$_cache_composite_fields[$class][$name])) { $isComposite = DataObject::$_cache_composite_fields[$class][$name]; } elseif($aggregated && $class != 'DataObject' && ($parentClass=get_parent_class($class)) != 'DataObject') { @@ -452,6 +452,10 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity $record = null; } + if(is_a($record, "stdClass")) { + $record = (array)$record; + } + // Set $this->record to $record, but ignore NULLs $this->record = array(); foreach($record as $k => $v) { @@ -1263,6 +1267,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity continue; } + // if database column doesn't correlate to a DBField instance... $fieldObj = $this->dbObject($fieldName); if(!$fieldObj) { @@ -1679,7 +1684,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity } else { $remoteClass = $this->belongsToComponent($component, false); } - + if(empty($remoteClass)) { throw new Exception("Unknown $type component '$component' on class '$this->class'"); } @@ -2742,6 +2747,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity public static function has_own_table($dataClass) { if(!is_subclass_of($dataClass,'DataObject')) return false; + $dataClass = ClassInfo::class_name($dataClass); if(!isset(DataObject::$cache_has_own_table[$dataClass])) { if(get_parent_class($dataClass) == 'DataObject') { DataObject::$cache_has_own_table[$dataClass] = true; @@ -3128,6 +3134,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity return $result; } + /** * Return the first item matching the given query. * All calls to get_one() are cached. diff --git a/model/DataQuery.php b/model/DataQuery.php index 4fbe306fe..ea84b02fa 100644 --- a/model/DataQuery.php +++ b/model/DataQuery.php @@ -196,7 +196,7 @@ class DataQuery { $tableClasses = $ancestorTables; } - $tableNames = array_keys($tableClasses); + $tableNames = array_values($tableClasses); $baseClass = $tableNames[0]; // Iterate over the tables and check what we need to select from them. If any selects are made (or the table is @@ -828,7 +828,9 @@ 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 SQLQuery} + * object. + * * All non-where methods call their DataQuery versions, which uses the base * query object. * diff --git a/model/fieldtypes/StringField.php b/model/fieldtypes/StringField.php index e489c7d17..fe778a1a1 100644 --- a/model/fieldtypes/StringField.php +++ b/model/fieldtypes/StringField.php @@ -84,7 +84,9 @@ abstract class StringField extends DBField { * @see core/model/fieldtypes/DBField#exists() */ public function exists() { - return ($this->value || $this->value == '0') || ( !$this->nullifyEmpty && $this->value === ''); + return $this->getValue() // All truthy values exist + || (is_string($this->getValue()) && strlen($this->getValue())) // non-empty strings exist ('0' but not (int)0) + || (!$this->getNullifyEmpty() && $this->getValue() === ''); // Remove this stupid exemption in 4.0 } /** diff --git a/search/filters/FulltextFilter.php b/search/filters/FulltextFilter.php old mode 100644 new mode 100755 index 64dfa995e..43c6b75a6 --- a/search/filters/FulltextFilter.php +++ b/search/filters/FulltextFilter.php @@ -17,22 +17,23 @@ * database table, using the {$indexes} hash in your DataObject subclass: * * - * static $indexes = array( + * private static $indexes = array( * 'SearchFields' => 'fulltext(Name, Title, Description)' * ); * * - * @package framework - * @subpackage search + * @todo Add support for databases besides MySQL */ class FulltextFilter extends SearchFilter { protected function applyOne(DataQuery $query) { + $this->model = $query->applyRelation($this->relation); $predicate = sprintf("MATCH (%s) AGAINST (?)", $this->getDbName()); return $query->where(array($predicate => $this->getValue())); } protected function excludeOne(DataQuery $query) { + $this->model = $query->applyRelation($this->relation); $predicate = sprintf("NOT MATCH (%s) AGAINST (?)", $this->getDbName()); return $query->where(array($predicate => $this->getValue())); } @@ -40,4 +41,37 @@ class FulltextFilter extends SearchFilter { public function isEmpty() { return $this->getValue() === array() || $this->getValue() === null || $this->getValue() === ''; } + + + /** + * This implementation allows for a list of columns to be passed into MATCH() instead of just one. + * + * @example + * + * MyDataObject::get()->filter('SearchFields:fulltext', 'search term') + * + * + * @return string + */ + public function getDbName() { + $indexes = Config::inst()->get($this->model, "indexes"); + if(is_array($indexes) && array_key_exists($this->getName(), $indexes)) { + $index = $indexes[$this->getName()]; + if(is_array($index) && array_key_exists("value", $index)) { + return $index['value']; + } else { + // Parse a fulltext string (eg. fulltext ("ColumnA", "ColumnB")) to figure out which columns + // we need to search. + if(preg_match('/^fulltext\s+\((.+)\)$/i', $index, $matches)) { + return $matches[1]; + } else { + throw new Exception("Invalid fulltext index format for '" . $this->getName() + . "' on '" . $this->model . "'"); + } + } + } + + return parent::getDbName(); + } + } diff --git a/search/filters/SearchFilter.php b/search/filters/SearchFilter.php index 224b3b76c..238994e18 100644 --- a/search/filters/SearchFilter.php +++ b/search/filters/SearchFilter.php @@ -164,6 +164,12 @@ abstract class SearchFilter extends Object { if($this->name == "NULL") { return $this->name; } + // Ensure that we're dealing with a DataObject. + if (!is_subclass_of($this->model, 'DataObject')) { + throw new InvalidArgumentException( + "Model supplied to " . get_class($this) . " should be an instance of DataObject." + ); + } $candidateClass = ClassInfo::table_for_object_field( $this->model, @@ -177,7 +183,7 @@ abstract class SearchFilter extends Object { return '"' . implode('"."', $parts) . '"'; } - return "\"$candidateClass\".\"$this->name\""; + return sprintf('"%s"."%s"', $candidateClass, $this->name); } /** @@ -194,7 +200,6 @@ abstract class SearchFilter extends Object { return $dbField->RAW(); } - /** * Apply filter criteria to a SQL query. * diff --git a/tests/core/ClassInfoTest.php b/tests/core/ClassInfoTest.php index bb4cb0142..70bef8b53 100644 --- a/tests/core/ClassInfoTest.php +++ b/tests/core/ClassInfoTest.php @@ -14,10 +14,18 @@ class ClassInfoTest extends SapphireTest { 'ClassInfoTest_NoFields', ); + public function setUp() { + parent::setUp(); + ClassInfo::reset_db_cache(); + } + public function testExists() { $this->assertTrue(ClassInfo::exists('Object')); + $this->assertTrue(ClassInfo::exists('object')); $this->assertTrue(ClassInfo::exists('ClassInfoTest')); + $this->assertTrue(ClassInfo::exists('CLASSINFOTEST')); $this->assertTrue(ClassInfo::exists('stdClass')); + $this->assertTrue(ClassInfo::exists('stdCLASS')); } public function testSubclassesFor() { @@ -30,6 +38,16 @@ class ClassInfoTest extends SapphireTest { ), 'ClassInfo::subclassesFor() returns only direct subclasses and doesnt include base class' ); + ClassInfo::reset_db_cache(); + $this->assertEquals( + ClassInfo::subclassesFor('classinfotest_baseclass'), + array( + 'ClassInfoTest_BaseClass' => 'ClassInfoTest_BaseClass', + 'ClassInfoTest_ChildClass' => 'ClassInfoTest_ChildClass', + 'ClassInfoTest_GrandChildClass' => 'ClassInfoTest_GrandChildClass' + ), + 'ClassInfo::subclassesFor() is acting in a case sensitive way when it should not' + ); } public function testClassesForFolder() { @@ -42,11 +60,11 @@ class ClassInfoTest extends SapphireTest { $classes, 'ClassInfo::classes_for_folder() returns classes matching the filename' ); - // $this->assertContains( - // 'ClassInfoTest_BaseClass', - // $classes, - // 'ClassInfo::classes_for_folder() returns additional classes not matching the filename' - // ); + $this->assertContains( + 'classinfotest_baseclass', + $classes, + 'ClassInfo::classes_for_folder() returns additional classes not matching the filename' + ); } /** @@ -54,8 +72,11 @@ class ClassInfoTest extends SapphireTest { */ public function testBaseDataClass() { $this->assertEquals('ClassInfoTest_BaseClass', ClassInfo::baseDataClass('ClassInfoTest_BaseClass')); + $this->assertEquals('ClassInfoTest_BaseClass', ClassInfo::baseDataClass('classinfotest_baseclass')); $this->assertEquals('ClassInfoTest_BaseClass', ClassInfo::baseDataClass('ClassInfoTest_ChildClass')); + $this->assertEquals('ClassInfoTest_BaseClass', ClassInfo::baseDataClass('CLASSINFOTEST_CHILDCLASS')); $this->assertEquals('ClassInfoTest_BaseClass', ClassInfo::baseDataClass('ClassInfoTest_GrandChildClass')); + $this->assertEquals('ClassInfoTest_BaseClass', ClassInfo::baseDataClass('ClassInfoTest_GRANDChildClass')); $this->setExpectedException('InvalidArgumentException'); ClassInfo::baseDataClass('DataObject'); @@ -75,6 +96,13 @@ class ClassInfoTest extends SapphireTest { )); $this->assertEquals($expect, $ancestry); + ClassInfo::reset_db_cache(); + $this->assertEquals($expect, ClassInfo::ancestry('classINFOTest_Childclass')); + + ClassInfo::reset_db_cache(); + $this->assertEquals($expect, ClassInfo::ancestry('classINFOTest_Childclass')); + + ClassInfo::reset_db_cache(); $ancestry = ClassInfo::ancestry('ClassInfoTest_ChildClass', true); $this->assertEquals(array('ClassInfoTest_BaseClass' => 'ClassInfoTest_BaseClass'), $ancestry, '$tablesOnly option excludes memory-only inheritance classes' @@ -97,16 +125,22 @@ class ClassInfoTest extends SapphireTest { 'ClassInfoTest_HasFields', ); - + ClassInfo::reset_db_cache(); $this->assertEquals($expect, ClassInfo::dataClassesFor($classes[0])); + ClassInfo::reset_db_cache(); + $this->assertEquals($expect, ClassInfo::dataClassesFor(strtoupper($classes[0]))); + ClassInfo::reset_db_cache(); $this->assertEquals($expect, ClassInfo::dataClassesFor($classes[1])); - + $expect = array( 'ClassInfoTest_BaseDataClass' => 'ClassInfoTest_BaseDataClass', 'ClassInfoTest_HasFields' => 'ClassInfoTest_HasFields', ); + ClassInfo::reset_db_cache(); $this->assertEquals($expect, ClassInfo::dataClassesFor($classes[2])); + ClassInfo::reset_db_cache(); + $this->assertEquals($expect, ClassInfo::dataClassesFor(strtolower($classes[2]))); } public function testTableForObjectField() { @@ -114,19 +148,27 @@ class ClassInfoTest extends SapphireTest { ClassInfo::table_for_object_field('ClassInfoTest_WithRelation', 'RelationID') ); - $this->assertEquals('ClassInfoTest_BaseDataClass', + $this->assertEquals('ClassInfoTest_WithRelation', + ClassInfo::table_for_object_field('ClassInfoTest_withrelation', 'RelationID') + ); + + $this->assertEquals('ClassInfoTest_BaseDataClass', ClassInfo::table_for_object_field('ClassInfoTest_BaseDataClass', 'Title') ); - $this->assertEquals('ClassInfoTest_BaseDataClass', + $this->assertEquals('ClassInfoTest_BaseDataClass', ClassInfo::table_for_object_field('ClassInfoTest_HasFields', 'Title') ); - $this->assertEquals('ClassInfoTest_BaseDataClass', + $this->assertEquals('ClassInfoTest_BaseDataClass', ClassInfo::table_for_object_field('ClassInfoTest_NoFields', 'Title') ); - $this->assertEquals('ClassInfoTest_HasFields', + $this->assertEquals('ClassInfoTest_BaseDataClass', + ClassInfo::table_for_object_field('classinfotest_nofields', 'Title') + ); + + $this->assertEquals('ClassInfoTest_HasFields', ClassInfo::table_for_object_field('ClassInfoTest_HasFields', 'Description') ); diff --git a/tests/filesystem/FileTest.php b/tests/filesystem/FileTest.php index 0fd26f237..b2052565d 100644 --- a/tests/filesystem/FileTest.php +++ b/tests/filesystem/FileTest.php @@ -250,6 +250,9 @@ class FileTest extends SapphireTest { $file = $this->objFromFixture('File', 'pdf'); $this->assertEquals("Adobe Acrobat PDF file", $file->FileType); + $file = $this->objFromFixture('File', 'gifupper'); + $this->assertEquals("GIF image - good for diagrams", $file->FileType); + /* Only a few file types are given special descriptions; the rest are unknown */ $file = $this->objFromFixture('File', 'asdf'); $this->assertEquals("unknown", $file->FileType); diff --git a/tests/filesystem/FileTest.yml b/tests/filesystem/FileTest.yml index 58d9b13ef..daf2bfd32 100644 --- a/tests/filesystem/FileTest.yml +++ b/tests/filesystem/FileTest.yml @@ -13,6 +13,8 @@ File: Filename: assets/FileTest.txt gif: Filename: assets/FileTest.gif + gifupper: + Filename: assets/FileTest.GIF pdf: Filename: assets/FileTest.pdf setfromname: diff --git a/tests/model/DataListTest.php b/tests/model/DataListTest.php index 1132c3be6..dc355aa8e 100755 --- a/tests/model/DataListTest.php +++ b/tests/model/DataListTest.php @@ -146,6 +146,11 @@ class DataListTest extends SapphireTest { $this->assertEquals('DataObjectTest_TeamComment',$list->dataClass()); } + public function testDataClassCaseInsensitive() { + $list = DataList::create('dataobjecttest_teamcomment'); + $this->assertTrue($list->exists()); + } + public function testClone() { $list = DataObjectTest_TeamComment::get(); $this->assertEquals($list, clone($list)); diff --git a/tests/model/DataObjectTest.php b/tests/model/DataObjectTest.php index 0ecb1d8d2..b86ee953c 100644 --- a/tests/model/DataObjectTest.php +++ b/tests/model/DataObjectTest.php @@ -61,6 +61,30 @@ class DataObjectTest extends SapphireTest { $this->assertEquals('Comment', key($dbFields), 'DataObject::db returns fields in correct order'); } + public function testConstructAcceptsValues() { + // Values can be an array... + $player = new DataObjectTest_Player(array( + 'FirstName' => 'James', + 'Surname' => 'Smith' + )); + + $this->assertEquals('James', $player->FirstName); + $this->assertEquals('Smith', $player->Surname); + + // ... or a stdClass inst + $data = new stdClass(); + $data->FirstName = 'John'; + $data->Surname = 'Doe'; + $player = new DataObjectTest_Player($data); + + $this->assertEquals('John', $player->FirstName); + $this->assertEquals('Doe', $player->Surname); + + // IDs should be stored as integers, not strings + $player = new DataObjectTest_Player(array('ID' => '5')); + $this->assertSame(5, $player->ID); + } + public function testValidObjectsForBaseFields() { $obj = new DataObjectTest_ValidatedObject(); diff --git a/tests/model/StringFieldTest.php b/tests/model/StringFieldTest.php index 2b6680f45..85b415bc4 100644 --- a/tests/model/StringFieldTest.php +++ b/tests/model/StringFieldTest.php @@ -36,6 +36,22 @@ class StringFieldTest extends SapphireTest { ); } + public function testExists() { + // True exists + $this->assertTrue(DBField::create_field('StringFieldTest_MyStringField', true)->exists()); + $this->assertTrue(DBField::create_field('StringFieldTest_MyStringField', '0')->exists()); + $this->assertTrue(DBField::create_field('StringFieldTest_MyStringField', '1')->exists()); + $this->assertTrue(DBField::create_field('StringFieldTest_MyStringField', 1)->exists()); + $this->assertTrue(DBField::create_field('StringFieldTest_MyStringField', 1.1)->exists()); + + // false exists + $this->assertFalse(DBField::create_field('StringFieldTest_MyStringField', false)->exists()); + $this->assertFalse(DBField::create_field('StringFieldTest_MyStringField', '')->exists()); + $this->assertFalse(DBField::create_field('StringFieldTest_MyStringField', null)->exists()); + $this->assertFalse(DBField::create_field('StringFieldTest_MyStringField', 0)->exists()); + $this->assertFalse(DBField::create_field('StringFieldTest_MyStringField', 0.0)->exists()); + } + } class StringFieldTest_MyStringField extends StringField implements TestOnly { diff --git a/tests/search/FulltextFilterTest.php b/tests/search/FulltextFilterTest.php new file mode 100755 index 000000000..efca15c6e --- /dev/null +++ b/tests/search/FulltextFilterTest.php @@ -0,0 +1,105 @@ +assertEquals(3, $baseQuery->count(), "FulltextFilterTest_DataObject count does not match."); + + // First we'll text the 'SearchFields' which has been set using an array + $search = $baseQuery->filter("SearchFields:fulltext", 'SilverStripe'); + $this->assertEquals(1, $search->count()); + + $search = $baseQuery->exclude("SearchFields:fulltext", "SilverStripe"); + $this->assertEquals(2, $search->count()); + + // Now we'll run the same tests on 'OtherSearchFields' which should yield the same resutls + // but has been set using a string. + $search = $baseQuery->filter("OtherSearchFields:fulltext", 'SilverStripe'); + $this->assertEquals(1, $search->count()); + + $search = $baseQuery->exclude("OtherSearchFields:fulltext", "SilverStripe"); + $this->assertEquals(2, $search->count()); + + // Search on a single field + $search = $baseQuery->filter("ColumnE:fulltext", 'Dragons'); + $this->assertEquals(1, $search->count()); + + $search = $baseQuery->exclude("ColumnE:fulltext", "Dragons"); + $this->assertEquals(2, $search->count()); + } else { + $this->markTestSkipped("FulltextFilter only supports MySQL syntax."); + } + } + + public function testGenerateQuery() { + // Test SearchFields + $filter1 = new FulltextFilter('SearchFields', 'SilverStripe'); + $filter1->setModel('FulltextFilterTest_DataObject'); + $query1 = FulltextFilterTest_DataObject::get()->dataQuery(); + $filter1->apply($query1); + $this->assertEquals('"ColumnA", "ColumnB"', $filter1->getDbName()); + $this->assertEquals( + array("MATCH (\"ColumnA\", \"ColumnB\") AGAINST ('SilverStripe')"), + $query1->query()->getWhere() + ); + + + // Test Other searchfields + $filter2 = new FulltextFilter('OtherSearchFields', 'SilverStripe'); + $filter2->setModel('FulltextFilterTest_DataObject'); + $query2 = FulltextFilterTest_DataObject::get()->dataQuery(); + $filter2->apply($query2); + $this->assertEquals('"ColumnC", "ColumnD"', $filter2->getDbName()); + $this->assertEquals( + array("MATCH (\"ColumnC\", \"ColumnD\") AGAINST ('SilverStripe')"), + $query2->query()->getWhere() + ); + + // Test fallback to single field + $filter3 = new FulltextFilter('ColumnA', 'SilverStripe'); + $filter3->setModel('FulltextFilterTest_DataObject'); + $query3 = FulltextFilterTest_DataObject::get()->dataQuery(); + $filter3->apply($query3); + $this->assertEquals('"FulltextFilterTest_DataObject"."ColumnA"', $filter3->getDbName()); + $this->assertEquals( + array("MATCH (\"FulltextFilterTest_DataObject\".\"ColumnA\") AGAINST ('SilverStripe')"), + $query3->query()->getWhere() + ); + } + +} + + +class FulltextFilterTest_DataObject extends DataObject implements TestOnly { + + private static $db = array( + "ColumnA" => "Varchar(255)", + "ColumnB" => "HTMLText", + "ColumnC" => "Varchar(255)", + "ColumnD" => "HTMLText", + "ColumnE" => 'Varchar(255)' + ); + + private static $indexes = array( + 'SearchFields' => array( + 'type' => 'fulltext', + 'name' => 'SearchFields', + 'value' => '"ColumnA", "ColumnB"', + ), + 'OtherSearchFields' => 'fulltext ("ColumnC", "ColumnD")', + 'SingleIndex' => 'fulltext ("ColumnE")' + ); + + private static $create_table_options = array( + "MySQLDatabase" => "ENGINE=MyISAM", + ); + +} \ No newline at end of file diff --git a/tests/search/FulltextFilterTest.yml b/tests/search/FulltextFilterTest.yml new file mode 100644 index 000000000..1f59ca081 --- /dev/null +++ b/tests/search/FulltextFilterTest.yml @@ -0,0 +1,19 @@ +FulltextFilterTest_DataObject: + object1: + ColumnA: 'SilverStripe' + CluumnB: '

Some content about SilverStripe.

' + ColumnC: 'SilverStripe' + ColumnD: '

Some content about SilverStripe.

' + ColumnE: 'Dragons be here' + object2: + ColumnA: 'Test Row' + ColumnB: '

Some information about this test row.

' + ColumnC: 'Test Row' + ColumnD: '

Some information about this test row.

' + ColumnE: 'No' + object3: + ColumnA: 'Fulltext Search' + ColumnB: '

Testing fulltext search.

' + ColumnC: 'Fulltext Search' + ColumnD: '

Testing fulltext search.

' + ColumnE: '' diff --git a/tests/templates/SSViewerTestIncludeScopeInheritanceInclude.ss b/tests/templates/SSViewerTestIncludeScopeInheritanceInclude.ss index 51b2dc190..0a67cba95 100644 --- a/tests/templates/SSViewerTestIncludeScopeInheritanceInclude.ss +++ b/tests/templates/SSViewerTestIncludeScopeInheritanceInclude.ss @@ -1 +1 @@ -$Title <% if ArgA %>- $ArgA <% end_if %>- <%if First %>First-<% end_if %><% if Last %>Last-<% end_if %><%if MultipleOf(2) %>EVEN<% else %>ODD<% end_if %> top:$Top.Title +<% if $Title %>$Title<% else %>Untitled<% end_if %> <% if $ArgA %>_ $ArgA <% end_if %>- <% if $First %>First-<% end_if %><% if $Last %>Last-<% end_if %><%if $MultipleOf(2) %>EVEN<% else %>ODD<% end_if %> top:$Top.Title diff --git a/tests/view/SSViewerTest.php b/tests/view/SSViewerTest.php index 9487232f2..39a3dd412 100644 --- a/tests/view/SSViewerTest.php +++ b/tests/view/SSViewerTest.php @@ -52,17 +52,45 @@ class SSViewerTest extends SapphireTest { // reset results for the tests that include arguments (the title is passed as an arg) $expected = array( - 'Item 1 - Item 1 - First-ODD top:Item 1', - 'Item 2 - Item 2 - EVEN top:Item 2', - 'Item 3 - Item 3 - ODD top:Item 3', - 'Item 4 - Item 4 - EVEN top:Item 4', - 'Item 5 - Item 5 - ODD top:Item 5', - 'Item 6 - Item 6 - Last-EVEN top:Item 6', + 'Item 1 _ Item 1 - First-ODD top:Item 1', + 'Item 2 _ Item 2 - EVEN top:Item 2', + 'Item 3 _ Item 3 - ODD top:Item 3', + 'Item 4 _ Item 4 - EVEN top:Item 4', + 'Item 5 _ Item 5 - ODD top:Item 5', + 'Item 6 _ Item 6 - Last-EVEN top:Item 6', ); $result = $data->renderWith('SSViewerTestIncludeScopeInheritanceWithArgs'); $this->assertExpectedStrings($result, $expected); } + + public function testIncludeTruthyness() { + $data = new ArrayData(array( + 'Title' => 'TruthyTest', + 'Items' => new ArrayList(array( + new ArrayData(array('Title' => 'Item 1')), + new ArrayData(array('Title' => '')), + new ArrayData(array('Title' => true)), + new ArrayData(array('Title' => false)), + new ArrayData(array('Title' => null)), + new ArrayData(array('Title' => 0)), + new ArrayData(array('Title' => 7)) + )) + )); + $result = $data->renderWith('SSViewerTestIncludeScopeInheritanceWithArgs'); + + // We should not end up with empty values appearing as empty + $expected = array( + 'Item 1 _ Item 1 - First-ODD top:Item 1', + 'Untitled - EVEN top:', + '1 _ 1 - ODD top:1', + 'Untitled - EVEN top:', + 'Untitled - ODD top:', + 'Untitled - EVEN top:0', + '7 _ 7 - Last-ODD top:7' + ); + $this->assertExpectedStrings($result, $expected); + } private function getScopeInheritanceTestData() { return new ArrayData(array( diff --git a/view/SSViewer.php b/view/SSViewer.php index 8ae373881..f84343d84 100644 --- a/view/SSViewer.php +++ b/view/SSViewer.php @@ -441,6 +441,15 @@ class SSViewer_DataPresenter extends SSViewer_Scope { } } + /** + * Get the injected value + * + * @param string $property Name of property + * @param array $params + * @param bool $cast If true, an object is always returned even if not an object. + * @return array Result array with the keys 'value' for raw value, or 'obj' if contained in an object + * @throws InvalidArgumentException + */ public function getInjectedValue($property, $params, $cast = true) { $on = $this->itemIterator ? $this->itemIterator->current() : $this->item; @@ -524,32 +533,25 @@ class SSViewer_DataPresenter extends SSViewer_Scope { if (isset($arguments[1]) && $arguments[1] != null) $params = $arguments[1]; else $params = array(); - $hasInjected = $res = null; - - if ($name == 'hasValue') { - if ($val = $this->getInjectedValue($property, $params, false)) { - $hasInjected = true; $res = (bool)$val['value']; - } - } - else { // XML_val - if ($val = $this->getInjectedValue($property, $params)) { - $hasInjected = true; - $obj = $val['obj']; + $val = $this->getInjectedValue($property, $params); + if ($val) { + $obj = $val['obj']; + if ($name === 'hasValue') { + $res = $obj instanceof Object + ? $obj->exists() + : (bool)$obj; + } else { + // XML_val $res = $obj->forTemplate(); } - } - - if ($hasInjected) { $this->resetLocalScope(); return $res; - } - else { + } else { return parent::__call($name, $arguments); } } } - /** * Parses a template file with an *.ss file extension. *