2011-04-04

Dynamic Assets: Part II - Construction

In the last post I introduced a feature in my team's current project that will allow our users (and us!) to dynamically define "assets", and I explained the syntax for defining an asset.

Now that we have our definition, we need to actually construct an asset instance. There are several classes involved here: the AssetField, which stores the display and validation information contained in the definition for a single field; the Asset which is basically a container wrapped around a list of fields and field values; and the AssetFactory, which reads a definition and constructs an Asset by hanging fields on it.

In the code below, I'm intentionally leaving out most of the gory details because they're boring and I'm focusing more on the design than the implementation.

AssetField is essentially a code translation of the JSON definition. Here's some of what it looks like:
class AssetField
{
    const TypeString   = 'string';
    const TypeInteger  = 'int';
    // etc. for each type
    
    protected $name = null;
    protected $display = null;
    protected $type = self::TypeString;
    // etc. for each attribute that can exist in a field definition
    
    public function getName()
    {
        return $this->name;
    }
    
    // All setters return $this to provide a fluent interface
    public function setName($name)
    {
        $this->name = $name;
        return $this;
    }

    // ... other getters and setters for each property
    // listed in the definition attributes ...
}

// Create a field named "power_sources" whose value is a unique array
// of one or more of the values "Gas", "Electric", "Solar"
// which defaults to having Gas and Solar turned on
// and allows the user to enter their own value if they need to.
$powerSourceField = new AssetField();
$powerSourceField->setName("power_sources")
    ->setUnique(true)
    ->setCollection(true)
    ->setOptions(array("Gas", "Electric", "Solar"))
    ->setDefault(array("Gas", "Solar"));
    ->setOther(true);

Every Asset object will carry around its AssetField objects, so that it can provide information about how it is built. This allows our form construction and validation code to be very generic.

Since Assets don't know what fields will be hung on them, we use PHP's magic `__get` and `__set` methods to set and return field values. However, during implementation we realized that the times we want to know the value of a field are very rare; more often, we want to know the properties of a field. So we also utilize the magic `__call` to give our code access to the underlying field object.

Here is what most of the Asset class looks like:
class Asset
{
    protected $type = null;
    protected $display = null;

    protected $fields = array();
    protected $values = array();

    // Return the field object when its name is called as a class method
    public function __call($fieldName, $args)
    {
        return isset($this->fields[$fieldName]) ? $this->fields[$fieldName] : null;
    }

    // Get the value of the named field
    public function __get($fieldName)
    {
        return isset($this->fields[$fieldName]) ? $this->values[$fieldName] : null;
    }

    // Set the value of the named field
    public function __set($fieldName, $value)
    {
        if (isset($this->fields[$fieldName])) {
           $this->values[$fieldName] = value;
        }
    }

    // Hang a new field on this asset
    public function addField(AssetField $field)
    {
        $name = $field->getName();
        $this->fields[$name] = $field;
        $this->values[$name] = $field->getDefault();
        return $this;
    }

    // ... Helper methods for returning the list of all fields
    // setting the Asset type, display string and instance name format ...
}

// Get an instantiation of our plumbing system example
$plumbing = new Asset();
$plumbing->setType("plumbing")
    ->setDisplay("Home Plumbing")
    ->setInstanceNameFormat("Installed %installation_date%");

$waterSource = new AssetField();
$waterSource->setName("water_source")
    ->setOptions(array("city", "well"))
    ->setOther("Where does the water come from")
    ->setDefault("city");

$installationDate = new AssetField();
$installationDate->setName("installation_date")
    ->setType(AssetField::TypeDate)
    ->setRequired(true);

$waterHeater = new AssetField();
$waterHeater->setName("water_heater")
    ->setType(AssetField::TypeSub)
    ->setOptions(array("gas_heater", "electric_heater"));

$showers = new AssetField();
$showers->setName("showers")
    ->setType(AssetField::TypeSub)
    ->setOptions(array("shower"))
    ->setCollection(true)
    ->setMax(5)
    ->setDefault(array());

$plumbing->addField($waterSource)
    ->addField($installationDate)
    ->addField($waterHeater)
    ->addField($showers);

// Use the asset 
$plumbing->water_source = "well";
$plumbing->installation_date = "06/05/2004";

echo $plumbing->getDisplay() . ": $plumbing";
// "Home Plumbing: Installed 06/05/2004"  <--- comes from an overloaded __toString method

echo "My water comes from a {$plumbing->water_source}"
// "My water comes from a well"

// Instantiate a new shower asset
$shower = new Asset();
// ... set up the asset ...

// add the shower to the plumbing system
$plumbing->showers[] = $shower;

// What is the default value for the water source?
$field = $plumbing->water_source();
$default = $field->getDefault();
Calling a field as a method will return the AssetField object for that property. The field object can then be used in forms and validation. Another benefit of dynamically constructing our Assets in this way is that we can customize any asset on the fly without affecting any other asset of that type. From our plumbing example, let's suppose one user wants to track the serial number of their water heater, but no one else does. We just make sure that any instantiation of a water_heater asset for that user gets an additional "serial_number" field:
// Continuing from above
if ($userId == $customAssetUserId) {
    $serialNumber = new AssetField();
    $serialNumber->setName("serial_number");
    $plumbing->addField($serialNumber);
}
Any form generation and validation code will automatically pick that field up and display it for that user.

The last important class for constructing Assets is the AssetFactory. All Asset instances are constructed through this factory. It takes a type definition, which in the plumbing example is a JSON string. The factory doesn't actually care where the definitions come from or how they are stored, as long as if receives a properly formatted array. AssetFactory is given definitions and then uses the definitions to construct Assets on demand:
class AssetFactory
{
    // List of asset recipes this factory knows how to bake
    protected $definitions = array();

    public function define($definition)
    {
        // ... Validate proper formed-ness of the definition ...
        // ... Set some reasonable defaults for non-specified field attributes ...

        // All assets get an id field
        $definition['fields']['id'] = array(
            'type' => DW_Asset_Field::TypeString,
            'hidden' => true,
        );

        $this->definitions[$definition['type']] = $definition;
    }

    // Construct an asset of the given type
    public function build($type)
    {
        $definition = $this->definitions[$type];

        $asset = new Asset();
        $asset->setType($definition['type'])
            ->setDisplay($definition['display'])
            ->setInstanceNameFormat($format);

        foreach ($definition['fields'] as $name => $fieldDef) {
            $field = new AssetField();
            $field->setName($name)
                ->setType($fieldDef['type'])
                ->setDisplay($fieldDef['display'])
                ->setHidden($fieldDef['hidden'])
                ->setUnique($fieldDef['unique'])
                ->setRequired($fieldDef['required'])
                ->setMin($fieldDef['min'])
                ->setMax($fieldDef['max'])
                ->setOptions($fieldDef['options'])
                ->setOther($fieldDef['other'])
                ->setDefault($fieldDef['default']);

            $asset->addField($field);
            $asset->$name = $fieldDef['default'];
        }

        return $asset;
    }
}

$factory = new AssetFactory();
$factory->define(json_decode('{
    "type" : "plumbing",
    "display" : "Home Plumbing",
    "instance_name" : "Installed %installation_date%",
    "fields" : {
      "water_source" : {
        "type" : "string",
        "options" : ["city", "well"],
        "other" : "Where does the water come from",
        "default" : "city"
      },
      "installation_date" : {
        "type" : "date",
        "required" : true
      },
      "water_heater" : {
        "type" : "subasset",
        "options" : ["gas_heater", "electric_heater"],
      },
      "showers" : {
        "type" : "subasset",
        "options" : ["shower"],
        "collection" : true,
        "max" : 5
      }
    }
  }'));

$plumbing = $factory->build("plumbing");
If we have any customizations, like our serial_number from above, we can call a `customize()` method on the factory from within the `build()` method, or pass the result of the `build()` to some other customization class. At the moment, we don't have any requirements like that. The important thing is that we now have an Asset object that we can pass around to our generic Asset handling code.

Next up, a description of how we generate a form from a generic Asset object.

No comments:

Post a Comment