Index: includes/bootstrap.inc =================================================================== RCS file: /cvs/drupal/drupal/includes/bootstrap.inc,v retrieving revision 1.288 diff -u -p -r1.288 bootstrap.inc --- includes/bootstrap.inc 1 Jul 2009 12:47:30 -0000 1.288 +++ includes/bootstrap.inc 8 Jul 2009 04:43:28 -0000 @@ -1857,3 +1857,437 @@ function &drupal_static($name, $default_ function drupal_static_reset($name = NULL) { drupal_static($name, NULL, TRUE); } + + +/** + * @ingroup plugins + * @{ + */ + +/** + * Request a plugin object. + * + * @param $slot_id + * The machine-readable name of the slot the plugin belongs to. + * @param $target + * The target inside the subsytem the plugin belongs to. + * @param $reset + * If this is set to TRUE, the internal caches are flushed. Internal use + * only. + * + * @return + * The associated plugin object. + * + * @see hook_slot_info() + * @see hook_plugin_info() + */ +function plugin($slot_id, $target = 'default', $reset = FALSE) { + // $plugin_mappings is an array, keyed by $slot_id and $target and the + // values are arrays containing class names and a boolean indicating + // reusability. plugin_objects keeps the objects themselves. + // $default_only[$slot_id] is TRUE if the given slot only uses defaults. + $plugin_mappings =& drupal_static(__FUNCTION__ . '_plugin_mappings', array()); + $plugin_objects =& drupal_static(__FUNCTION__ . '_plugin_objects', array()); + $default_only =& drupal_static(__FUNCTION__ . '_default_only', array()); + + // If we already have the appropriate plugin cached, just use that. + if (!empty($plugin_objects[$slot_id][$target])) { + return $plugin_objects[$slot_id][$target]; + } + + // While the objects need to be stored per target, the classnames can be per + // slot, if the only attached plugin is attached to default. That includes + // any slot that does not make use of targets. We cache the plugin + // information for these slots in the variable system to reduce database + // lookups. Because the variable system doesn't auto-initialize in + // variable_get(), any plugin lookup that runs before the variable system + // has been initialized will simply fail this check and use the database + // anyway. + $mapping_target = isset($default_only[$slot_id]) ? 'default' : $target; + // Look up the class for the requested slot/target. + if (empty($plugin_mappings[$slot_id][$mapping_target])) { + // The variable table caching of simple attachments doesn't work so well + // once we introduce configuration objects. The serialized configuration + // object is likely to be too large to use the variable table sanely. I'm + // not sure yet how to handle that, so for now the varable table override + // is disabled. That's what the FALSE is about. + // @todo: Figure out what to do about this. + if (FALSE && $record = variable_get('plugin_default_' . $slot_id, array())) { + $default_only[$slot_id] = TRUE; + $mapping_target = 'default'; + } + else { + // Try to get the associated plugin. If the first query finds an associated + // plugin, we use that. If not, the second query will always find the + // default plugin. By UNIONing them together we get the fallback default + // behavior without having to issue a second request to the database. + $record = db_query_range("SELECT class, configuration, configuration_class, reuse FROM {plugin_attachments} WHERE slot = :slot_1 AND target = :target + UNION SELECT class, configuration, configuration_class, reuse FROM {plugin_attachments} WHERE slot = :slot_2 AND target = 'default'", array( + ':slot_1' => $slot_id, + ':slot_2' => $slot_id, + ':target' => $target, + ), 0, 1)->fetchAssoc(); + + // It's possible that this function will be called before the plugin system + // has first been initialized, such as during the installer. That's + // especially the case for early-running systems such as cache or path, + // as they operate during the bootstrap phase. If we have no record at all, + // we first rebuild the registry and then try again. That should at least + // always give us the slot-defined default and allow the system to proceed. + if (!$record) { + registry_rebuild(); + plugins_rebuild(); + $record = db_query_range("SELECT class, configuration, configuration_class, reuse FROM {plugin_attachments} WHERE slot = :slot_1 AND target = :target + UNION SELECT class, configuration, configuration_class, reuse FROM {plugin_attachments} WHERE slot = :slot_2 AND target = 'default'", array( + ':slot_1' => $slot_id, + ':slot_2' => $slot_id, + ':target' => $target, + ), 0, 1)->fetchAssoc(); + } + } + + // Deserialize the configuration object before we cache the record. + $record['configuration'] = is_null($record['configuration']) ? new $record['configuration_class'] : unserialize($record['configuration']); + + // Statically cache the lookup information so that we don't need to check + // for it again. + $plugin_mappings[$slot_id][$mapping_target] = $record; + } + + $plugin = new $plugin_mappings[$slot_id][$mapping_target]['class']($plugin_mappings[$slot_id][$mapping_target]['configuration'], $target); + + // Cache the plugin object for later use, if flagged to do so. + if ($plugin_mappings[$slot_id][$mapping_target]['reuse']) { + $plugin_objects[$slot_id][$target] = $plugin; + } + return $plugin; +} + +/** + * Rebuild the plugin and slot registries. + */ +function plugins_rebuild() { + require_once DRUPAL_ROOT . '/includes/plugins.inc'; + plugin_slot_rebuild(); + plugin_plugin_rebuild(); + plugin_ensure_defaults(); +} + + +/** + * Associate a plugin with a specific target for the given slot. + * + * @param $slot_id + * The machine-readable name of the slot. + * @param $plugin_id + * The machine-readable name of the plugin. + * @param $target + * The plugin will be associated with this target. + * @param $config + * The configuration object for this plugin on this target. If not specified, + * an empty configuration object will be used with default options. + * + * @see plugin_get_available_plugins() + * @see plugin_detach() + */ +function plugin_attach($slot_id, $plugin_id, $target = 'default', PluginConfigurationInterface $config = NULL) { + // Lazy-load the utility function. + if (drupal_function_exists('plugin_load')) { + $plugin = plugin_load($slot_id, $plugin_id); + $slot = slot_load($slot_id); + + if ($plugin) { + // We store the class name in the database in addition to the plugin ID + // so that we can immediately instantiate the class upon lookup. + db_merge('plugin_attachments') + ->key(array( + 'slot' => $slot_id, + 'target' => $target, + )) + ->fields(array( + 'plugin' => $plugin->plugin, + 'class' => $plugin->class, + 'configuration_class' => $plugin->configuration_class, + 'configuration' => is_null($config) ? NULL : serialize($config), + 'reuse' => (int)$slot->reuse, + )) + ->execute(); + } + + // If we've associated something other than the default target, disable the + // extra variable system caching of plugin attachments. + if ($target != 'default') { + variable_del('plugin_default_' . $slot_id); + } + + // Flush the target cache. + drupal_static_reset('plugin_plugin_mappings'); + drupal_static_reset('plugin_plugin_objects'); + drupal_static_reset('plugin_default_only'); + } +} + +/** + * Revert a given slot and target to the default plugin. + * + * @param $slot_id + * The machine-readable name of the slot to reset to the default plugin. + * @param $target + * The target to reset to the default plugin. + * + * @see plugin_attach() + * @see plugin_get_available_plugins() + */ +function plugin_detach($slot_id, $target = 'default') { + // Delete the plugin attachment. + db_delete('plugin_attachments') + ->condition('slot', $slot_id) + ->condition('target', $target) + ->execute(); + + // If we just deleted the default target for this slot, reattach the + // slot-defined default plugin. That ensures that we always have at least + // one plugin configured. + if ($target == 'default') { + if (drupal_function_exists('slot_load')) { + $slot = slot_load($slot_id); + plugin_attach($slot_id, $slot->default_plugin, 'default'); + } + } + + // If there are no attachments for this slot other than 'default', + // cache that to the variable system for faster lookups. + $attachments = db_query("SELECT target, class, configuration_class, reuse FROM {plugin_attachments} WHERE slot = :slot", array(':slot' => $slot_id))->fetchAllAssoc('target', PDO::FETCH_ASSOC); + if (count($attachments) == 1 && key($attachments) == 'default') { + $attachments = current($attachments); + unset($attachments['target']); + variable_set('plugin_default_' . $slot_id, $attachments); + } + + // Flush the target cache. + drupal_static_reset('plugin_plugin_mappings'); + drupal_static_reset('plugin_plugin_objects'); + drupal_static_reset('plugin_default_only'); +} + +/** + * Retrieve a list of all available plugins for a given slot. + * + * @param $slot_id + * The machine-readable name of the slot for which we want a list of + * available plugins. + * @return + * An array of plugins that have been defined for this slot. + */ +function plugin_get_available_plugins($slot_id) { + return db_query('SELECT plugin, title, description, class FROM {plugin_info} WHERE slot = :slot ORDER BY title', array(':slot' => $slot_id))->fetchAllAssoc('plugin'); +} + +/** + * Get the plugin object for the plugin associated to this slot/target. + * + * @param $slot_id + * The machine-readable name of the slot for which we want to look up a + * plugin. + * @param $target + * The target for which we want to look up a plugin. + * @return + * A loaded plugin object or NULL if not defined. + * + * @see plugin_get_available_plugin() + */ +function plugin_get_attached_plugin($slot_id, $target = 'default') { + // Lazy-load the utility function. + if (drupal_function_exists('plugin_load')) { + $plugin_id = db_query_range("SELECT plugin FROM {plugin_attachments} WHERE slot = :slot_1 AND target = :target + UNION SELECT plugin FROM {plugin_attachments} WHERE slot = :slot_2 AND target = 'default'", array( + ':slot_1' => $slot_id, + ':slot_2' => $slot_id, + ':target' => $target, + ), 0, 1)->fetchField(); + + return plugin_load($slot_id, $plugin_id); + } +} + + +/** + * Interface for all plugin classes for any slot. + * + * This is a very thin interface, but does serve to help standardize plugin + * behavior and provides a way to type-check a plugin object. Interfaces for + * specific slots should extend this interface. + */ +interface PluginInterface { + + /** + * Constructor + * + * @param $config + * A configuration object holding configuration options for this plugin. + * @param $target + * The target for which this plugin is being called. Some plugins may + * require this information in order to route commands properly. + */ + function __construct(PluginConfigurationInterface $config, $target = 'default'); +} + +/** + * Interface for all plugin configuration classes. + */ +interface PluginConfigurationInterface { + + /** + * Declares the options available in this configuration. + * + * @return + * An array of options, keyed by the option name. The value of each + * array element is the default value for the option. + */ + public function options(); + + /** + * Creates the configuration form for this configuration object. + * + * Unlike a normal form function, you should not return $form from + * this method. Simply alter $form as-is. + * + * @param $form + * The form object to which to add our fields. + * @param $form_state + * The state for this form. + */ + public function optionsForm(&$form, &$form_state); + + /** + * Validates the configuration form. + * + * This method behaves the same as a normal form validate hook. + * + * @param $form + * The form object. + * @param $form_state + * The state for this form. + */ + public function optionsFormValidate($form, &$form_state); + + + /** + * Pre-processes a configuration form before it is submitted. + * + * Note that this method should not save form information directly. + * Configuration is saved automatically by the system. Use this method + * only if you need to manipulate the $form_state before the submitted values + * are saved. + * + * @param $form + * The form object. + * @param $form_state + * The state for this form. + */ + public function optionsFormSubmit($form, &$form_state); + + /** + * Retrieves the specified option from this configuration object. + * + * The meaning of a given configuration is slot- and plugin-dependent. + * + * @param $option + * A string indicating the option to retrieve. + * @return + * The stored value for the specified option, or if one is not found then + * the default value specified in the options() method. + */ + public function getOption($option); + + /** + * + * @param $option + * The name of the option to set. + * @param $value + * The value to which to set the option. This may be any primitive data + * type (int, float, string, array). + */ + public function setOption($option, $value); + +} + +/** + * Base implementation of a plugin configuration object. + */ +abstract class PluginConfigurationBase implements PluginConfigurationInterface { + + /** + * Keyed array of available options. + * + * @var array + */ + protected $options; + + public function options() { + return $this->options; + } + + public function optionsForm(&$form, &$form_state) {} + + public function optionsFormValidate($form, &$form_state) {} + + public function optionsFormSubmit($form, &$form_state) {} + + public function getOption($option) { + if (isset($this->options[$option])) { + return $this->options[$option]; + } + else { + error_log('Got to the default options'); + $defaults = $this->options(); + error_log(print_r($defaults, 1)); + return $defaults[$option]; + } + } + + public function setOption($option, $value) { + $this->options[$option] = $value; + } +} + +/** + * "Null" implementation of plugin configuration. + * + * Use this configuration class for plugins that have no configuration options. + */ +class PluginConfigurationNone extends PluginConfigurationBase { + +} + +/** + * Base implementation of a plugin object. + * + * Simple plugin objects may choose to inherit from a base class in order to + * not reimplement routine functionality. Alternatively they may simply implement + * the appropriate interface and implement their own version of common + * functionality. Both methods are acceptable depending on the use case. + */ +abstract class PluginBase implements PluginInterface { + + /** + * The configuration object for this plugin. + * + * @var PluginConfigurationInterface + */ + protected $config; + + /** + * The target for which this plugin is active. + */ + protected $target; + + function __construct(PluginConfigurationInterface $config, $target = 'default') { + $this->config = $config; + $this->target = $target; + } +} + +/** + * @} End of "ingroup plugins". + */ Index: includes/plugins.inc =================================================================== RCS file: includes/plugins.inc diff -N includes/plugins.inc --- /dev/null 1 Jan 1970 00:00:00 -0000 +++ includes/plugins.inc 8 Jul 2009 04:43:28 -0000 @@ -0,0 +1,295 @@ +get(...); + * @endcode + * + * If no plugin has been attached to that slot and target, the plugin attached + * to the target 'default' is used instead. This allows arbitrarily defined + * targets -- plugin can ask for any target and will get a meaningful answer + * every time. If no target is specified, the slot's currently assigned default + * plugin is used, allowing the target argument to be ommitted for subsystems + * with only a single target. + * + * A slot/target may also be reset: + * + * @code + * plugin_detach('cache', 'filter'); + * @endcode + * + * After detach, as explained above, subsequent calls for this target will + * return the plugin attached to the target 'default'. + * + * If the target argument is omitted, both plugin_attach() and plugin_detach() + * operate on the 'default' target. + */ + +/** + * Rebuild the plugin slot registry. + * + * @return + * The array of slots just declared. + */ +function plugin_slot_rebuild() { + $slots = module_invoke_all('slot_info'); + + // Set default values, to avoid NULL issues if nothing else. + foreach ($slots as $slot => $info) { + $slots[$slot] += plugin_slot_defaults(); + } + + // Let other modules alter the plugin target registry if needed. + drupal_alter('slot_info', $slots); + + if ($slots) { + // Build the target data. + $insert = db_insert('plugin_slot_info')->fields(array('slot', 'title', 'interface', 'configuration_interface', 'reuse', 'default_plugin', 'description')); + + foreach ($slots as $slot => $info) { + $insert->values(array( + 'slot' => $slot, + 'title' => $info['title'], + 'interface' => $info['interface'], + 'configuration_interface' => $info['configuration_interface'], + 'reuse' => (int)$info['reuse'], + 'default_plugin' => $info['default_plugin'], + 'description' => $info['description'], + )); + } + + // Don't do the deletion until after we've gotten the new data prepared. + // That reduces the potential race condition window. + db_delete('plugin_slot_info')->execute(); + $insert->execute(); + } + + return $slots; +} + +/** + * Defines the default info array for plugins. + */ +function plugin_plugin_defaults() { + return array( + 'plugin' => '', + 'slot' => '', + 'title' => '', + 'class' => '', + 'configuration_class' => 'PluginConfigurationNone', + 'description' => '', + ); +} + +/** + * Defines the default info array for slots. + */ +function plugin_slot_defaults() { + return array( + 'slot' => '', + 'title' => '', + 'interface' => 'PluginInterface', + 'configuration_interface' => 'PluginConfigurationInterface', + 'reuse' => 1, + 'default_plugin' => 'default', + 'description' => '', + ); +} + +/** + * Rebuild the plugin registry. + * + * @return + * The array of plugins just defined. + */ +function plugin_plugin_rebuild() { + $plugins = module_invoke_all('plugin_info'); + + // Set default values, to avoid NULL issues if nothing else. + foreach ($plugins as $slot => $plugin_info) { + foreach ($plugin_info as $plugin => $info) { + $plugins[$slot][$plugin] += plugin_plugin_defaults(); + } + } + + // Let other modules alter the plugin registry if needed. + drupal_alter('plugin_info', $plugins); + + if ($plugins) { + // Build the plugin data. + $insert = db_insert('plugin_info')->fields(array('plugin', 'slot', 'title', 'class', 'configuration_class', 'description')); + foreach ($plugins as $slot => $plugin_info) { + foreach ($plugin_info as $plugin => $info) { + $insert->values(array( + 'plugin' => $plugin, + 'slot' => $slot, + 'title' => $info['title'], + 'class' => $info['class'], + 'configuration_class' => $info['configuration_class'], + 'description' => $info['description'], + )); + } + } + + // Don't do the deletion until after we've gotten the new data. That + // reduces the potential race condition window. + db_delete('plugin_info')->execute(); + $insert->execute(); + } + + return $plugins; +} + +/** + * Ensure that every slot has a plugin attached for the default target. + * + * For every slot, attach whatever plugin is declared the default plugin + * to the default target unless one has already been defined. That ensures + * that the attachment table always has at least that default record in it for + * each slot, which greatly simplifies the lookup logic. + */ +function plugin_ensure_defaults() { + $result = db_query("SELECT psi.slot, reuse, class, pi.configuration_class, plugin + FROM {plugin_slot_info} psi + INNER JOIN {plugin_info} pi ON psi.slot = pi.slot AND psi.default_plugin = pi.plugin"); + + foreach ($result as $record) { + // We can't exclude all fields or the query breaks. However, setting + // reuse to itself has no effect on the table so it's safe to do. + db_merge('plugin_attachments') + ->key(array( + 'slot' => $record->slot, + 'target' => 'default', + )) + ->fields(array( + 'plugin' => $record->plugin, + 'class' => $record->class, + 'configuration_class' => $record->configuration_class, + 'reuse' => $record->reuse, + )) + ->updateExcept('class', 'plugin', 'configuration_class') + ->execute(); + + // If there are no attachments for this slot other than the default, cache + // that to the variable system for faster lookups. + $attachments = db_query("SELECT target, class, reuse FROM {plugin_attachments} WHERE slot = :slot", array(':slot' => $record->slot))->fetchAllAssoc('target', PDO::FETCH_ASSOC); + if (count($attachments) == 1 && key($attachments) == 'default') { + $attachments = current($attachments); + unset($attachments['target']); + variable_set('plugin_default_' . $record->slot, $attachments); + } + } +} + +/** + * Load function for slot objects. + * + * @param $slot_id + * The machine-readable name of the slot. + * @param $refresh + * If this is set to TRUE, the internal slot cache is flushed. + * @return + * The slot object or NULL if it is not defined. + */ +function slot_load($slot_id, $refresh = FALSE) { + static $slots; + + if ($refresh) { + $slots = array(); + } + + if (empty($slots[$slot_id])) { + $slots[$slot_id] = db_query("SELECT slot, title, interface, reuse, default_plugin, description FROM {plugin_slot_info} WHERE slot = :slot", array(':slot' => $slot_id))->fetchObject(); + } + + return $slots[$slot_id]; +} + +/** + * Load function for plugin info objects. + * + * @param $slot_id + * The machine-readable name of the slot. + * @param $plugin_id + * The machine-readable name of the plugin. All plugins are namespaced + * within their slot. + * @param $refresh + * If this is set to TRUE, the internal plugins cache is flushed. + * @return + * The plugin object or NULL if not defined. + */ +function plugin_load($slot_id, $plugin_id, $refresh = FALSE) { + static $plugins; + + if ($refresh) { + $plugins = array(); + } + + if (empty($plugins[$slot_id][$plugin_id])) { + $plugins[$slot_id][$plugin_id] = db_query("SELECT plugin, slot, title, class, configuration_class, description FROM {plugin_info} WHERE slot = :slot AND plugin = :plugin", array( + ':slot' => $slot_id, + ':plugin' => $plugin_id, + ))->fetchObject(); + } + + return $plugins[$slot_id][$plugin_id]; +} + +/** + * @} End of "ingroup plugins". + */ Index: modules/simpletest/simpletest.info =================================================================== RCS file: /cvs/drupal/drupal/modules/simpletest/simpletest.info,v retrieving revision 1.7 diff -u -p -r1.7 simpletest.info --- modules/simpletest/simpletest.info 1 Jul 2009 13:44:53 -0000 1.7 +++ modules/simpletest/simpletest.info 8 Jul 2009 04:43:29 -0000 @@ -25,6 +25,7 @@ files[] = tests/graph.test files[] = tests/image.test files[] = tests/menu.test files[] = tests/module.test +files[] = tests/plugins.test files[] = tests/registry.test files[] = tests/schema.test files[] = tests/session.test Index: modules/simpletest/tests/plugins.test =================================================================== RCS file: modules/simpletest/tests/plugins.test diff -N modules/simpletest/tests/plugins.test --- /dev/null 1 Jan 1970 00:00:00 -0000 +++ modules/simpletest/tests/plugins.test 8 Jul 2009 04:43:29 -0000 @@ -0,0 +1,293 @@ + t('Plugins, Nonreusable'), + 'description' => t('Test the plugin routing system'), + 'group' => t('Plugins'), + ); + } + + function setUp() { + parent::setUp('plugins_test'); + $this->sampleString = $this->randomName(); + + // We need to rebuild the index at least once, because the module_enable() + // routine called by the parent method doesn't call drupal_flush_all_caches(). + plugins_rebuild(); + } + + /** + * Test that we can retrieve the default plugin if no target is defined. + */ + function testDefaultPlugin() { + // 'default' has not been attached to anything, so should return the + // default implementation. + $plugin = plugin('fancystring', 'default'); + + $this->assertTrue($plugin instanceof FancystringInterface, t('Returned plugin has the correct interface.')); + $this->assertTrue($plugin instanceof FancystringDefault, t('Correct plugin object returned.')); + + if ($plugin instanceof pluginInterface) { + $plugin->setString($this->sampleString); + $mangled = $plugin->getString(); + + $this->assertEqual($this->sampleString, $mangled, t('Default plugin returned correct string.')); + } + } + + /** + * Test that we can specify a plugin for a target and it loads correctly. + */ + function testSpecifiedPlugin() { + + // Specify a specific plugin for a given target. + plugin_attach('fancystring', 'rot13', 'foo'); + + // Now request the plugin. + $plugin = plugin('fancystring', 'foo'); + + $this->assertTrue($plugin instanceof FancystringInterface, t('Returned plugin has the correct interface.')); + $this->assertTrue($plugin instanceof FancystringRot13, t('Correct plugin object returned.')); + + if ($plugin instanceof PluginInterface) { + $plugin->setString($this->sampleString); + $mangled = $plugin->getString(); + + $this->assertEqual(str_rot13($this->sampleString), $mangled, t('Specified plugin returned correct string.')); + } + } + + /** + * Test that we can skip using target on a slot and everything still works. + */ + function testUndefinedPlugin() { + // An undefined target should always give us the default plugin defined by the slot. + $plugin = plugin('fancystring'); + + $this->assertTrue($plugin instanceof FancystringInterface, t('Returned plugin has the correct interface.')); + $this->assertTrue($plugin instanceof FancystringDefault, t('Correct plugin object returned.')); + + if ($plugin instanceof PluginInterface) { + $plugin->setString($this->sampleString); + $mangled = $plugin->getString(); + + $this->assertEqual($this->sampleString, $mangled, t('Default plugin returned correct string.')); + } + } + + /** + * Test a context-sensitive plugin. + */ + function testContextSensitivePlugin() { + + // This is a poor way of simulating the variable system, but it will have + // to do for now. + $map = array( + 'Hello' => 'Goodbye', + 'World' => 'Cruel World', + ); + $GLOBALS['conf']['fancystring_translate'] = $map; + + // Specify a specific plugin for a given target. + plugin_attach('fancystring', 'custom_translate', 'bar'); + + // Now request the plugin. + $plugin = plugin('fancystring', 'bar'); + + $this->assertTrue($plugin instanceof FancystringInterface, t('Returned plugin has the correct interface.')); + $this->assertTrue($plugin instanceof FancystringCustom, t('Correct plugin object returned.')); + + if ($plugin instanceof PluginInterface) { + $plugin->setString($this->sampleString); + $mangled = $plugin->getString(); + + $this->assertEqual(strtr($this->sampleString, $map), $mangled, t('Specified plugin returned correct string.')); + } + + unset ($GLOBALS['conf']['fancystring_translate']); + } + + /** + * Test that we can successfully unset a plugin attachment. + */ + function testUnsettingPlugin() { + // Specify a specific plugin for a given target. + plugin_attach('fancystring', 'rot13', 'foo'); + // Now unset that attachment. + plugin_detach('fancystring', 'foo'); + // Now request the plugin. We should get back the default. + $plugin = plugin('fancystring', 'foo'); + + // $this->fail(get_class($plugin)); + + $this->assertTrue($plugin instanceof FancystringInterface, t('Returned plugin has the correct interface.')); + $this->assertTrue($plugin instanceof FancystringDefault, t('Correct plugin object returned.')); + + if ($plugin instanceof PluginInterface) { + $plugin->setString($this->sampleString); + $mangled = $plugin->getString(); + + $this->assertEqual($this->sampleString, $mangled, t('Unspecified plugin returned correct string.')); + } + } + + /** + * Test that a non-reusable slot returns a new object eacn time. + */ + function testPluginReuse() { + + // Specify a specific plugin for a given target. + plugin_attach('fancystring', 'rot13', 'foo'); + + // Now request the plugin. + $plugin_1 = plugin('fancystring', 'foo'); + + $this->assertTrue($plugin_1 instanceof FancystringInterface, t('Returned plugin has the correct interface.')); + $this->assertTrue($plugin_1 instanceof FancystringRot13, t('Correct plugin object returned.')); + + $plugin_2 = plugin('fancystring', 'foo'); + + $this->assertNotIdentical($plugin_1, $plugin_2, t('A new object was returned.')); + } +} + +/** + * Test class for the plugins system, part 2. + */ +class PluginsReusableTestCase extends DrupalWebTestCase { + + protected $sampleString = 'Hello World'; + + public static function getInfo() { + return array( + 'name' => t('Plugins, Reusable'), + 'description' => t('Test the plugin routing system for reusable plugins.'), + 'group' => t('Plugins'), + ); + } + + function setUp() { + parent::setUp('plugins_test'); + + // We need to rebuild the index at least once, because the module_enable() + // routine called by the parent method doesn't call drupal_flush_all_caches(). + plugins_rebuild(); + } + + /** + * Test that a reusable slot returns the same object each time. + */ + function testPluginReuse() { + + // Specify a specific plugin for a given target. + plugin_attach('thingie', 'default', 'foo'); + + // Now request the plugin. + $plugin_1 = plugin('thingie', 'foo'); + + $this->assertTrue($plugin_1 instanceof ThingieInterface, t('Returned plugin has the correct interface.')); + $this->assertTrue($plugin_1 instanceof ThingieDefault, t('Correct plugin object returned.')); + + $plugin_2 = plugin('thingie', 'foo'); + + $this->assertIdentical($plugin_1, $plugin_2, t('The same plugin object was returned.')); + } +} + + +/** + * Test plugin configuration ability. + */ +class PluginsConfigurationTestCase extends DrupalWebTestCase { + + public static function getInfo() { + return array( + 'name' => t('Plugins, Configuration'), + 'description' => t('Test the plugin routing system for configuration.'), + 'group' => t('Plugins'), + ); + } + + function setUp() { + parent::setUp('plugins_test'); + + // We need to rebuild the index at least once, because the module_enable() + // routine called by the parent method doesn't call drupal_flush_all_caches(). + plugins_rebuild(); + } + + /** + * Test that a plugin responds to its configuration object. + */ + function testPluginConfiguration() { + + // Specify a specific plugin for a given target. + plugin_attach('math', 'default', 'multiply'); + + // Now request the plugin. + $plugin_1 = plugin('math', 'default'); + + $this->assertTrue($plugin_1 instanceof MathInterface, t('Returned plugin has the correct interface.')); + $this->assertTrue($plugin_1 instanceof MathMultiply, t('Correct plugin object returned.')); + + $this->assertEqual($plugin_1->calculate(5), 10, t('plugin calculates the correct value.')); + } + + /** + * Test that we can safely serialize and deserialize a configuration object. + */ + function testConfigurationSerialization() { + + $before = new MathConfiguration(); + + $serialized = serialize($before); + + $after = unserialize($serialized); + + $this->assertTrue($after instanceof MathConfiguration, t('Configuration object deserialized to the correct type.')); + + foreach (array_keys($before->options()) as $option) { + $this->assertEqual($before->getOption($option), $after->getOption($option), t('Serialized value the same after deserialization.')); + } + } + + /** + * Test that configuration objects affect the plugin appropriately. + */ + function testConfigurationSetting() { + $config = new MathConfiguration(); + + $config->setOption('factor', 3); + $plugin = new MathMultiply($config); + + $this->assertEqual($plugin->calculate(5), 15, t('Set option used correctly.')); + } + + /** + * Test that we can attach a plugin to a slot/target with configuration. + */ + function testConfiguratingSaving() { + $config = new MathConfiguration(); + + $config->setOption('factor', 3); + + plugin_attach('math', 'multiply', 'default', $config); + + $plugin = plugin('math', 'default'); + + $this->assertEqual($plugin->calculate(5), 15, t('Saved option used correctly.')); + } + +} Index: modules/simpletest/tests/plugins_test.info =================================================================== RCS file: modules/simpletest/tests/plugins_test.info diff -N modules/simpletest/tests/plugins_test.info --- /dev/null 1 Jan 1970 00:00:00 -0000 +++ modules/simpletest/tests/plugins_test.info 8 Jul 2009 04:43:29 -0000 @@ -0,0 +1,7 @@ +; $Id:$ +name = Plugins test +description = "Support module for Plugins tests." +core = 7.x +package = Testing +files[] = plugins_test.module +hidden = TRUE Index: modules/simpletest/tests/plugins_test.module =================================================================== RCS file: modules/simpletest/tests/plugins_test.module diff -N modules/simpletest/tests/plugins_test.module --- /dev/null 1 Jan 1970 00:00:00 -0000 +++ modules/simpletest/tests/plugins_test.module 8 Jul 2009 04:43:29 -0000 @@ -0,0 +1,223 @@ + array( + 'title' => 'Fancy string', + 'description' => 'Do fancy stuff to strings', + 'interface' => 'FancystringInterface', + 'default_plugin' => 'default', + 'reuse' => FALSE, + ), + 'thingie' => array( + 'title' => 'Other slot', + 'description' => "This slot does nothing. It's just to test lookup behavior of reusable slots.", + 'interface' => 'ThingieInterface', + ), + 'math' => array( + 'title' => 'Math tests', + 'description' => "Run a mathematical manipulation on a number.", + 'interface' => 'MathInterface', + 'default_plugin' => 'multiply', + ), + ); +} + +/** + * Implementation of hook_plugin_info(). + */ +function plugins_test_plugin_info() { + return array( + 'fancystring' => array( + 'default' => array( + // Translate on load, not define, like hook_menu(). + 'title' => 'No string mutation', + 'class' => 'FancystringDefault', + 'description' => "This plugin doesn't do anything to the string. It passes through unaltered.", + ), + 'rot13' => array( + 'title' => 'Rot13 translation', + 'class' => 'FancystringRot13', + 'description' => 'This plugin ROT13 encrypts a string.', + ), + 'custom_translate' => array( + 'title' => 'Custom mapping', + 'class' => 'FancystringCustom', + 'description' => 'This plugin uses a user-specified mapping array.', + ), + ), + 'thingie' => array( + 'default' => array( + 'title' => 'Do nothing plugin', + 'description' => 'This plugin does nothing', + 'class' => 'ThingieDefault', + ) + ), + 'math' => array( + 'multiply' => array( + 'title' => 'Multiply', + 'class' => 'MathMultiply', + 'configuration_class' => 'MathConfiguration', + 'description' => "Multiply a provided number.", + ), + 'add' => array( + 'title' => 'Add', + 'class' => 'MathAdd', + 'description' => "Add a provided number.", + ), + ), + ); +} + +interface MathInterface extends PluginInterface { + + /** + * Calculates the result of a number and this plugin's operation. + * + * @param $number + * The number to use for calculations. + */ + public function calculate($number); +} + +class MathMultiply extends PluginBase implements MathInterface { + public function calculate($number) { + return $number * $this->config->getOption('factor'); + } +} + +class MathAdd extends PluginBase implements MathInterface { + public function calculate($number) { + return $number + $this->config->getOption('factor'); + } +} + +class MathConfiguration extends PluginConfigurationBase { + + public function options() { + return array( + 'factor' => 2, + ); + } +} + +/** + * Interface for the example string mangling plugin. + */ +interface FancystringInterface extends PluginInterface { + + /** + * Set the string for this object to mangle. + * @param $string + * The string to mangle. + */ + public function setString($string); + + /** + * Get the string back, mangled according to this plugin. + * @return + * The mangled string. + */ + public function getString(); +} + +/** + * Base implementation of Fancystring plugin. + */ +abstract class FancystringBase implements FancystringInterface { + + protected $target; + + protected $config; + + protected $string = ''; + + function __construct(PluginConfigurationInterface $config, $target = 'default') { + $this->config = $config; + $this->target = $target; + } + + public function setString($string) { + $this->string = $string; + } +} + +/** + * Default implementation of Fancystring plugin. + */ +class FancystringDefault extends FancystringBase { + + public function getString() { + return $this->string; + } +} + +/** + * Rot13 implementation of Fancystring plugin. + */ +class FancystringRot13 extends FancystringBase { + + public function getString() { + return str_rot13($this->string); + } +} + +/** + * Custom replacement implementation of Fancystring plugin. + * + * Note that we are implementing the FancystringInterface here directly + * rather than inheriting from the base class, mostly to show that we can. + * There's no obligation that plugins do anything other than support the + * appropriate interface. How they do so is entirely up to them. + * + */ +class FancystringCustom implements FancystringInterface { + + protected $string = ''; + + protected $target; + + function __construct(PluginConfigurationInterface $config, $target = 'default') { + $this->config = $config; + $this->target = $target; + } + + public function setString($string) { + $this->string = $string; + } + + public function getString() { + $map = variable_get('fancystring_translate', array()); + + return strtr($this->string, $map); + } +} + +/** + * Interface for the do-nothing test plugin. + */ +interface ThingieInterface extends PluginInterface { + + /** + * Do nothing. + */ + function noop(); +} + +class ThingieDefault implements ThingieInterface { + + protected $target; + + function __construct(PluginConfigurationInterface $config, $target = 'default') { + $this->config = $config; + $this->target = $target; + } + + function noop() { + return; + } +} Index: modules/system/system.install =================================================================== RCS file: /cvs/drupal/drupal/modules/system/system.install,v retrieving revision 1.353 diff -u -p -r1.353 system.install --- modules/system/system.install 4 Jul 2009 14:45:36 -0000 1.353 +++ modules/system/system.install 8 Jul 2009 04:43:29 -0000 @@ -1158,6 +1158,144 @@ function system_schema() { 'primary key' => array('mlid'), ); + $schema['plugin_attachments'] = array( + 'description' => 'Maps slots and targets to their configured plugin.', + 'fields' => array( + 'slot' => array( + 'description' => 'The machine-readable name of the slot.', + 'type' => 'varchar', + 'length' => '50', + 'not null' => TRUE, + ), + 'target' => array( + 'description' => 'The target value.', + 'type' => 'varchar', + 'length' => '50', + 'not null' => TRUE, + ), + 'plugin' => array( + 'description' => 'The plugin attached to this slot/target.', + 'type' => 'varchar', + 'length' => '50', + 'not null' => TRUE, + ), + 'configuration' => array( + 'description' => 'The configuration object for this attachment.', + 'type' => 'text', + 'not null' => FALSE, + ), + 'class' => array( + 'description' => 'The PHP class for the plugin attached to this slot/target. It is stored in this table to make lookups faster.', + 'type' => 'varchar', + 'length' => '100', + 'not null' => TRUE, + ), + 'configuration_class' => array( + 'description' => 'The PHP class that defines this plugin\s configuration. It is stored in this table to make lookups faster.', + 'type' => 'varchar', + 'length' => '100', + 'not null' => TRUE, + ), + 'reuse' => array( + 'description' => 'Whether or not this plugin may be reused. It is stored in this table to make lookups faster.', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 1, + ), + ), + 'primary key' => array('slot', 'target'), + ); + + $schema['plugin_info'] = array( + 'description' => 'Tracks all available plugins in the system.', + 'fields' => array( + 'plugin' => array( + 'description' => 'The machine-readable name of the plugin.', + 'type' => 'varchar', + 'length' => '50', + 'not null' => TRUE, + ), + 'slot' => array( + 'description' => 'The machine-readable name of the slot this plugin is for.', + 'type' => 'varchar', + 'length' => '50', + 'not null' => TRUE, + ), + 'title' => array( + 'description' => 'The human-readable name of the plugin.', + 'type' => 'varchar', + 'length' => '100', + 'not null' => TRUE, + ), + 'class' => array( + 'description' => 'The PHP class that defines this plugin.', + 'type' => 'varchar', + 'length' => '100', + 'not null' => TRUE, + ), + 'configuration_class' => array( + 'description' => 'The PHP class that defines this plugin\s configuration.', + 'type' => 'varchar', + 'length' => '100', + 'not null' => TRUE, + ), + 'description' => array( + 'description' => 'A human-readable description of this plugin.', + 'type' => 'text', + 'not null' => TRUE, + ) + ), + 'primary key' => array('plugin', 'slot'), + ); + + $schema['plugin_slot_info'] = array( + 'description' => 'Tracks all registered slots in the system that plugins can fulfill.', + 'fields' => array( + 'slot' => array( + 'description' => 'The machine-readable name of the slot.', + 'type' => 'varchar', + 'length' => '50', + 'not null' => TRUE, + ), + 'title' => array( + 'description' => 'The human-readable name of the slot.', + 'type' => 'varchar', + 'length' => '100', + 'not null' => TRUE, + ), + 'interface' => array( + 'description' => 'The PHP interface that defines this slot.', + 'type' => 'varchar', + 'length' => '50', + 'not null' => TRUE, + ), + 'configuration_interface' => array( + 'description' => 'The PHP interface that defines this slot\'s configuration object.', + 'type' => 'varchar', + 'length' => '50', + 'not null' => TRUE, + ), + 'reuse' => array( + 'description' => 'Whether or not to reuse plugin objects on this slot when requested multiple times.', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 1, + ), + 'default_plugin' => array( + 'description' => 'The default plugin id for this slot if one is not otherwise configured.', + 'type' => 'varchar', + 'length' => '50', + 'not null' => TRUE, + ), + 'description' => array( + 'description' => 'A human-readable description of this slot.', + 'type' => 'text', + 'not null' => FALSE, + ), + ), + 'primary key' => array('slot'), + ); + $schema['queue'] = array( 'description' => 'Stores items in queues.', 'fields' => array(