Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP

Loading…

Add virtual fields #569

Open
wants to merge 1 commit into from

6 participants

@hans-d

Summary:

Virtual fields in using methods in the Model, that are almost like the current behaviour to implement methods for an Entity.

Virtual fields are defined in the model like _schema (although that is done for SQL db's by the datasource), eg:

protected $_properties = array(
    'extra_field_1' => 'ExtraField`, 
    // expects model to have methods getExtraField, setExtraField, issetExtraField

    'extra_field_2' => array('get' => 'DoSomethingFancy', 'set' => false) 
    // expects model to have methods DoSomethingFancy and issetextra_field_2
    // set is done like for normal fields: data is added to _updated
)

that would save a lot of checks and makes the behaviour more explicit.

Outstanding:

  • set behaviour in data\entity\Document - need some help with that
  • squash commits (when everything is ready)
@hans-d

Open for improvements etc.

data/Entity.php
@@ -173,9 +188,27 @@ public function __set($name, $value = null) {
* @return mixed Result.
*/
public function __isset($name) {
+ if ($callable = getModelMethod('isset_'. $name)) {
@nateabele Owner

Does this line even execute without a fatal error?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@nateabele
Owner

Seems like a reasonable feature, only having underscores in method names is a coding standards violation (as is not prefixing protected methods with an underscore), and as I mentioned in the code, it looks like this must not have even been tested, as I don't see how the __isset() implementation could even execute.

Let's see some tests and documentation on this first. Thanks.

@hans-d

@nateabele no probs.. This PR was also more to shape the feature in the correct way. I will submit a new PR with squashed commit when all is sorted out.

What kind of documentation are you looking for, besides updated docblocks ?

I explicitly choose the underscore, as most of the fieldnames will be lowercased with the occasional underscore (at least in the SQL environments I worked) and to maintain consistency at the field level between normal and virtual fields. What do you suggest?

@jails
Collaborator

What do you mean by "virtual fields" ? I don't figure out the use case.

@hans-d

Virtual fields, or derived fields. Currently it is not yet possible to easily do the derivation using the datasource (see also #558), but it can be done using functions/methods in php.

Use cases I currently have:

  • data is entered using 1 field, and on save this is normalised and broken into mutliple fields. I use the combined field as a virtual field that via the get method provides the combined data from the original fields.
  • a phone number is stored normalized as fully international, but entered and shown localized for the current country (eg local phone numbers without the international prefix, international numbers with the prefix)
  • dates are stored in iso format, but entered/shown in the local format. (there is only one local format)

Other use cases:

  • use improved field names
  • other transformations and/or calculations

The advantages:

  • model deals with the data transformations
  • view only have to work with field, and not functions, and is directly available for forms
  • controller has no clue what is happening
  • in case of the combined field entry, in other views I use the separate fields to show data, so I need both combined and separate fields
  • transformations are only done when needed
  • re-uses the Entity to model callback

Current workarounds:

  • do the mapping in the controller by explicitly setting the needed fields
  • call the entity/model to do it, either as a separate call from the controller or via some filters on data retrieval
@hans-d

The getModelMethod might be even more useful if we do not check on the existence of a (static) method, but only test if it is callable. That way we can use the __call and __callStatic in the model. to do more magic...

@jails
Collaborator

It's a lot of checks ! Why not making Entity::set() filterable instead ?
You can explode your "combined field" on set() call. Then you can get back the "virtual fields" with a minimal :

public function &__get($name) {
    if (isset($this->_updated[$name])) {
        return $this->_updated[$name];
    }
    $null = null;
    return $null;
}
@hans-d

I'm not too happy about the checks either... Filterable methods (set, __get, __set and __isset) can be part of the solution. For my current situation I might be able to get away with only one on set.

I prefer having methods in the Model, that are almost like the current behaviour to implement methods for an Entity. That way I have most of the behaviour in a single place (methods in the model), without requiring filters.

if the the virtual fields are defined in the model like _schema (although that is done for SQL db's by the datasource), eg:

protected $_virtual = array(
    'extra_field_1' => 'ExtraField`, 
    // expects model to have methods GetExtraField, SetExtraField, IssetExtraField

    'extra_field_2' => array('get' => 'DoSomethingFancy') 
    // will only accept a get, a set will put the value in Enity->_updated
    // the get method will be DoSomethingFancy
)

that would save a lot of checks and makes the behaviour more explicit.

@jails
Collaborator

Currently __set() use set() (or it should). Filterting __get is imo not necessary since pre-processing can be done during the set() (same for isset()).

$entity->vitual = 'data1'; // will populate the real field based on virtual data
$entity->real = 'data2'; // will populate the virtual field based on real data

Imo you can reproduce your virtual behavior with setting a filter on set() in the __construct() of your model and keeping all the methods in models.

@jails
Collaborator

Well personnaly I would prefer the following solution and let people override __set rather than adding fixed behaviors :
https://gist.github.com/3090475

@hans-d

Doing it like this will add to the overhead of larger entity sets. Each individual property set for a model bound entity now results in 3 additional function calls (invokeMethod, call_user_func, setter). For my "benchmark" dataset, this will be 20k (rows) * 3 (calls) * 6 (fields) = 360k additional function calls, and without these calls it is already way slower as retrieving the same dataset using doctrine (slower as in li3 hits the max execution time where doctrine is already done).

The new fixed behaviour builds on existing behaviour for entity functions, only adding an optional property in the model when used to keep the number of required checks and calls to a minimum.

@hans-d

@nateabele you now have some documentation and tests...

@hans-d hans-d Add virtual fields
define in the model as functions
identify in the model using $_properties
49d7851
@d1rk

Any news on this one?

@L-P
L-P commented

That's definitely something I could use, I'd like some news too if possible.

@2chg

Is someone still working in this? I'm looking for this feature right now and unfortunately the gist link from two years ago is no longer valid. :(

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Commits on Jul 17, 2012
  1. @hans-d

    Add virtual fields

    hans-d authored
    define in the model as functions
    identify in the model using $_properties
This page is out of date. Refresh to see the latest.
View
147 data/Entity.php
@@ -75,6 +75,16 @@ class Entity extends \lithium\core\Object {
protected $_updated = array();
/**
+ * Contains the callables for recognized virtual fields. These values will not be persisted.
+ * Virtual fields are derived fields that are made available via functions in the model.
+ *
+ * @see lithium\data\Entity::_initVirtual();
+ * @var mixed - array when holding data
+ * - null when not yet initialized
+ */
+ protected $_virtual = null;
+
+ /**
* An array of key/value pairs corresponding to fields that should be updated using atomic
* incrementing / decrementing operations. Keys match field names, and values indicate the value
* each field should be incremented or decremented by.
@@ -130,6 +140,67 @@ public function __construct(array $config = array()) {
parent::__construct($config + $defaults);
}
+ /**
+ * Initializes the virtual field access.
+ *
+ * Only called when fields are accessed that are not yet in _updated or
+ * _relationships.
+ * Information is retrieved the static property `_properties` defined in the model
+ * where the Entity is bound too.
+ * {{{
+ * class Examples extends \lithium\data\Model {
+ *
+ * public static $_properties = array(
+ * 'field_x' => 'MyFieldX',
+ * 'field_y' => array('SomeOtherField', 'set' => false),
+ * 'field_z' => array('get' => 'mySpecialFunction')
+ * );
+ *
+ * // rest of the class ...
+ * }}}
+ *
+ * The example above will introduce the fields 'field_x', 'field_z' that can be used
+ * like normal fields, eg `echo $entity->field_x;`.
+ * When a field is read, set, or isset, methods defined in the Model will be called.
+ * For 'field_x', those will be 'getMyFieldX()', 'setMyFieldX()', 'issetMyFieldX()';
+ * the value given prefixed with 'get', 'set' and 'isset'. As first paramater they
+ * should accept $entity, and only 'set' needs a second parameter for the $value to
+ * be set.
+ * For 'field_y', no setter method is called as indicated by setting 'set' to false.
+ * The entity will set the value just to _updated[] like any other set action.
+ * For 'field_z', the getter method is named 'mySpecialFunction', for 'set' and 'isset'
+ * the method base name will be 'field_z'.
+ */
+ protected function _initVirtual() {
+ $this->_virtual = array('get' => array(), 'set' => array(), 'isset' => array());
+ if (!$model = $this->_model) {
+ return;
+ }
+ if (!class_exists($model) || !isset($model::$_properties) || !is_array($model::$_properties)) {
+ return;
+ }
+ foreach ($model::$_properties as $property => $options) {
+ if (!is_string($property)) {
+ continue;
+ }
+ $defaults = array(
+ 0 => $property, 'get' => true, 'set' => true, 'isset' => true
+ );
+ $options = (array)$options + $defaults;
+ $baseName = $options[0];
+ foreach (array('get', 'set', 'isset') as $key) {
+ $name = $options[$key];
+ if (!$name) {
+ continue;
+ }
+ if ($name === true) {
+ $name = $key . $baseName;
+ }
+ $this->_virtual[$key][$property] = $name;
+ }
+ }
+ }
+
protected function _init() {
parent::_init();
$this->_updated = $this->_data;
@@ -138,6 +209,11 @@ protected function _init() {
/**
* Overloading for reading inaccessible properties.
*
+ * Check '_relationships' and '_updated' first. When
+ * not found, make sure that '_virtual' is initialized and
+ * try to access it as a virtual property.
+ * If nothing is found, null will be returned.
+ *
* @param string $name Property name.
* @return mixed Result.
*/
@@ -148,6 +224,14 @@ public function &__get($name) {
if (isset($this->_updated[$name])) {
return $this->_updated[$name];
}
+ if (!isset($this->_virtual)) {
+ $this->_initVirtual();
+ }
+ if (isset($this->_virtual['get'][$name])) {
+ $getter = $this->_virtual['get'][$name];
+ $result = $this->$getter();
+ return $result;
+ }
$null = null;
return $null;
}
@@ -155,6 +239,12 @@ public function &__get($name) {
/**
* Overloading for writing to inaccessible properties.
*
+ * If called with only an array as first parameter, call __set
+ * on the individual entries.
+ *
+ * First checks if the property is already known. Otherwise try it via
+ * the virtual setter. Else treat is a new normal property.
+ *
* @param string $name Property name.
* @param string $value Property value.
* @return mixed Result.
@@ -163,17 +253,39 @@ public function __set($name, $value = null) {
if (is_array($name) && !$value) {
return array_map(array(&$this, '__set'), array_keys($name), array_values($name));
}
- $this->_updated[$name] = $value;
+ if (isset($this->_updated[$name])) {
+ return $this->_updated[$name] = $value;
+ }
+ if (!isset($this->_virtual)) {
+ $this->_initVirtual();
+ }
+ if (isset($this->_virtual['set'][$name])) {
+ $setter = $this->_virtual['set'][$name];
+ return $this->$setter($value);
+ }
+ return $this->_updated[$name] = $value;
}
/**
* Overloading for calling `isset()` or `empty()` on inaccessible properties.
*
+ * Will check existing properties first before going virtual.
+ *
* @param string $name Property name.
* @return mixed Result.
*/
public function __isset($name) {
- return isset($this->_updated[$name]) || isset($this->_relationships[$name]);
+ if (isset($this->_updated[$name]) || isset($this->_relationships[$name])) {
+ return true;
+ }
+ if (!isset($this->_virtual)) {
+ $this->_initVirtual();
+ }
+ if (isset($this->_virtual['isset'][$name])) {
+ $issetter = $this->_virtual['isset'][$name];
+ return $this->$issetter();
+ }
+ return false;
}
/**
@@ -384,28 +496,51 @@ public function modified() {
return $fields;
}
- public function export() {
- return array(
+ public function export(array $options=array()) {
+ $options += array('virtual' => false);
+ $export = array(
'exists' => $this->_exists,
'data' => $this->_data,
'update' => $this->_updated,
'increment' => $this->_increment
);
+ if ($options['virtual']) {
+ $export['virtual'] = $this->_exportVirtual();
+ }
+ return $export;
+ }
+
+ protected function _exportVirtual() {
+ if (!isset($this->_virtual)) {
+ $this->_initVirtual();
+ }
+ $export = array();
+ foreach ($this->_virtual['get'] as $name => $getter) {
+ $export[$name] = $this->$getter();
+ }
+ return $export;
}
/**
* Converts the data in the record set to a different format, i.e. an array.
*
+ * Optionally exports virtual fields.
+ *
* @param string $format currently only `array`
- * @param array $options
+ * @param array $options 'virtual' true will export virtual fields. Default false
* @return mixed
*/
public function to($format, array $options = array()) {
+ $options += array('virtual' => false);
switch ($format) {
case 'array':
$data = $this->_updated;
$rel = array_map(function($obj) { return $obj->data(); }, $this->_relationships);
- $data = $rel + $data;
+ $extra = array();
+ if ($options['virtual']) {
+ $extra = $this->_exportVirtual();
+ }
+ $data = $rel + $data + $extra;
$result = Collection::toArray($data, $options);
break;
default:
View
19 data/entity/Document.php
@@ -115,7 +115,9 @@ public function &__get($name) {
}
$result = parent::__get($name);
- if ($result !== null || array_key_exists($name, $this->_updated)) {
+ if ($result !== null || array_key_exists($name, $this->_updated)
+ || array_key_exists($name, $this->_virtual['get'])
+ ) {
return $result;
}
@@ -135,14 +137,14 @@ public function &__get($name) {
return $null;
}
- public function export() {
+ public function export(array $options=array()) {
foreach ($this->_updated as $key => $val) {
if ($val instanceof self) {
$path = $this->_pathKey ? "{$this->_pathKey}." : '';
$this->_updated[$key]->_pathKey = "{$path}{$key}";
}
}
- return parent::export() + array('key' => $this->_pathKey);
+ return parent::export($options) + array('key' => $this->_pathKey);
}
/**
@@ -246,17 +248,6 @@ protected function _setNested($name, $value) {
}
/**
- * PHP magic method used to check the presence of a field as document properties, i.e.
- * `$document->_id`.
- *
- * @param $name The field name, as specified with an object property.
- * @return boolean True if the field specified in `$name` exists, false otherwise.
- */
- public function __isset($name) {
- return isset($this->_updated[$name]);
- }
-
- /**
* PHP magic method used when unset() is called on a `Document` instance.
* Use case for this would be when you wish to edit a document and remove a field, ie.:
* {{{
View
48 tests/cases/data/EntityTest.php
@@ -119,6 +119,54 @@ public function testModified() {
$entity->set($data);
$this->assertEqual(array('foo' => true, 'baz' => true), $entity->modified());
}
+
+ public function testVirtual() {
+ $model = 'lithium\tests\mocks\data\MockModelVirtual';
+ $entity = new Entity(array('model' => $model, 'data' => array('foo' => true)));
+ $this->assertTrue($entity->validates());
+
+ $this->assertEqual(true, $entity->foo);
+ $this->assertTrue(isset($entity->foo));
+ $this->assertFalse(isset($entity->bar));
+
+ $this->assertFalse(isset($entity->fielda));
+ $entity->fielda = 'a';
+ $this->assertTrue(isset($entity->fielda));
+ $this->assertTrue(isset($entity->bar));
+ $this->assertEqual('a', $entity->fielda);
+ $this->assertEqual('a', $entity->bar);
+ $entity->bar = null;
+
+ $this->assertFalse(isset($entity->fieldb));
+ $entity->fieldb = 'b';
+ $this->assertTrue(isset($entity->fieldb));
+ $this->assertFalse(isset($entity->bar));
+ $this->assertEqual('b', $entity->fieldb);
+ $this->assertEqual(null, $entity->bar);
+ $entity->bar = null;
+
+ $this->assertFalse(isset($entity->field_c));
+ $entity->bar = 'c';
+ $this->assertTrue(isset($entity->field_c));
+ $this->assertTrue(isset($entity->bar));
+ $this->assertEqual('c', $entity->field_c);
+ $this->assertEqual('c', $entity->bar);
+
+ $export = $entity->export();
+ $expected = array('exists', 'data', 'update', 'increment');
+ $this->assertEqual($expected, array_keys($export));
+ $expected = array('foo' => true, 'bar' => 'c', 'fieldb' => 'b');
+ $this->assertEqual($expected, $export['update']);
+
+ $export = $entity->export(array('virtual' => true));
+ $expected = array('exists', 'data', 'update', 'increment', 'virtual');
+ $this->assertEqual($expected, array_keys($export));
+ $expected = array('fielda' => 'c', 'fieldb' => 'c', 'field_c' => 'c');
+ $this->assertEqual($expected, $export['virtual']);
+
+ $this->expectException('No model bound or unhandled method call `setfield_c`.');
+ $this->assertTrue($entity->field_c = 'd');
+ }
}
?>
View
56 tests/mocks/data/MockModelVirtual.php
@@ -0,0 +1,56 @@
+<?php
+/**
+ * Lithium: the most rad php framework
+ *
+ * @copyright Copyright 2012, Union of RAD (http://union-of-rad.org)
+ * @license http://opensource.org/licenses/bsd-license.php The BSD License
+ */
+
+namespace lithium\tests\mocks\data;
+
+use lithium\tests\mocks\data\source\database\adapter\MockAdapter;
+
+class MockModelVirtual extends \lithium\data\Model {
+
+ protected $_meta = array(
+ 'connection' => false
+ );
+
+ public static $_properties = array(
+ 'fielda' => 'FieldA',
+ 'fieldb' => array('FieldB', 'get' => 'getFieldZ', 'set' => false),
+ 'field_c' => array('get' => 'myTest')
+ );
+
+ public function getFieldA($entity) {
+ return $entity->bar;
+ }
+
+ public function setFieldA($entity, $value) {
+ $entity->bar = $value;
+ }
+
+ public function issetFieldA($entity) {
+ return isset($entity->bar);
+ }
+
+ public function issetFieldB($entity) {
+ return $entity->bar;
+ }
+
+ public function getFieldZ($entity) {
+ return $entity->bar;
+ }
+
+ public function myTest($entity) {
+ return $entity->bar;
+ }
+
+ public function issetfield_c($entity) {
+ return isset($entity->bar);
+ }
+
+ // must be missing public function setfield_c
+}
+
+?>
Something went wrong with that request. Please try again.