From d355cd5baf6fd90271277d871aa8accb1131271d Mon Sep 17 00:00:00 2001 From: Hamish Friedlander Date: Thu, 22 Dec 2011 15:17:41 +1300 Subject: [PATCH] ENHANCEMENT: Add config layer - the Config access class itself, and the ConfigManfiest builder which parses in the yaml config files --- core/Config.php | 502 +++++++++++++++++++++++++++ core/DAG.php | 74 ++++ core/Object.php | 50 ++- core/manifest/ConfigManifest.php | 493 ++++++++++++++++++++++++++ core/manifest/ManifestFileFinder.php | 4 +- tests/core/ConfigTest.php | 66 ++++ 6 files changed, 1187 insertions(+), 2 deletions(-) create mode 100644 core/Config.php create mode 100644 core/DAG.php create mode 100644 core/manifest/ConfigManifest.php create mode 100644 tests/core/ConfigTest.php diff --git a/core/Config.php b/core/Config.php new file mode 100644 index 000000000..157694911 --- /dev/null +++ b/core/Config.php @@ -0,0 +1,502 @@ + lowest priority order + * + * - Any values set via a call to Config#update + * + * - The configuration values taken from the YAML files in _config directories (internally sorted in before / after order, where + * the item that is latest is highest priority) + * + * - Any static set on an "additional static source" class (such as an extension) named the same as the name of the property + * + * - Any static set on the class named the same as the name of the property + * + * - The composite configuration value of the parent class of this class + * + * At some of these levels you can also set masks. These remove values from the composite value at their priority point rather than add. + * They are much simpler. They consist of a list of key / value pairs. When applied against the current composite value + * + * - If the composite value is a sequential array, any member of that array that matches any value in the mask is removed + * + * - If the composite value is an associative array, any member of that array that matches both the key and value of any pair in the mask is removed + * + * - If the composite value is not an array, if that value matches any value in the mask it is removed + * + * + */ +class Config { + + /** @var Object - A marker instance for the "anything" singleton value. Don't access directly, even in-class, always use self::anything() */ + static private $_anything = null; + + /** + * Get a marker class instance that is used to do a "remove anything with this key" by adding $key => Config::anything() to the suppress array + * todo: Does this follow the SS coding conventions? Config::get_anything_marker_instance() is a lot less elegant. + * @return Object + */ + static public function anything() { + if (self::$_anything === null) self::$_anything = new stdClass(); + return self::$_anything; + } + + // -- Source options bitmask -- + + /** @const source options bitmask value - merge all parent configuration in as lowest priority */ + const INHERITED = 0; + /** @const source options bitmask value - only get configuration set for this specific class, not any of it's parents */ + const UNINHERITED = 1; + /** @const source options bitmask value - inherit, but stop on the first class that actually provides a value (event an empty value) */ + const FIRST_SET = 2; + /** @const source options bitmask value - do not use additional statics sources (such as extension) */ + const EXCLUDE_EXTRA_SOURCES = 4; + + // -- get_value_type response enum -- + + /** @const Return flag for get_value_type indicating value is a scalar (or really just not-an-array, at least ATM)*/ + const ISNT_ARRAY = 1; + /** @const Return flag for get_value_type indicating value is an array */ + const IS_ARRAY = 2; + + /** + * Get whether the value is an array or not. Used to be more complicated, but still nice sugar to have an enum to compare + * and not just a true/false value + * @param $val any - The value + * @return int - One of ISNT_ARRAY or IS_ARRAY + */ + static protected function get_value_type($val) { + if (is_array($val)) return self::IS_ARRAY; + return self::ISNT_ARRAY; + } + + /** + * What to do if there's a type mismatch + * @throws UnexpectedValueException + */ + static protected function type_mismatch() { + throw new UnexpectedValueException('Type mismatch in configuration. All values for a particular property must contain the same type (or no value at all).'); + } + + /* @todo If we can, replace next static & static methods with DI once that's in */ + static protected $instance; + + /** + * Get the current active Config instance. + * + * Configs should not normally be manually created. + * In general use you will use this method to obtain the current Config instance. + * + * @return Config + */ + static public function inst() { + if (!self::$instance) self::$instance = singleton('Config'); + return self::$instance; + } + + /** + * Set the current active Config instance. + * + * Configs should not normally be manually created. + * A use case for replacing the active configuration set would be for creating an isolated environment for + * unit tests + * + * @return Config + */ + static public function set_instance($instance) { + self::$instance = $instance; + global $_SINGLETONS; + $_SINGLETONS['Config'] = $instance; + } + + /** + * Empty construction, otherwise calling singleton('Config') (not the right way to get the current active config + * instance, but people might) gives an error + */ + function __construct() { + } + + /** @var [array] - Array of arrays. Each member is an nested array keyed as $class => $name => $value, + * where value is a config value to treat as the highest priority item */ + protected $overrides = array(); + + /** @var [array] - Array of arrays. Each member is an nested array keyed as $class => $name => $value, + * where value is a config value suppress from any lower priority item */ + protected $suppresses = array(); + + /** @var [array] - The list of settings pulled from config files to search through */ + protected $manifests = array(); + + /** + * Add another manifest to the list of config manifests to search through. + * + * WARNING: Config manifests to not merge entries, and do not solve before/after rules inter-manifest - + * instead, the last manifest to be added always wins + */ + public function pushConfigManifest(SS_ConfigManifest $manifest) { + array_unshift($this->manifests, $manifest->yamlConfig); + + // @todo: Do anything with these. They're for caching after config.php has executed + $this->collectConfigPHPSettings = true; + $this->configPHPIsSafe = false; + + $manifest->activateConfig(); + + $this->collectConfigPHPSettings = false; + } + + static $extra_static_sources = array(); + + static function add_static_source($forclass, $donorclass) { + self::$extra_static_sources[$forclass][] = $donorclass; + } + + /** @var [Config_ForClass] - The list of Config_ForClass instances, keyed off class */ + static protected $for_class_instances = array(); + + /** + * Get an accessor that returns results by class by default. + * + * Shouldn't be overridden, since there might be many Config_ForClass instances already held in the wild. Each + * Config_ForClass instance asks the current_instance of Config for the actual result, so override that instead + * + * @param $class + * @return Config_ForClass + */ + public function forClass($class) { + if (isset(self::$for_class_instances[$class])) { + return self::$for_class_instances[$class]; + } + else { + return self::$for_class_instances[$class] = new Config_ForClass($class); + } + } + + /** + * Merge a lower priority associative array into an existing higher priority associative array, as per the class + * docblock rules + * + * It is assumed you've already checked that you've got two associative arrays, not scalars or sequential arrays + * + * @param $dest array - The existing high priority associative array + * @param $src array - The low priority associative array to merge in + */ + static function merge_array_low_into_high(&$dest, $src) { + foreach ($src as $k => $v) { + if (!$v) { + continue; + } + else if (is_int($k)) { + $dest[] = $v; + } + else if (isset($dest[$k])) { + $newType = self::get_value_type($v); + $currentType = self::get_value_type($dest[$k]); + + // Throw error if types don't match + if ($currentType !== $newType) self::type_mismatch(); + + if ($currentType == self::IS_ARRAY) self::merge_array_low_into_high($dest[$k], $v); + else continue; + } + else { + $dest[$k] = $v; + } + } + } + + /** + * Merge a higher priority assocative array into an existing lower priority associative array, as per the class + * docblock rules. + * + * Much more expensive that the other way around, as there's no way to insert an associative k/v pair into an + * array at the top of the array + * + * @static + * @param $dest array - The existing low priority associative array + * @param $src array - The high priority array to merge in + */ + static function merge_array_high_into_low(&$dest, $src) { + $res = $src; + self::merge_array_low_into_high($res, $dest); + $dest = $res; + } + + static function merge_high_into_low(&$result, $value) { + if (!$value) return; + $newType = self::get_value_type($value); + + if (!$result) { + $result = $value; + } + else { + $currentType = self::get_value_type($result); + if ($currentType !== $newType) self::type_mismatch(); + + if ($currentType == self::ISNT_ARRAY) $result = $value; + else self::merge_array_high_into_low($result, $value); + } + } + + static function merge_low_into_high(&$result, $value, $suppress) { + $newType = self::get_value_type($value); + + if ($suppress) { + if ($newType == self::IS_ARRAY) { + $value = self::filter_array_by_suppress_array($value, $suppress); + if (!$value) return; + } + else { + if (self::check_value_contained_in_suppress_array($value, $suppress)) return; + } + } + + if (!$result) { + $result = $value; + } + else { + $currentType = self::get_value_type($result); + if ($currentType !== $newType) self::type_mismatch(); + + if ($currentType == self::ISNT_ARRAY) return; // PASS + else self::merge_array_low_into_high($result, $value); + } + } + + static function check_value_contained_in_suppress_array($v, $suppresses) { + foreach ($suppresses as $suppress) { + list($sk, $sv) = $suppress; + if ($sv === self::anything() || $v == $sv) return true; + } + return false; + } + + static protected function check_key_or_value_contained_in_suppress_array($k, $v, $suppresses) { + foreach ($suppresses as $suppress) { + list($sk, $sv) = $suppress; + if (($sk === self::anything() || $k == $sk) && ($sv === self::anything() || $v == $sv)) return true; + } + return false; + } + + static protected function filter_array_by_suppress_array($array, $suppress) { + $res = array(); + + foreach ($array as $k => $v) { + $suppressed = self::check_key_or_value_contained_in_suppress_array($k, $v, $suppress); + + if (!$suppressed) { + if (is_numeric($k)) $res[] = $v; + else $res[$k] = $v; + } + } + + return $res; + } + + /** + * Get the config value associated for a given class and property + * + * This merges all current sources and overrides together to give final value + * todo: Currently this is done every time. This function is an inner loop function, so we really need to be caching heavily here. + * + * @param $class string - The name of the class to get the value for + * @param $name string - The property to get the value for + * @param int $sourceOptions - Bitmask which can be set to some combintain of Config::UNINHERITED, Config::FIRST_SET, and Config::EXCLUDE_EXTENSIONS. + * Config::UNINHERITED does not include parent classes when merging configuration fragments + * Config::FIRST_SET stops inheriting once the first class that sets a value (even an empty value) is encoutered + * Config::EXCLUDE_EXTRA_SOURCES does not include any additional static sources (such as extensions) + * + * Config::INHERITED is a utility constant that can be used to mean "none of the above", equvilient to 0 + * Setting both Config::UNINHERITED and Config::FIRST_SET behaves the same as just Config::UNINHERITED + * + * should the parent classes value be merged in as the lowest priority source? + * @param null $result array|scalar - Reference to a variable to put the result in. Also returned, so this can be left as null safely. If you do pass a value, it will be treated as the highest priority value in the result chain + * @param null $suppress array - Internal use when called by child classes. Array of mask pairs to filter value by + * @return array|scalar - The value of the config item, or null if no value set. Could be an associative array, sequential array or scalar depending on value (see class docblock) + */ + function get($class, $name, $sourceOptions = 0, &$result = null, $suppress = null) { + // If result is already not something to merge into, just return it + if ($result !== null && !is_array($result)) return $result; + + // First, look through the override values + foreach($this->overrides as $k => $overrides) { + if (isset($overrides[$class][$name])) { + $value = $overrides[$class][$name]; + + self::merge_low_into_high($result, $value, $suppress); + if ($result !== null && !is_array($result)) return $result; + } + + if (isset($this->suppresses[$k][$class][$name])) { + $suppress = $suppress ? array_merge($suppress, $this->suppresses[$k][$class][$name]) : $this->suppresses[$k][$class][$name]; + } + } + + // Then the manifest values + foreach($this->manifests as $manifest) { + if (isset($manifest[$class][$name])) { + self::merge_low_into_high($result, $manifest[$class][$name], $suppress); + if ($result !== null && !is_array($result)) return $result; + } + } + + // Then look at the static variables + $nothing = new stdClass(); + $classes = array($class); + // Include extensions only if not flagged not to, and some have been set + if ((($sourceOptions & self::EXCLUDE_EXTRA_SOURCES) != self::EXCLUDE_EXTRA_SOURCES) && isset(self::$extra_static_sources[$class])) { + $classes = array_merge($classes, self::$extra_static_sources[$class]); + } + + foreach ($classes as $staticSource) { + $value = Object::static_lookup($staticSource, $name, $nothing); + + if ($value !== $nothing) { + self::merge_low_into_high($result, $value, $suppress); + if ($result !== null && !is_array($result)) return $result; + } + } + + // Finally, merge in the values from the parent class + if (($sourceOptions & self::UNINHERITED) != self::UNINHERITED && (($sourceOptions & self::FIRST_SET) != self::FIRST_SET || $result === null)) { + $parent = get_parent_class($class); + if ($parent) $this->get($parent, $name, $sourceOptions, $result, $suppress); + } + + if ($name == 'routes') { + print_r($result); die; + } + + return $result; + } + + /** + * Update a configuration value + * + * Configuration is modify only. The value passed is merged into the existing configuration. If you want to replace the + * current value, you'll need to call remove first. + * + * @param $class string - The class to update a configuration value for + * @param $name string - The configuration property name to update + * @param $value any - The value to update with + * + * Arrays are recursively merged into current configuration as "latest" - for associative arrays the passed value + * replaces any item with the same key, for sequential arrays the items are placed at the end of the array, for + * non-array values, this value replaces any existing value + * + * You will get an error if you try and override array values with non-array values or vice-versa + */ + function update($class, $name, $val) { + if (!isset($this->overrides[0][$class])) $this->overrides[0][$class] = array(); + + if (!isset($this->overrides[0][$class][$name])) $this->overrides[0][$class][$name] = $val; + else self::merge_high_into_low($this->overrides[0][$class][$name], $val); + } + + /** + * Remove a configuration value + * + * You can specify a key, a key and a value, or neither. Either argument can be Config::anything(), which is + * what is defaulted to if you don't specify something + * + * This removes any current configuration value that matches the key and/or value specified + * + * Works like this: + * - Check the current override array, and remove any values that match the arguments provided + * - Keeps track of the arguments passed to this method, and in get filters everything _except_ the current override array to + * exclude any match + * + * This way we can re-set anything removed by a call to this function by calling set. Because the current override + * array is only filtered immediately on calling this remove method, that value will then be exposed. However, 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. + * 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. + * 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 + * + * Matching is always by "==", not by "===" + */ + function remove($class, $name) { + $argc = func_num_args(); + $key = $argc > 2 ? func_get_arg(2) : self::anything(); + $value = $argc > 3 ? func_get_arg(3) : self::anything(); + + $suppress = array($key, $value); + + if (isset($this->overrides[0][$class][$name])) { + $value = $this->overrides[0][$class][$name]; + + if (is_array($value)) { + $this->overrides[0][$class][$name] = self::filter_array_by_suppress_array($value, array($suppress)); + } + else { + if (self::check_value_contained_in_suppress_array($value, array($suppress))) unset($this->overrides[0][$class][$name]); + } + } + + if (!isset($this->suppresses[0][$class])) $this->suppresses[0][$class] = array(); + if (!isset($this->suppresses[0][$class][$name])) $this->suppresses[0][$class][$name] = array(); + + $this->suppresses[0][$class][$name][] = $suppress; + } + +} + +class Config_ForClass { + protected $class; + + function __construct($class) { + $this->class = $class; + } + + function __get($name) { + return Config::inst()->get($this->class, $name); + } + + function __set($name, $val) { + return Config::inst()->update($this->class, $name, $val); + } + + function get($name, $sourceOptions = 0) { + return Config::inst()->get($this->class, $name, $sourceOptions); + } + + function forClass($class) { + return Config::inst()->forClass($class); + } +} \ No newline at end of file diff --git a/core/DAG.php b/core/DAG.php new file mode 100644 index 000000000..ac1d9ec09 --- /dev/null +++ b/core/DAG.php @@ -0,0 +1,74 @@ + [$from_idx1, $from_idx2, ...] format */ + protected $dag; + + function __construct($data = null) { + $data = $data ? array_values($data) : array(); + + $this->data = $data; + $this->dag = array_fill_keys(array_keys($data), array()); + } + + /** + * Add another node/vertex + * @param $item anything - The item to add to the graph + */ + function additem($item) { + $this->data[] = $item; + $this->dag[] = array(); + } + + /** + * Add an edge from one vertex to another + * @param $from integer|any - The index in $data of the node/vertex, or the node/vertex itself, that the edge goes from + * @param $to integer|any - The index in $data of the node/vertex, or the node/vertex itself, that the edge goes to + * + * When passing actual nodes (as opposed to indexes), uses array_search with strict = true to find + */ + function addedge($from, $to) { + $i = is_numeric($from) ? $from : array_search($from, $this->data, true); + $j = is_numeric($to) ? $to : array_search($to, $this->data, true); + + if ($i === false) throw new Exception("Couldnt find 'from' item in data when adding edge to DAG"); + if ($j === false) throw new Exception("Couldnt find 'to' item in data when adding edge to DAG"); + + if (!isset($this->dag[$j])) $this->dag[$j] = array(); + $this->dag[$j][] = $i; + } + + /** + * Sort graph so that each node (a) comes before any nodes (b) where an edge exists from a to b + * @return array - The nodes + * @throws Exception - If the graph is cyclic (and so can't be sorted) + */ + function sort() { + $data = $this->data; $dag = $this->dag; $sorted = array(); + + while (true) { + $withedges = array_filter($dag, 'count'); + $starts = array_diff_key($dag, $withedges); + + if (!count($starts)) break; + + foreach ($starts as $i => $foo) $sorted[] = $data[$i]; + + foreach ($withedges as $j => $deps) { + $withedges[$j] = array_diff($withedges[$j], array_keys($starts)); + } + + $dag = $withedges; + } + + if ($dag) throw new Exception("DAG has cyclic requirements"); + return $sorted; + } +} \ No newline at end of file diff --git a/core/Object.php b/core/Object.php index d6da4c9a5..06476ab97 100755 --- a/core/Object.php +++ b/core/Object.php @@ -255,7 +255,55 @@ abstract class Object { return $class; } - + + /** + * Get the value of a static property of a class, even in that property is declared protected (but not private), without any inheritance, + * merging or parent lookup if it doesn't exist on the given class + * + * In 5.3, we can do this fast using $foo::$bar syntax, but this method then needs to be in a base class of $class + * to bust the protected def + * + * @static + * @param $class - The class to get the static from + * @param $name - The property to get from the class + * @param null $default - The value to return if property doesn't exist on class + * @return any - The value of the static property $name on class $class, or $default if that property is not defined + */ + public static function static_lookup($class, $name, $default = null) { + if (version_compare(PHP_VERSION, '5.4', '>=') && is_subclass_of($class, 'Object')) { + if (isset($class::$$name)) { + $parent = get_parent_class($class); + if (!$parent || !isset($parent::$$name) || $parent::$$name !== $class::$$name) return $class::$$name; + } + return $default; + } + else { + // TODO: This gets set once, then not updated, so any changes to statics after this is called the first time for any class won't be exposed + static $static_properties = array(); + + if (!isset($static_properties[$class])) { + $reflection = new ReflectionClass($class); + $static_properties[$class] = $reflection->getStaticProperties(); + } + + if (isset($static_properties[$class][$name])) { + $value = $static_properties[$class][$name]; + + $parent = get_parent_class($class); + if (!$parent) return $value; + + if (!isset($static_properties[$parent])) { + $reflection = new ReflectionClass($parent); + $static_properties[$parent] = $reflection->getStaticProperties(); + } + + if (!isset($static_properties[$parent][$name]) || $static_properties[$parent][$name] !== $value) return $value; + } + } + + return $default; + } + /** * Get a static variable, taking into account SS's inbuild static caches and pseudo-statics * diff --git a/core/manifest/ConfigManifest.php b/core/manifest/ConfigManifest.php new file mode 100644 index 000000000..802840cda --- /dev/null +++ b/core/manifest/ConfigManifest.php @@ -0,0 +1,493 @@ +modules[$module]) && $this->modules[$module] != $path) { + user_error("Module ".$module." in two places - ".$path." and ".$this->modules[$module]); + } + $this->modules[$module] = $path; + } + + /** Returns true if the passed module exists */ + function moduleExists($module) { + return array_key_exists($module, $this->modules); + } + + /** + * Constructs and initialises a new configuration object, either loading + * from the cache or re-scanning for classes. + * + * @param string $base The project base path. + * @param bool $forceRegen Force the manifest to be regenerated. + */ + public function __construct($base, $includeTests = false, $forceRegen = false ) { + $this->base = $base; + + // Get the Zend Cache to load/store cache into + $this->cache = SS_Cache::factory('SS_Configuration', 'Core', array( + 'automatic_serialization' => true, + 'lifetime' => null + )); + + // Unless we're forcing regen, try loading from cache + if (!$forceRegen) { + // The PHP config sources are always needed + $this->phpConfigSources = $this->cache->load('php_config_sources'); + + // Get the variant key spec - if this isn't present, we can't figure out what variant we're in so it's full regen time + if ($this->variantKeySpec = $this->cache->load('variant_key_spec')) { + // Try getting the pre-filtered & merged config for this variant + if (!($this->yamlConfig = $this->cache->load('yaml_config_'.$this->variantKey()))) { + // Otherwise, if we do have the yaml config fragments (and we should since we have a variant key spec) work out the config for this variant + if ($this->yamlConfigFragments = $this->cache->load('yaml_config_fragments')) { + $this->buildYamlConfigVariant(); + } + } + } + } + + // If we don't have a config yet, we need to do a full regen to get it + if (!$this->yamlConfig) { + $this->regenerate($includeTests); + $this->buildYamlConfigVariant(); + } + } + + /** + * Includes all of the php _config.php files found by this manifest. Called by SS_Config when adding this manifest + * @return void + */ + public function activateConfig() { + foreach ($this->phpConfigSources as $config) { + require_once $config; + } + } + + /** + * Returns the string that uniquely identifies this variant. The variant is the combination of classes, modules, environment, + * environment variables and constants that selects which yaml fragments actually make it into the configuration because of "only" + * and "except" rules. + * + * @return string + */ + public function variantKey() { + $key = $this->variantKeySpec; // Copy to fill in actual values + + if (isset($key['environment'])) $key['environment'] = Director::isDev() ? 'dev' : (Director::isTest() ? 'test' : 'live'); + + if (isset($key['envvars'])) foreach ($key['envvars'] as $variable => $foo) { + $key['envvars'][$variable] = isset($_ENV[$variable]) ? $_ENV[$variable] : null; + } + + if (isset($key['constants'])) foreach ($key['constants'] as $variable => $foo) { + $key['constants'][$variable] = defined($variable) ? constant($variable) : null; + } + + return sha1(serialize($key)); + } + + /** + * Completely regenerates the manifest file. Scans through finding all php _config.php and yaml _config/*.ya?ml files, + * parses the yaml files into fragments, sorts them and figures out what values need to be checked to pick the + * correct variant. + * + * Does _not_ build the actual variant + * + * @param bool $cache Cache the result. + */ + public function regenerate($includeTests = false, $cache = true) { + $finder = new ManifestFileFinder(); + $finder->setOptions(array( + 'name_regex' => '/_config.php$/', + 'ignore_tests' => !$includeTests, + 'file_callback' => array($this, 'addSourceConfigFile') + )); + $finder->find($this->base); + + $finder = new ManifestFileFinder(); + $finder->setOptions(array( + 'name_regex' => '/\.ya?ml$/', + 'ignore_tests' => !$includeTests, + 'file_callback' => array($this, 'addYAMLConfigFile') + )); + $finder->find($this->base); + + $this->prefilterYamlFragments(); + $this->sortYamlFragments(); + $this->buildVariantKeySpec(); + + if ($cache) { + $this->cache->save($this->phpConfigSources, 'php_config_sources'); + $this->cache->save($this->yamlConfigFragments, 'yaml_config_fragments'); + $this->cache->save($this->variantKeySpec, 'variant_key_spec'); + } + } + + /** + * Handle finding a php file. We just keep a record of all php files found, we don't include them + * at this stage + * + * Public so that ManifestFileFinder can call it. Not for general use. + */ + public function addSourceConfigFile($basename, $pathname, $depth) { + $this->phpConfigSources[] = $pathname; + // Add this module too + $this->addModule(dirname($pathname)); + } + + /** + * Handle finding a yml file. Parse the file by spliting it into header/fragment pairs, + * and normalising some of the header values (especially: give anonymous name if none assigned, + * splt/complete before and after matchers) + * + * Public so that ManifestFileFinder can call it. Not for general use. + */ + public function addYAMLConfigFile($basename, $pathname, $depth) { + if (!preg_match('{/([^/]+)/_config/}', $pathname, $match)) return; + + // Keep track of all the modules we've seen + $this->addModule(dirname(dirname($pathname))); + + // We use Symfony Yaml since it's the most complete. It still doesn't handle all of YAML, but it's better than + // nothing. + require_once 'thirdparty/symfony-yaml/lib/sfYamlParser.php'; + $parser = new sfYamlParser(); + + // The base header + $base = array( + 'module' => $match[1], + 'file' => basename(basename($basename, '.yml'), '.yaml') + ); + + // YAML parsers really should handle this properly themselves, but neither spyc nor symfony-yaml do. So we + // follow in their vein and just do what we need, not what the spec says + $parts = preg_split('/^---$/m', file_get_contents($pathname), -1, PREG_SPLIT_NO_EMPTY); + + // If only one document, it's a headerless fragment. So just add it with an anonymous name + if (count($parts) == 1) { + $this->yamlConfigFragments[] = $base + array( + 'name' => 'anonymous-1', + 'fragment' => $parser->parse($parts[0]) + ); + } + // Otherwise it's a set of header/document pairs + else { + // If we got an odd number of parts the config file doesn't have a header for every document + if (count($parts) % 2 != 0) user_error("Configuration file $basename does not have an equal number of headers and config blocks"); + + // Step through each pair + for ($i = 0; $i < count($parts); $i+=2) { + // Make all the first-level keys of the header lower case + $header = array_change_key_case($parser->parse($parts[$i]), CASE_LOWER); + + // Assign a name if non assigned already + if (!isset($header['name'])) $header['name'] = 'anonymous-'.(1+$i/2); + + // Parse & normalise the before and after if present + foreach (array('before', 'after') as $order) { + if (isset($header[$order])) { + // First, splice into parts (multiple before or after parts are allowed, comma separated) + $orderparts = preg_split('/\s+,\s+/', $header[$order], PREG_SPLIT_NO_EMPTY); + + // For each, parse out into module/file#name, and set any missing to "*" + $header[$order] = array(); + foreach($orderparts as $part) { + preg_match('! (\*|\w+) (?:\/(\*|\w+) (?:\*|\#(\w+))? )? !x', $part, $match); + $header[$order][] = array( + 'module' => $match[1], + 'file' => isset($match[2]) ? $match[2] : '*', + 'name' => isset($match[3]) ? $match[3] : '*' + ); + } + } + } + + // And add to the fragments list + $this->yamlConfigFragments[] = $base + $header + array( + 'fragment' => $parser->parse($parts[$i+1]) + ); + } + } + } + + /** + * Sorts the YAML fragments so that the "before" and "after" rules are met. + * Throws an error if there's a loop + * + * We can't use regular sorts here - we need a topological sort. Easiest + * way is with a DAG, so build up a DAG based on the before/after rules, then + * sort that. + * + * @return void + */ + protected function sortYamlFragments() { + $frags = $this->yamlConfigFragments; + + // Build a directed graph + $dag = new SS_DAG($frags); + + foreach ($frags as $i => $frag) { + foreach ($frags as $j => $otherfrag) { + if ($i == $j) continue; + + $order = $this->relativeOrder($frag, $otherfrag); + + if ($order == 'before') $dag->addedge($i, $j); + elseif ($order == 'after') $dag->addedge($j, $i); + } + } + + $this->yamlConfigFragments = $dag->sort(); + } + + /** + * Return a string "after", "before" or "undefined" depending on whether the YAML fragment array element passed as $a should + * be positioned after, before, or either compared to the YAML fragment array element passed as $b + * + * @param $a Array - a YAML config fragment as loaded by addYAMLConfigFile + * @param $b Array - a YAML config fragment as loaded by addYAMLConfigFile + * @return string "after", "before" or "undefined" + */ + protected function relativeOrder($a, $b) { + $matchesSomeRule = array(); + + // Do the same thing for after and before + foreach (array('after'=>'before', 'before'=>'after') as $rulename => $opposite) { + $matchesSomeRule[$rulename] = false; + + // If no rule specified, we don't match it + if (isset($a[$rulename])) { + + foreach ($a[$rulename] as $rule) { + $matchesRule = true; + + foreach(array('module', 'file', 'name') as $part) { + $partMatches = true; + + // If part is *, we match _unless_ the opposite rule has a non-* matcher than also matches $b + if ($rule[$part] == '*') { + if (isset($a[$opposite])) foreach($a[$opposite] as $oppositeRule) { + if ($oppositeRule[$part] == $b[$part]) { $partMatches = false; break; } + } + } + else { + $partMatches = ($rule[$part] == $b[$part]); + } + + $matchesRule = $matchesRule && $partMatches; + if (!$matchesRule) break; + } + + $matchesSomeRule[$rulename] = $matchesSomeRule[$rulename] || $matchesRule; + } + } + } + + // Check if it matches both rules - problem if so + if ($matchesSomeRule['before'] && $matchesSomeRule['after']) { + user_error('Config fragment requires itself to be both before _and_ after another fragment', E_USER_ERROR); + } + + return $matchesSomeRule['before'] ? 'before' : ($matchesSomeRule['after'] ? 'after' : 'undefined'); + } + + /** + * This function filters the loaded yaml fragments, removing any that can't ever have their "only" and "except" rules + * match + * + * Some tests in "only" and "except" rules need to be checked per request, but some are manifest based - + * these are invariant over requests and only need checking on manifest rebuild. So we can prefilter these before + * saving yamlConfigFragments to speed up the process of checking the per-request variant/ + */ + function prefilterYamlFragments() { + $matchingFragments = array(); + + foreach ($this->yamlConfigFragments as $i => $fragment) { + $failsonly = isset($fragment['only']) && !$this->matchesPrefilterVariantRules($fragment['only']); + $matchesexcept = isset($fragment['except']) && $this->matchesPrefilterVariantRules($fragment['except']); + + if (!$failsonly && !$matchesexcept) $matchingFragments[] = $fragment; + } + + $this->yamlConfigFragments = $matchingFragments; + } + + /** + * Returns false if the prefilterable parts of the rule aren't met, and true if they are + * + * @param $rules array - A hash of rules as allowed in the only or except portion of a config fragment header + * @return bool - True if the rules are met, false if not. (Note that depending on whether we were passed an only or an except rule, + * which values means accept or reject a fragment + */ + function matchesPrefilterVariantRules($rules) { + foreach ($rules as $k => $v) { + switch (strtolower($k)) { + case 'classexists': + if (!ClassInfo::exists($v)) return false; + break; + + case 'moduleexists': + if (!$this->moduleExists($v)) return false; + break; + + default: + // NOP + } + } + + return true; + } + + /** + * Builds the variant key spec - the list of values that need to be build to give a key that uniquely identifies this variant. + */ + function buildVariantKeySpec() { + $this->variantKeySpec = array(); + + foreach ($this->yamlConfigFragments as $fragment) { + if (isset($fragment['only'])) $this->addVariantKeySpecRules($fragment['only']); + if (isset($fragment['except'])) $this->addVariantKeySpecRules($fragment['except']); + } + } + + /** + * Adds any variables referenced in the passed rules to the $this->variantKeySpec array + */ + function addVariantKeySpecRules($rules) { + foreach ($rules as $k => $v) { + switch (strtolower($k)) { + case 'classexists': + case 'moduleexists': + // Classes and modules are a special case - we can pre-filter on config regenerate because we already know + // if the class or module exists + break; + + case 'environment': + $this->variantKeySpec['environment'] = true; + break; + + case 'envvarset': + if (!isset($this->variantKeySpec['envvars'])) $this->variantKeySpec['envvars'] = array(); + $this->variantKeySpec['envvars'][$k] = $k; + break; + + case 'constantdefined': + if (!isset($this->variantKeySpec['constants'])) $this->variantKeySpec['constants'] = array(); + $this->variantKeySpec['constants'][$k] = $k; + break; + + default: + if (!isset($this->variantKeySpec['envvars'])) $this->variantKeySpec['envvars'] = array(); + if (!isset($this->variantKeySpec['constants'])) $this->variantKeySpec['constants'] = array(); + $this->variantKeySpec['envvars'][$k] = $this->variantKeySpec['constants'][$k] = $k; + } + } + } + + /** + * Calculates which yaml config fragments are applicable in this variant, and merge those all together into + * the $this->yamlConfig propperty + */ + function buildYamlConfigVariant($cache = true) { + $this->yamlConfig = array(); + + foreach ($this->yamlConfigFragments as $i => $fragment) { + $failsonly = isset($fragment['only']) && !$this->matchesVariantRules($fragment['only']); + $matchesexcept = isset($fragment['except']) && $this->matchesVariantRules($fragment['except']); + + if (!$failsonly && !$matchesexcept) $this->mergeInYamlFragment($this->yamlConfig, $fragment['fragment']); + } + + if ($cache) { + $this->cache->save($this->yamlConfig, 'yaml_config_'.$this->variantKey()); + } + } + + /** + * Returns false if the non-prefilterable parts of the rule aren't met, and true if they are + */ + function matchesVariantRules($rules) { + foreach ($rules as $k => $v) { + switch (strtolower($k)) { + case 'classexists': + case 'moduleexists': + break; + + case 'environment': + switch (strtolower($v)) { + case 'live': + if (!Director::isLive()) return false; + break; + case 'test': + if (!Director::isTest()) return false; + break; + case 'dev': + if (!Director::isDev()) return false; + break; + default: + user_error('Unknown environment '.$v.' in config fragment', E_USER_ERROR); + } + break; + + case 'envvarset': + if (isset($_ENV[$k])) break; + return false; + + case 'constantdefined': + if (defined($k)) break; + return false; + + default: + if (isset($_ENV[$k]) && $_ENV[$k] == $v) break; + if (defined($k) && constant($k) == $v) break; + return false; + } + } + + return true; + } + + /** + * Recursively merge a yaml fragment's configuration array into the primary merged configuration array. + * @param $into + * @param $fragment + * @return void + */ + function mergeInYamlFragment(&$into, $fragment) { + foreach ($fragment as $k => $v) { + if (is_array($v) || is_object($v)) { + if (isset($into[$k])) { $sub = $into[$k]; $this->mergeInYamlFragment($sub, $v); $into[$k] = $sub; } + else $into[$k] = $v; + } + else if (is_numeric($k)) $into[] = $v; + else $into[$k] = $v; + } + } + +} \ No newline at end of file diff --git a/core/manifest/ManifestFileFinder.php b/core/manifest/ManifestFileFinder.php index 63e8e2969..da2a38aad 100644 --- a/core/manifest/ManifestFileFinder.php +++ b/core/manifest/ManifestFileFinder.php @@ -2,7 +2,7 @@ /** * An extension to the default file finder with some extra filters to faciliate * autoload and template manifest generation: - * - Only modules with _config.php files arescanned. + * - Only modules with _config.php files are scanned. * - If a _manifest_exclude file is present inside a directory it is ignored. * - Assets and module language directories are ignored. * - Module tests directories are skipped if the ignore_tests option is not @@ -14,6 +14,7 @@ class ManifestFileFinder extends SS_FileFinder { const CONFIG_FILE = '_config.php'; + const CONFIG_DIR = '_config'; const EXCLUDE_FILE = '_manifest_exclude'; const LANG_DIR = 'lang'; const TESTS_DIR = 'tests'; @@ -53,6 +54,7 @@ class ManifestFileFinder extends SS_FileFinder { $depth == 1 && !($this->getOption('include_themes') && $basename == THEMES_DIR) && !file_exists($pathname . '/' . self::CONFIG_FILE) + && !file_exists($pathname . '/' . self::CONFIG_DIR) ); if ($lackingConfig) { diff --git a/tests/core/ConfigTest.php b/tests/core/ConfigTest.php new file mode 100644 index 000000000..a981933fb --- /dev/null +++ b/tests/core/ConfigTest.php @@ -0,0 +1,66 @@ + 1, 'B' => 2, 'C' => 3); + Config::merge_array_low_into_high($result, array('C' => 4, 'D' => 5)); + $this->assertEquals($result, array('A' => 1, 'B' => 2, 'C' => 3, 'D' => 5)); + + $result = array('A' => 1, 'B' => 2, 'C' => 3); + Config::merge_array_high_into_low($result, array('C' => 4, 'D' => 5)); + $this->assertEquals($result, array('A' => 1, 'B' => 2, 'C' => 4, 'D' => 5)); + + $result = array('A' => 1, 'B' => 2, 'C' => array(1, 2, 3)); + Config::merge_array_low_into_high($result, array('C' => array(4, 5, 6), 'D' => 5)); + $this->assertEquals($result, array('A' => 1, 'B' => 2, 'C' => array(1, 2, 3, 4, 5, 6), 'D' => 5)); + + $result = array('A' => 1, 'B' => 2, 'C' => array(1, 2, 3)); + Config::merge_array_high_into_low($result, array('C' => array(4, 5, 6), 'D' => 5)); + $this->assertEquals($result, array('A' => 1, 'B' => 2, 'C' => array(4, 5, 6, 1, 2, 3), 'D' => 5)); + + $result = array('A' => 1, 'B' => 2, 'C' => array('Foo' => 1, 'Bar' => 2), 'D' => 3); + Config::merge_array_low_into_high($result, array('C' => array('Bar' => 3, 'Baz' => 4))); + $this->assertEquals($result, array('A' => 1, 'B' => 2, 'C' => array('Foo' => 1, 'Bar' => 2, 'Baz' => 4), 'D' => 3)); + + $result = array('A' => 1, 'B' => 2, 'C' => array('Foo' => 1, 'Bar' => 2), 'D' => 3); + Config::merge_array_high_into_low($result, array('C' => array('Bar' => 3, 'Baz' => 4))); + $this->assertEquals($result, array('A' => 1, 'B' => 2, 'C' => array('Foo' => 1, 'Bar' => 3, 'Baz' => 4), 'D' => 3)); + } + + function testStaticLookup() { + $this->assertEquals(Object::static_lookup('ConfigTest_DefinesFoo', 'foo'), 1); + $this->assertEquals(Object::static_lookup('ConfigTest_DefinesFoo', 'bar'), null); + + $this->assertEquals(Object::static_lookup('ConfigTest_DefinesBar', 'foo'), null); + $this->assertEquals(Object::static_lookup('ConfigTest_DefinesBar', 'bar'), 2); + + $this->assertEquals(Object::static_lookup('ConfigTest_DefinesFooAndBar', 'foo'), 3); + $this->assertEquals(Object::static_lookup('ConfigTest_DefinesFooAndBar', 'bar'), 3); + + $this->assertEquals(Object::static_lookup('ConfigTest_DefinesFooDoesntExtendObject', 'foo'), 4); + $this->assertEquals(Object::static_lookup('ConfigTest_DefinesFooDoesntExtendObject', 'bar'), null); + } + + function testFragmentOrder() { + // $manifest = new SS_ConfigManifest(BASE_PATH, false, true); + } + +}