entity properties From: fago --- includes/common.inc | 27 ++++ includes/properties.inc | 277 ++++++++++++++++++++++++++++++++++++++ modules/node/node.info | 1 modules/node/node.properties.inc | 125 +++++++++++++++++ modules/system/system.test | 45 ++++++ modules/user/user.info | 1 modules/user/user.properties.inc | 67 +++++++++ 7 files changed, 542 insertions(+), 1 deletions(-) create mode 100644 includes/properties.inc create mode 100644 modules/node/node.properties.inc create mode 100644 modules/user/user.properties.inc diff --git a/includes/common.inc b/includes/common.inc index bb026b8..49f8dd7 100644 --- a/includes/common.inc +++ b/includes/common.inc @@ -2848,7 +2848,7 @@ function drupal_clear_css_cache() { * - 'file': Path to the file relative to base_path(). * - 'inline': The JavaScript code that should be placed in the given scope. * - 'external': The absolute path to an external JavaScript file that is not - * hosted on the local server. These files will not be aggregated if + * hosted on the local server. These files will not be aggregated if * JavaScript aggregation is enabled. * - 'setting': An array with configuration options as associative array. The * array is directly placed in Drupal.settings. All modules should wrap @@ -5130,3 +5130,28 @@ function entity_get_controller($entity_type) { } return $controllers[$entity_type]; } + +/** + * Returns a DrupalEntityPropertyWrapper or DrupalPropertyFormatWrapper + * dependent whether the passed data is an entity. + * + * @param $type + * The type of the passed data. + * @param $data + * The data to wrap. + * @param $options + * (optional) A keyed array of options. The supported options vary by class. + * @param $entity + * (optional) If a non-entity property is given, the entity to which this + * property belongs. + * @param $entity_type + * (optional) If a non-entity property is given, the type of the entity to + * which this property belongs. + * @return + * An instance of DrupalPropertyWrapperInterface. + */ +function drupal_get_property_wrapper($type, $data, array $options, $entity = NULL, $entity_type = NULL) { + $entity_info = entity_get_info(); + $class = isset($entity_info[$type]) ? 'DrupalEntityPropertyWrapper' : 'DrupalPropertyFormatWrapper'; + return new $class($type, $data, $options, $entity, $entity_type); +} diff --git a/includes/properties.inc b/includes/properties.inc new file mode 100644 index 0000000..f1fa665 --- /dev/null +++ b/includes/properties.inc @@ -0,0 +1,277 @@ +node->author->name; + * @endcode + */ +interface DrupalPropertyWrapperInterface extends IteratorAggregate { + + /** + * Constructor. + * + * @see drupal_get_property_wrapper(). + */ + public function __construct($type, $data, array $options = array()); + + /** + * Get the data wrapped by this object. + */ + public function get(); + + /** + * Magic method: Get a derived value. + */ + public function __get($name); + + /** + * Magic method: Check whether a derived value of this name is supported. + */ + public function __isset($name); + +} + +/** + * Provides a wrapper for entities which eases dealing with entity properties. + */ +class DrupalEntityPropertyWrapper implements DrupalPropertyWrapperInterface { + + protected $entityType; + protected $entity; + protected $info; + protected $options; + protected $cache = array(); + + /** + * Construct a new DrupalEntityPropertyWrapper object. + * + * @param $entityType + * The type of the passed entity. + * @param $entity + * The entity with which properties we deal. + * @param $options + * (optional) A keyed array of options. Supported are: + * - language: A language object to be used when getting locale-sensitive + * properties. + * - sanitize: A boolean flag indicating that textual properties should be + * sanitized for display to a web browser. Defaults to FALSE. + */ + public function __construct($entityType, $entity, array $options = array()) { + $this->entityType = $entityType; + $this->entity = $entity; + $this->info = entity_get_info($entityType) + array('properties' => array()); + $this->options = $options + array('sanitize' => FALSE, 'language' => NULL); + } + + /** + * Gets the info about the given property. + * + * @param $name + * The name of the property. + * @throws DrupalEntityPropertyException + * If there is no such property. + * @return + * An array of info about the property. + */ + public function getPropertyInfo($name) { + if (!isset($this->info['properties'][$name])) { + throw new DrupalEntityPropertyException('Unknown entity property '. check_plain($name). '.'); + } + $info = $this->info['properties'][$name] + array('type' => 'string'); + $defaults = ($info['type'] == 'string' && empty($info['getter callback'])) ? array('sanitize' => 'check_plain') : array(); + return $info + $defaults; + } + + /** + * Magic method: Get a property. + * + * @return + * An instance of DrupalPropertyWrapperInterface. + */ + public function __get($name) { + // Look it up in the cache if possible. + if (!array_key_exists($name, $this->cache)) { + $info = $this->getPropertyInfo($name); + $this->cache[$name] = NULL; + + if (!empty($info['getter callback']) && drupal_function_exists($info['getter callback'])) { + $this->cache[$name] = $info['getter callback']($this->entity, $this->options, $name, $this->entityType); + } + elseif (is_array($this->entity) && isset($this->entity[$name])) { + $this->cache[$name] = $this->entity[$name]; + } + elseif (is_object($this->entity) && isset($this->entity->$name)) { + $this->cache[$name] = $this->entity->$name; + } + // Return another wrapper to support chained usage. + if (isset($this->cache[$name]) && !empty($this->options['sanitize']) && !empty($info['sanitize']) && function_exists($info['sanitize'])) { + $this->cache[$name] = $info['sanitize']($this->cache[$name]); + } + $this->cache[$name] = drupal_get_property_wrapper($info['type'], $this->cache[$name], $this->options, $this->entity, $this->entityType); + } + return $this->cache[$name]; + } + + /** + * Magic method: Set a property. + */ + public function __set($name, $value) { + $info = $this->getPropertyInfo($name); + if (!empty($info['setter callback']) && drupal_function_exists($info['setter callback'])) { + $info['setter callback']($this->entity, $name, $value, $this->entityType); + } + elseif (!isset($info['setter callback']) && is_array($this->entity)) { + $this->entity[$name] = $value; + } + elseif (!isset($info['setter callback']) && is_object($this->entity)) { + $this->entity->$name = $value; + } + throw new DrupalEntityPropertyException('Entity property '. check_plain($name). " doesn't support writing."); + } + + /** + * Magic method: isset() can be used to check if a property is known. + */ + public function __isset($name) { + return isset($this->info['properties'][$name]); + } + + /** + * Get the entity wrapped by this object. + */ + public function get() { + return $this->entity; + } + + public function getIterator() { + return new ArrayIterator(array_keys($this->info['properties'])); + } +} + + +/** + * Class that eases applying token formats for returned properties. + */ +class DrupalPropertyFormatWrapper implements DrupalPropertyWrapperInterface { + + protected $type; + protected $data; + protected $info; + protected $options; + protected $entityType; + protected $entity; + + /** + * Construct a new DrupalPropertyFormatWrapper object. + * + * @param $type + * The type of the passed data. + * @param $data + * The data to format. + * @param $options + * (optional) A keyed array of options. Supported are: + * - language: A language object to be used when generating + * locale-sensitive formats. + * - formats: An array of further formats for this property + * as defined in hook_entity_info(). + * @param $entity + * (optional) The entity to which this property belongs. + * @param $entityType + * (optional) The type of the entity to which this property belongs. + */ + public function __construct($type, $data, array $options = array(), $entity = NULL, $entityType = NULL) { + $this->type = $type; + $this->data = $data; + $this->options = $options + array('language' => NULL, 'formats' => array()); + $this->entityType = $entityType; + $this->entity = $entity; + } + + /** + * We use this to init $this->info onyl when needed. + */ + protected function initInfo() { + if (!isset($this->info)) { + $this->info = token_get_format_info($this->type) + array('formats' => array()); + $this->info['formats'] = $this->options['formats'] + $this->info['formats']; + } + } + + + /** + * Gets the info about the given format. + * + * @param $name + * The name of the format. + * @throws DrupalEntityPropertyException + * If there is no such format. + * @return + * An array of info about the format. + */ + public function getFormatInfo($name) { + $this->initInfo(); + if (!isset($this->info['formats'][$name])) { + throw new DrupalEntityPropertyException('Unknown format '. check_plain($name). '.'); + } + return $this->info['formats'][$name]; + } + + /** + * Magic method: Format the data. + */ + public function __get($name) { + $info = $this->getFormatInfo($name); + if (isset($this->data) && !empty($info['callback']) && drupal_function_exists($info['callback'])) { + return $info['callback']($this->data, $this->options, $name, $this->entity, $this->entityType); + } + } + + /** + * Magic method: isset() can be used to check if a format is known. + */ + public function __isset($name) { + $this->initInfo(); + return isset($this->info['formats'][$name]); + } + + /** + * Get the data wrapped by this object. + */ + public function get() { + return $this->data; + } + + public function getIterator() { + $this->initInfo(); + return new ArrayIterator(array_keys($this->info['formats'])); + } + + /** + * For converting to a string use the default format, if any. + */ + public function __toString() { + $this->initInfo(); + if (isset($this->info['default format'])) { + return $this->{$this->info['default format']}; + } + return (string)$this->get(); + } +} + +/** + * Provide a separate Exception so it can be caught separately. + */ +class DrupalEntityPropertyException extends Exception { + +} + diff --git a/modules/node/node.info b/modules/node/node.info index 1a6f91c..7675125 100644 --- a/modules/node/node.info +++ b/modules/node/node.info @@ -10,4 +10,5 @@ files[] = node.admin.inc files[] = node.pages.inc files[] = node.install files[] = node.test +files[] = node.properties.inc required = TRUE diff --git a/modules/node/node.properties.inc b/modules/node/node.properties.inc new file mode 100644 index 0000000..0d82f27 --- /dev/null +++ b/modules/node/node.properties.inc @@ -0,0 +1,125 @@ + t("Node ID"), + 'type' => 'integer', + 'description' => t("The unique ID of the node."), + ); + $node['vid'] = array( + 'name' => t("Revision ID"), + 'type' => 'integer', + 'description' => t("The unique ID of the node's latest revision."), + ); + $node['tnid'] = array( + 'name' => t("Translation set ID"), + 'type' => 'integer', + 'description' => t("The unique ID of the original-language version of this node, if one exists."), + ); + $node['uid'] = array( + 'name' => t("User ID"), + 'type' => 'integer', + 'description' => t("The unique ID of the user who posted the node."), + ); + $node['type'] = array( + 'name' => t("Content type"), + 'description' => t("The type of the node."), + ); + $node['typeName'] = array( + 'name' => t("Content type name"), + 'description' => t("The human-readable name of the node type."), + 'getter callback' => 'node_get_properties', + ); + $node['title'] = array( + 'name' => t("Title"), + 'description' => t("The title of the node."), + ); + $node['body'] = array( + 'name' => t("Body"), + 'description' => t("The main body text of the node."), + 'getter callback' => 'field_property_get', + ); + $node['summary'] = array( + 'name' => t("Summary"), + 'description' => t("The summary of the node's main body text."), + 'getter callback' => 'field_property_get', + ); + $node['language'] = array( + 'name' => t("Language"), + 'description' => t("The language the node is written in."), + ); + $node['url'] = array( + 'name' => t("URL"), + 'description' => t("The URL of the node."), + 'getter callback' => 'node_get_properties', + ); + $node['editUrl'] = array( + 'name' => t("Edit URL"), + 'description' => t("The URL of the node's edit page."), + 'getter callback' => 'node_get_properties', + ); + $node['created'] = array( + 'name' => t("Date created"), + 'type' => 'date', + 'description' => t("The date the node was posted."), + ); + $node['changed'] = array( + 'name' => t("Date changed"), + 'type' => 'date', + 'description' => t("The date the node was most recently updated."), + ); + $node['authorName'] = array( + 'name' => t("Author name"), + 'description' => t("The node author's name."), + 'getter callback' => 'node_get_properties', + ); + $node['author'] = array( + 'name' => t("Author"), + 'type' => 'user', + 'description' => t("The author of the node."), + 'getter callback' => 'node_get_properties', + ); +} + +function field_property_get($object, array $options, $name, $obj_type) { + return $options['sanitize'] ? $object->$name[0]['safe'] : $object->$name[0]['value']; +} + +/** + * Callback for getting node properties. + * @see node_entity_info_alter(). + */ +function node_get_properties($node, array $options, $name, $entity_type) { + + switch ($name) { + case 'typeName': + $type_name = node_type_get_name($node->type); + return $options['sanitize'] ? check_plain($type_name) : $type_name; + + case 'url': + return url('node/' . $node->nid, $options + array('absolute' => TRUE)); + + case 'editUrl': + return url('node/' . $node->nid . '/edit', $options + array('absolute' => TRUE)); + + case 'authorName': + $name = ($node->uid == 0) ? variable_get('anonymous', t('Anonymous')) : $node->name; + return $options['sanitize'] ? filter_xss($name) : $name; + + case 'author': + return user_load($node->uid); + } +} + + diff --git a/modules/system/system.test b/modules/system/system.test index 9310d83..6cbf385 100644 --- a/modules/system/system.test +++ b/modules/system/system.test @@ -967,6 +967,51 @@ class SystemThemeFunctionalTest extends DrupalWebTestCase { } } +/** + * Test entity propert wrapper. + */ +class DrupalEntityPropertyWrapperTestCase extends DrupalWebTestCase { + public static function getInfo() { + return array( + 'name' => 'Entity properties', + 'description' => 'Tests using the drupal entity property wrappers.', + 'group' => 'System', + ); + } + + /** + * Creates a user and a node, then tests the tokens generated from them. + */ + function testEntityPropertyWrapper() { + $account = $this->drupalCreateUser(); + $node = $this->drupalCreateNode(array('uid' => $account->uid, 'title' => 'Is it bold?')); + // For testing sanitizing give the user a malicious user name + $account = user_save($account, array('name' => 'BadName')); + + // Fetch the object so every properties are set as usual. + $node = node_load($node->nid); + + // First test without sanitizing. + $wrapper = new DrupalEntityPropertyWrapper('node', $node); + + $this->assertEqual($node->title, $wrapper->title, 'Getting property.'); + $this->assertEqual($node->name, $wrapper->authorName, 'Getting property with getter callback.'); + + // Test sanitized output. + $wrapper = new DrupalEntityPropertyWrapper('node', $node, array('sanitize' => TRUE)); + + $this->assertEqual(check_plain($node->title), $wrapper->title, 'Getting sanitized property.'); + $this->assertEqual(filter_xss($node->name), $wrapper->authorName, 'Getting sanitized property with getter callback.'); + + // Test chaining + $this->assertEqual(check_plain($account->mail), $wrapper->author->mail, 'Testing chained usage.'); + $this->assertEqual(filter_xss($account->name), $wrapper->author->name, 'Testing chained usage with callback and sanitizing.'); + + // Test iterator + $type_info = entity_get_info('node'); + $this->assertEqual(iterator_to_array($wrapper->getIterator()), array_keys($type_info['properties']), 'Iterator is working.'); + } +} /** * Test the basic queue functionality. diff --git a/modules/user/user.info b/modules/user/user.info index e0066a7..04f049e 100644 --- a/modules/user/user.info +++ b/modules/user/user.info @@ -9,4 +9,5 @@ files[] = user.admin.inc files[] = user.pages.inc files[] = user.install files[] = user.test +files[] = user.properties.inc required = TRUE diff --git a/modules/user/user.properties.inc b/modules/user/user.properties.inc new file mode 100644 index 0000000..dc8454d --- /dev/null +++ b/modules/user/user.properties.inc @@ -0,0 +1,67 @@ + t('User ID'), + 'type' => 'integer', + 'description' => t("The unique ID of the user account."), + ); + $user['name'] = array( + 'name' => t("Name"), + 'description' => t("The login name of the user account."), + 'getter callback' => 'user_get_properties', + ); + $user['mail'] = array( + 'name' => t("Email"), + 'description' => t("The email address of the user account."), + ); + $user['url'] = array( + 'name' => t("URL"), + 'description' => t("The URL of the account profile page."), + 'getter callback' => 'user_get_properties', + ); + $user['editUrl'] = array( + 'name' => t("Edit URL"), + 'description' => t("The url of the account edit page."), + 'getter callback' => 'user_get_properties', + ); + $user['login'] = array( + 'name' => t("Last login"), + 'description' => t("The date the user last logged in to the site."), + 'type' => 'date', + ); + $user['created'] = array( + 'name' => t("Created"), + 'description' => t("The date the user account was created."), + 'type' => 'date', + ); +} + +/** + * Callback for getting user properties. + */ +function user_get_properties($account, array $options, $name, $entity_type) { + + switch ($name) { + case 'name': + $name = ($account->uid == 0) ? variable_get('anonymous', t('Anonymous')) : $account->name; + return $options['sanitize'] ? filter_xss($name) : $name; + + case 'url': + return url("user/$account->uid", $options + array('absolute' => TRUE)); + + case 'editUrl': + return url("user/$account->uid/edit", $options + array('absolute' => TRUE)); + } +}