PlFlagset and PlDBTableEntry are exportable.
[platal.git] / classes / pldbtableentry.php
index 2e1bc11..466a4ff 100644 (file)
@@ -39,6 +39,16 @@ class PlDBNoSuchFieldException extends PlException
     }
 }
 
+class PlDBNoSuchKeyException extends PlException
+{
+    public function __construct($key, PlDBTable $table)
+    {
+        parent::__construct('Erreur lors de l\'accès à la base de données',
+                            'No such key ' . $key . ' in table ' . $table->table);
+    }
+}
+
+
 class PlDBIncompleteEntryDescription extends PlException
 {
     public function __construct($field, PlDBTable $table)
@@ -55,8 +65,6 @@ class PlDBTableField
 
     public $name;
     public $inPrimaryKey;
-    public $inUniqueKey;
-    public $inKey;
 
     public $type;
     public $typeLength;
@@ -66,6 +74,9 @@ class PlDBTableField
     public $defaultValue;
     public $autoIncrement;
 
+    private $validator;
+    private $formatter;
+
     public function __construct(array $column)
     {
         $this->name = $column['Field'];
@@ -80,9 +91,7 @@ class PlDBTableField
         }
         $this->allowNull = ($column['Null'] === 'YES');
         $this->autoIncrement = (strpos($column['Extra'], 'auto_increment') !== false);
-        $this->inPrimaryKey = ($column['Key'] === 'PRI');
-        $this->inUniqueKey = $this->inPrimaryKey || ($column['Key'] === 'UNI');
-        $this->inKey = $this->inUniqueKey || ($column['Key'] === 'MUL');
+        $this->inPrimaryKey  = ($column['Key'] == 'PRI');
 
         try {
             $this->defaultValue = $this->format($column['Default']);
@@ -91,6 +100,16 @@ class PlDBTableField
         }
     }
 
+    public function registerFormatter($class)
+    {
+        $this->formatter = $class;
+    }
+
+    public function registerValidator($class)
+    {
+        $this->validator = $class;
+    }
+
     public function format($value, $badNullFallbackToDefault = false)
     {
         if (is_null($value)) {
@@ -101,6 +120,14 @@ class PlDBTableField
                 return $this->defaultValue;
             }
             throw new PlDBBadValueException($value, $this, 'null not allowed');
+        }
+        if (!is_null($this->validator)) {
+            $class = $this->validator;
+            new $class($this, $value);
+        }
+        if (!is_null($this->formatter)) {
+            $class = $this->formatter;
+            $value = new $class($this, $value);
         } else if ($this->type == 'enum') {
             if (!$this->typeParameters->hasFlag($value)) {
                 throw new PlDBBadValueException($value, $this, 'invalid value for enum ' . $this->typeParameters->flags());
@@ -126,32 +153,131 @@ class PlDBTableField
             }
             /* TODO: Check bounds */
             return $value;
-        } else if ($this->type == 'varchar') {
+        } else if (ends_with($this->type, 'char')) {
             if (strlen($value) > $this->typeLength) {
                 throw new PlDBBadValueException($value, $this, 'value is expected to be at most ' . $this->typeLength . ' characters long, ' . strlen($value) . ' given');
             }
             return $value;
-        } else if ($this->type == 'char') {
-            if (strlen($value) != $this->typeLength) {
-                throw new PlDBBadValueException($value, $this, 'value is expected to be ' . $this->typeLength . ' characters long, ' . strlen($value) . ' given');
-            }
-            return $value;
+        } else if (starts_with($this->type, 'date') || $this->type == 'timestamp') {
+            return new DateFieldFormatter($this, $value);
         }
-        /* TODO: Support data and times */
         return $value;
     }
 }
 
+interface PlDBTableFieldValidator
+{
+    public function __construct(PlDBTableField $field, $value);
+}
+
+interface PlDBTableFieldFormatter extends PlDBTableFieldValidator, XDBFormat, PlExportable
+{
+}
+
+class DateFieldFormatter implements PlDBTableFieldFormatter
+{
+    private $datetime;
+    private $storageFormat;
+
+    public function __construct(PlDBTableField $field, $date)
+    {
+        $this->datetime = make_datetime($date);
+        if (is_null($this->datetime)) {
+            throw new PlDBBadValueException($date, $field, 'value is expected to be a date/time, ' . $date . ' given');
+        }
+        if ($field->type == 'date') {
+            $this->storageFormat = 'Y-m-d';
+        } else if ($field->type == 'datetime') {
+            $this->storageFormat = 'Y-m-d H:i:s';
+        } else {
+            $this->storageFormat = 'U';
+        }
+    }
+
+    public function format()
+    {
+        return XDB::escape($this->export());
+    }
+
+    public function date($format)
+    {
+        return $this->datetime->format($format);
+    }
+
+    public function export()
+    {
+        return $this->datetime->format($this->storageFormat);
+    }
+}
+
+class JSonFieldFormatter implements PlDBTableFieldFormatter, ArrayAccess
+{
+    private $data;
+
+    public function __construct(PlDBTableField $field, $data)
+    {
+        if (strpos($field->type, 'text') === false) {
+            throw new PlDBBadValueException($data, $field, 'json formatting requires a text field');
+        }
+
+        if (is_string($data)) {
+            $this->data = json_decode($data, true);
+        } else if (is_object($data)) {
+            $this->data = json_decode(json_encode($data), true);
+        } else if (is_array($data)) {
+            $this->data = $data;
+        }
+
+        if (is_null($this->data)) {
+            throw new PlDBBadValueException($data, $field, 'cannot interpret data as json: ' . $data);
+        }
+    }
+
+    public function format()
+    {
+        return XDB::escape(json_encode($this->data));
+    }
+
+    public function export()
+    {
+        return $this->data;
+    }
+
+    public function offsetExists($offset)
+    {
+        return isset($this->data[$offset]);
+    }
+
+    public function offsetGet($offset)
+    {
+        return $this->data[$offset];
+    }
+
+    public function offsetSet($offset, $value)
+    {
+        $this->data[$offset] = $value;
+    }
+
+    public function offsetUnset($offset)
+    {
+        unset($this->data[$offset]);
+    }
+}
+
 
 /** This class aims at providing a simple interface to interact with a single
  * table of a database. It is implemented as a wrapper around XDB.
  */
 class PlDBTable
 {
+    const PRIMARY_KEY = 'PRIMARY';
+
     public $table;
 
     private $schema;
-    private $keyFields;
+    private $primaryKey;
+    private $uniqueKeys;
+    private $multipleKeys;
     private $mutableFields;
 
     public function __construct($table)
@@ -160,20 +286,38 @@ class PlDBTable
         $this->schema();
     }
 
-    private function parseSchema(PlIterator $schema)
+    private function parseSchema(PlIterator $schema, PlIterator $keys)
     {
         $this->schema = array();
-        $this->keyFields = array();
+        $this->primaryKey = array();
+        $this->uniqueKeys = array();
+        $this->multipleKeys = array();
         $this->mutableFields = array();
         while ($column = $schema->next()) {
             $field = new PlDBTableField($column);
             $this->schema[$field->name] = $field;
-            if ($field->inPrimaryKey) {
-                $this->keyFields[] = $field->name;
-            } else {
+            if (!$field->inPrimaryKey) {
                 $this->mutableFields[] = $field->name;
             }
         }
+        while ($column = $keys->next()) {
+            $name     = $column['Key_name'];
+            $multiple = intval($column['Non_unique']) != 0;
+            $field    = $column['Column_name'];
+            if ($multiple) {
+                if (!isset($this->multipleKeys[$name])) {
+                    $this->multipleKeys[$name] = array();
+                }
+                $this->multipleKeys[$name][] = $field;
+            } else if ($name == self::PRIMARY_KEY) {
+                $this->primaryKey[] = $field;
+            } else {
+                if (!isset($this->uniqueKeys[$name])) {
+                    $this->uniqueKeys[$name] = array();
+                }
+                $this->uniqueKeys[$name][] = $field;
+            }
+        }
     }
 
 
@@ -181,7 +325,8 @@ class PlDBTable
     {
         if (!$this->schema) {
             $schema = XDB::iterator('DESCRIBE ' . $this->table);
-            $this->parseSchema($schema);
+            $keys   = XDB::iterator('SHOW INDEX FROM ' . $this->table);
+            $this->parseSchema($schema, $keys);
         }
         return $this->schema;
     }
@@ -200,15 +345,68 @@ class PlDBTable
         return $this->field($field)->format($value);
     }
 
+    public function registerFieldFormatter($field, $class)
+    {
+        return $this->field($field)->registerFormatter($class);
+    }
+
+    public function registerFieldValidator($field, $class)
+    {
+        return $this->field($field)->registerValidator($class);
+    }
+
+
     public function defaultValue($field)
     {
         return $this->field($field)->defaultValue;
     }
 
-    public function primaryKey(PlDBTableEntry $entry)
+    private function hasKeyField(PlDBTableEntry $entry, array $fields)
+    {
+        foreach ($fields as $field) {
+            if (isset($entry->$field)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private function keyFields($keyName)
+    {
+        if ($keyName == self::PRIMARY_KEY) {
+            return $this->primaryKey;
+        } else if (isset($this->uniqueKeys[$keyName])) {
+            return $this->uniqueKeys[$keyName];
+        } else if (isset($this->multipleKeys[$keyName])) {
+            return $this->multipleKeys[$keyName];
+        }
+        throw new PlDBNoSuchKeyException($keyName, $this);
+    }
+
+    private function bestKeyFields(PlDBTableEntry $entry, $allowMultiple)
+    {
+        if ($this->hasKeyField($entry, $this->primaryKey)) {
+            return $this->primaryKey;
+        }
+        foreach ($this->uniqueKeys as $fields) {
+            if ($this->hasKeyField($entry, $fields)) {
+                return $fields;
+            }
+        }
+        if ($allowMultiple) {
+            foreach ($this->multipleKeys as $fields) {
+                if ($this->hasKeyField($entry, $fields)) {
+                    return $fields;
+                }
+            }
+        }
+        return $this->primaryKey;
+    }
+
+    public function key(PlDBTableEntry $entry, array $keyFields)
     {
         $key = array();
-        foreach ($this->keyFields as $field) {
+        foreach ($keyFields as $field) {
             if (!isset($entry->$field)) {
                 throw new PlDBIncompleteEntryDescription($field, $this);
             } else {
@@ -218,10 +416,15 @@ class PlDBTable
         return implode('-', $key);
     }
 
-    private function buildKeyCondition(PlDBTableEntry $entry, $allowIncomplete)
+    public function primaryKey(PlDBTableEntry $entry)
+    {
+        return $this->key($this->keyFields(self::PRIMARY_KEY));
+    }
+
+    private function buildKeyCondition(PlDBTableEntry $entry, array $keyFields, $allowIncomplete)
     {
         $condition = array();
-        foreach ($this->keyFields as $field) {
+        foreach ($keyFields as $field) {
             if (!isset($entry->$field)) {
                 if (!$allowIncomplete) {
                     throw new PlDBIncompleteEntryDescription($field, $this);
@@ -237,44 +440,131 @@ class PlDBTable
     {
         $result = XDB::rawFetchOneAssoc('SELECT  *
                                            FROM  ' . $this->table . '
-                                          WHERE  ' . $this->buildKeyCondition($entry, false));
+                                          WHERE  ' . $this->buildKeyCondition($entry,
+                                                                              $this->bestKeyFields($entry, false),
+                                                                              false));
         if (!$result) {
             return false;
         }
         return $entry->fillFromDBData($result);
     }
 
-    public function iterateOnEntry(PlDBTableEntry $entry)
+    public function iterateOnCondition(PlDBTableEntry $entry, $condition, $sortField)
     {
+        if (empty($sortField)) {
+            $sortField = $this->primaryKey;
+        }
+        if (!is_array($sortField)) {
+            $sortField = array($sortField);
+        }
+        $sort = ' ORDER BY ' . implode(', ', $sortField);
         $it = XDB::rawIterator('SELECT  *
                                   FROM  ' . $this->table . '
-                                 WHERE  ' . $this->buildKeyCondition($entry, true));
+                                 WHERE  ' . $condition . '
+                                        ' . $sort);
         return PlIteratorUtils::map($it, array($entry, 'cloneAndFillFromDBData'));
     }
 
-    public function updateEntry(PlDBTableEntry $entry)
+    public function iterateOnEntry(PlDBTableEntry $entry, $sortField)
+    {
+        return $this->iterateOnCondition($entry,
+                                         $this->buildKeyCondition($entry,
+                                                                  $this->bestKeyFields($entry, true),
+                                                                  true),
+                                         $sortField);
+    }
+
+    const SAVE_INSERT_MISSING   = 0x01;
+    const SAVE_UPDATE_EXISTING  = 0x02;
+    const SAVE_IGNORE_DUPLICATE = 0x04;
+    public function saveEntry(PlDBTableEntry $entry, $flags)
     {
-        $values = array();
-        foreach ($this->mutableFields as $field) {
-            if ($entry->hasChanged($field)) {
-                $values[] = XDB::format($field . ' = {?}', $entry->$field);
+        $flags &= (self::SAVE_INSERT_MISSING | self::SAVE_UPDATE_EXISTING | self::SAVE_IGNORE_DUPLICATE);
+        Platal::assert($flags != 0, "Hey, the flags ($flags) here are so stupid, don't know what to do");
+        if ($flags == self::SAVE_UPDATE_EXISTING) {
+            $values = array();
+            foreach ($this->mutableFields as $field) {
+                if ($entry->hasChanged($field)) {
+                    $values[] = XDB::format($field . ' = {?}', $entry->$field);
+                }
+            }
+            if (count($values) > 0) {
+                XDB::rawExecute('UPDATE ' . $this->table . '
+                                    SET ' . implode(', ', $values) . '
+                                  WHERE ' . $this->buildKeyCondition($entry,
+                                                                     $this->keyFields(self::PRIMARY_KEY),
+                                                                     false));
+            }
+        } else {
+            $values = array();
+            foreach ($this->schema as $field=>$type) {
+                if ($entry->hasChanged($field)) {
+                    $values[$field] = XDB::escape($entry->$field);
+                }
+            }
+            if (count($values) > 0) {
+                $query = $this->table . ' (' . implode(', ', array_keys($values)) . ')
+                               VALUES  (' . implode(', ', $values) . ')';
+                if (($flags & self::SAVE_UPDATE_EXISTING)) {
+                    $update = array();
+                    foreach ($this->mutableFields as $field) {
+                        if (isset($values[$field])) {
+                            $update[] = "$field = VALUES($field)";
+                        }
+                    }
+                    if (count($update) > 0) {
+                        $query = 'INSERT ' . $query;
+                        $query .= "\n  ON DUPLICATE KEY UPDATE " . implode(', ', $update);
+                    } else {
+                        $query = 'INSERT IGNORE ' . $query;
+                    }
+                } else if (($flags & self::SAVE_IGNORE_DUPLICATE)) {
+                    $query = 'INSERT IGNORE ' . $query;
+                } else {
+                    $query = 'INSERT ' . $query;
+                }
+                XDB::rawExecute($query);
+                $id = XDB::insertId();
+                if ($id) {
+                    foreach ($this->primaryKey as $field) {
+                        if ($this->schema[$field]->autoIncrement) {
+                            $entry->$field = $id;
+                            break;
+                        }
+                    }
+                }
             }
         }
-        if (count($values) > 0) {
-            XDB::rawExecute('UPDATE ' . $this->table . '
-                                SET ' . implode(', ', $values) . '
-                              WHERE ' . $this->buildKeyCondition($entry, false));
+    }
+
+    public function deleteEntry(PlDBTableEntry $entry, $allowIncomplete)
+    {
+        XDB::rawExecute('DELETE FROM ' . $this->table . '
+                               WHERE ' . $this->buildKeyCondition($entry,
+                                                                  $this->bestKeyFields($entry, $allowIncomplete),
+                                                                  $allowIncomplete));
+    }
+
+    public function exportEntry(PlDBTableEntry $entry)
+    {
+        $export = array();
+        foreach ($this->schema as $key=>$field) {
+            $value = $entry->$key;
+            if ($value instanceof PlExportable) {
+                $value = $value->export();
+            }
+            $export[$key] = $value;
         }
+        return $export;
     }
 
     public static function get($name)
     {
-        var_dump('blah');
         return new PlDBTable($name);
     }
 }
 
-class PlDBTableEntry extends PlAbstractIterable
+class PlDBTableEntry extends PlAbstractIterable implements PlExportable
 {
     private $table;
     private $changed;
@@ -294,6 +584,28 @@ class PlDBTableEntry extends PlAbstractIterable
         $this->changed = new PlFlagSet();
     }
 
+    /** Register a custom formatter for a field.
+     *
+     * A formatter can be used to perform on-the-fly conversion from db storage to a user-friendly format.
+     * For example, if you have a textual field that contain json, you can use a JSonFieldFormatter on this
+     * field to perform automatic decoding when reading from the database (or when assigning the field)
+     * and automatic json_encoding when storing the object back to the db.
+     */
+    protected function registerFieldFormatter($field, $formatterClass)
+    {
+        $this->table->registerFieldFormatter($field, $formatterClass);
+    }
+
+    /** Register a custom validator for a field.
+     *
+     * A validator perform a pre-filter on the value of a field. As opposed to the formatters, it does
+     * not affects how the value is stored in the database.
+     */
+    protected function registerFieldValidator($field, $validatorClass)
+    {
+        $this->table->registerFieldValidator($field, $validatorClass);
+    }
+
     /** This hook is called when the entry is going to be updated in the db.
      *
      * A typical usecase is a class that stores low-level representation of
@@ -307,6 +619,26 @@ class PlDBTableEntry extends PlAbstractIterable
         return true;
     }
 
+    /** This hook is called when the entry has been save in the database.
+     *
+     * It can be used to perform post-actions on save like storing extra data
+     * in database or sending a notification.
+     */
+    protected function postSave()
+    {
+    }
+
+    /** This hook is called when the entry is going to be deleted from the db.
+     *
+     * Default behavior is to call preSave().
+     *
+     * @return true in case of success.
+     */
+    protected function preDelete()
+    {
+        return $this->preSave();
+    }
+
     /** This hook is called when the entry has just been fetched from the db.
      *
      * This is the counterpart of @ref preSave and a typical use-case is the conversion
@@ -368,6 +700,15 @@ class PlDBTableEntry extends PlAbstractIterable
         return $this->postFetch();
     }
 
+    public function copy(PlDBTableEntry $other)
+    {
+        Platal::assert($this->table == $other->table,
+                       "Trying to fill an entry of table {$this->table->table} with content of {$other->table->table}.");
+        $this->changed = $other->changed;
+        $this->fetched = $other->fetched;
+        $this->data    = $other->data;
+    }
+
     public function cloneAndFillFromDBData(array $data)
     {
         $clone = clone $this;
@@ -380,20 +721,57 @@ class PlDBTableEntry extends PlAbstractIterable
         return $this->table->fetchEntry($this);
     }
 
-    public function iterate()
+    public function iterate($sortField = null)
+    {
+        return $this->table->iterateOnEntry($this, $sortField);
+    }
+
+    public function iterateOnCondition($condition, $sortField = null)
     {
-        return $this->table->iterateOnEntry($this);
+        return $this->table->iterateOnCondition($this, $condition, $sortField);
     }
 
-    public function save()
+    public function save($flags)
     {
         if (!$this->preSave()) {
             return false;
         }
-        $this->table->updateEntry($this);
+        $this->table->saveEntry($this, $flags);
         $this->changed->clear();
+        $this->postSave();
         return true;
     }
+
+    public function update($insertMissing = false)
+    {
+        $flags = PlDBTable::SAVE_UPDATE_EXISTING;
+        if ($insertMissing) {
+            $flags = PlDBTable::SAVE_INSERT_MISSING;
+        }
+        return $this->save($flags);
+    }
+
+    public function insert($allowUpdate = false)
+    {
+        $flags = PlDBTable::SAVE_INSERT_MISSING;
+        if ($allowUpdate) {
+            $flags |= PlDBTable::SAVE_UPDATE_EXISTING;
+        }
+        return $this->save($flags);
+    }
+
+    public function delete()
+    {
+        if (!$this->preDelete()) {
+            return 0;
+        }
+        return $this->table->deleteEntry($this, true);
+    }
+
+    public function export()
+    {
+        return $this->table->exportEntry($this);
+    }
 }
 
 // vim:set et sw=4 sts=4 sws=4 foldmethod=marker enc=utf-8: