* Create the Attribute model and test case.

Robert Sesek [2010-06-04 03:31]
* Create the Attribute model and test case.
* Add methods for setting attributes in the Bug model.
diff --git a/includes/model_attribute.php b/includes/model_attribute.php
new file mode 100644
index 0000000..aa3e83d
--- /dev/null
+++ b/includes/model_attribute.php
@@ -0,0 +1,204 @@
+<?php
+// Bugdar 2
+// Copyright (c) 2010 Blue Static
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program.  If not, see <http://www.gnu.org/licenses/>.
+
+require_once PHALANX_ROOT . '/data/model.php';
+require_once BUGDAR_ROOT . '/includes/model_user.php';
+
+class Attribute extends phalanx\data\Model
+{
+    // Model properties.
+    protected $table_prefix = TABLE_PREFIX;
+    protected $table = 'attributes';
+    protected $primary_key = 'title';
+    protected $condition = 'title = :title';
+
+    // Struct properties.
+    protected $fields = array(
+        'title',
+        'description',
+        'type',  // See constants below.
+        'validator_pattern',  // Stores list options and string regex.
+        'required',
+        'default_value',  // String. Or TRUE for TYPE_DATE to mean today.
+        'can_search',
+        'color_foreground',
+        'color_background'
+    );
+
+    // Types of attributes {{
+      const TYPE_TEXT = 'text';
+      const TYPE_BOOL = 'boolean';
+      const TYPE_LIST = 'list';
+      const TYPE_DATE = 'date';
+      const TYPE_USER = 'user';
+    // }}
+
+    // Usergroup access controls {{
+      const ACCESS_NONE  = 0;
+      const ACCESS_READ  = 1;
+      const ACCESS_WRITE = 2;
+    // }}
+
+    // Returns the access level that |user| has for this attribute for |bug|.
+    public function CheckAccess(User $user, Bug $bug)
+    {
+        return self::ACCESS_READ | self::ACCESS_WRITE;
+    }
+
+    // Validates the value of an attribute. Returns a 2-Tuple<bool,mixed>. The
+    // first item is whether or not the value validated. The second item is the
+    // validated value, if any transformation took place.
+    public function Validate($value)
+    {
+        switch ($this->type) {
+            case self::TYPE_TEXT: return $this->_ValidateText($value);
+            case self::TYPE_BOOL: return $this->_ValidateBoolean($value);
+            case self::TYPE_LIST: return $this->_ValidateList($value);
+            case self::TYPE_DATE: return $this->_ValidateDate($value);
+            case self::TYPE_USER: return $this->_ValidateUser($value);
+            default: throw new AttributeException('Unknown attribute type "' . $this->type . '"');
+        }
+    }
+
+    protected function _ValidateText($value)
+    {
+        $value = trim($value);
+
+        // Handle empty strings, including the default value.
+        if ($this->required && empty($value) && !$this->default_value) {
+            return array(FALSE, $value);
+        } else if ($this->default_value) {
+            return array(TRUE, $this->default_value);
+        }
+
+        // Validate using pattern.
+        if ($this->validator_pattern) {
+            $valid = preg_match("/{$this->validator_pattern}/", $value);
+            return array($valid !== FALSE && $valid > 0, $value);
+        }
+
+        // All other values are valid.
+        return array(TRUE, $value);
+    }
+
+    protected function _ValidateBoolean($value)
+    {
+        // Booleans are technically tri-state: true, false, and unset. The only
+        // time the default value can be used is in the unset state.
+        if ($value === NULL && $this->default_value !== NULL) {
+            return array(TRUE, $this->default_value);
+        }
+
+        // Parse booleans in a bunch of different ways.
+        $value = trim(strtolower($value));
+        if (intval($value[0]) == 1 || $value[0] === TRUE ||
+            $value[0] == 'y' || $value[0] == 't') {
+            return array(TRUE, TRUE);
+        }
+        // Everything else will assume false. Don't bother failing validation.
+        return array(TRUE, FALSE);
+    }
+
+    protected function _ValidateList($value)
+    {
+        // Handle empty values, including the default value.
+        if ($this->required && empty($value) && !$this->default_value) {
+            return array(FALSE, $value);
+        } else if ($this->default_value) {
+            return array(TRUE, $this->default_value);
+        }
+
+        // Otherwise, iterate over the possible values.
+        $options = $this->GetListOptions();
+        $value   = trim($value);
+        foreach ($options as $option) {
+            if (strcasecmp($option, $value) == 0) {
+                // Return the proper case from the canonical option value.
+                return array(TRUE, $option);
+            }
+        }
+        return array(FALSE, $value);
+    }
+
+    protected function _ValidateDate($value)
+    {
+        // Handle the one default value (now).
+        if ($this->required && empty($value) && !$this->default_value) {
+            return array(FALSE, $value);
+        } else if ($this->default_value) {
+            return array(TRUE, time());
+        }
+
+        $time = strtotime($value);
+        if ($time === FALSE) {
+            return array(FALSE, $value);
+        } else {
+            return array(TRUE, $time);
+        }
+    }
+
+    protected function _ValidateUser($value)
+    {
+        // Handle the default value.
+        if ($this->required && empty($value) && !$this->default_value) {
+            return array(FALSE, $value);
+        } else if ($this->default_value) {
+            return array(TRUE, $this->default_value);
+        }
+
+        // Look the user up by alias to get the user ID.
+        $user = new User();
+        $user->alias = $value;
+        $user->set_condition('alias = :alias');
+        try {
+            $user->FetchInto();
+            return array(TRUE, $user->user_id);
+        } catch (\phalanx\data\ModelException $e) {
+            return array(FALSE, $value);
+        }
+    }
+
+    // If this Attribute is TYPE_LIST, this will return an array of options for
+    // the list. Note that bugs store values rather than indices, so comparison
+    // is case-insensitive string compare to determine if a value is a member
+    // of the set.
+    public function GetListOptions()
+    {
+        if ($this->type != self::TYPE_LIST) {
+            throw new AttributeException('"' . $this->title . '" is not a list');
+        }
+        return explode("\n", $this->validator_pattern);
+    }
+
+    // Sets the valid options for the list. This will replace all current
+    // options. Note that bugs will retain their current values if an option is
+    // removed, as they store the actual value, rather than a reference to the
+    // value.
+    public function SetListOptions(Array $options)
+    {
+        if ($this->type != self::TYPE_LIST) {
+            throw new AttributeException('"' . $this->title . '" is not a list');
+        }
+        $str_filter = '/[^a-z0-9_\-\.,]/i';
+        foreach ($options as $i => $option) {
+            $options[$i] = preg_replace($str_filter, '', $option);
+        }
+        $this->validator_pattern = implode("\n", $options);
+    }
+}
+
+class AttributeException extends Exception
+{}
diff --git a/includes/model_bug.php b/includes/model_bug.php
index 13ad8e4..e64e0a4 100644
--- a/includes/model_bug.php
+++ b/includes/model_bug.php
@@ -34,6 +34,9 @@ class Bug extends phalanx\data\Model
         'first_comment_id'
     );

+    // Cached attributes.
+    protected $attributes = array();
+
     // Fetches all the comments for a bug and returns them in a time descending
     // order, oldest to newest.
     public function FetchComments()
@@ -56,12 +59,14 @@ class Bug extends phalanx\data\Model
     // Returns an array of all the attributes the bug has.
     public function FetchAttributes()
     {
+        if ($this->attributes)
+            return $this->attributes;
+
         $stmt = Bugdar::$db->Prepare("SELECT * from " . TABLE_PREFIX . "bug_attributes WHERE bug_id = ?");
         $stmt->Execute(array($this->bug_id));
-        $attributes = array();
         while ($attr = $stmt->FetchObject())
-            $attributes[] = $attr;
-        return $attributes;
+            $this->attributes[] = $attr;
+        return $this->attributes;
     }

     // Returns the user who reported the bug.
@@ -71,4 +76,37 @@ class Bug extends phalanx\data\Model
         $user->FetchInto();
         return $user;
     }
+
+    // Sets an attribute. If |key| is NULL, this will act as a tag. Note that
+    // this does not perform validation or permission checks.
+    public function SetAttribute($key, $value)
+    {
+        $this->FetchAttributes();
+        $stmt = NULL;
+        foreach ($this->attributes as $i => $attr) {
+            if ($attr->attribute_title == $key) {
+                $stmt = Bugdar::$db->Prepare("
+                    UPDATE " . TABLE_PREFIX . "bug_attributes
+                    SET value = :value
+                    WHERE bug_id = :bug_id
+                    AND attribute_title = :attribute_title
+                ");
+                $this->attributes[$i]->value = $value;
+                break;
+            }
+        }
+        if (!$stmt) {
+            $stmt = Bugdar::$db->Prepare("
+                INSERT INTO " . TABLE_PREFIX . "bug_attributes
+                    (bug_id, attribute_title, value)
+                VALUES
+                    (:bug_id, :attribute_title, :value)
+            ");
+        }
+        $stmt->Execute(array(
+            'bug_id'          => $this->bug_id,
+            'attribute_title' => $key,
+            'value'           => $value
+        ));
+    }
 }
diff --git a/testing/phpunit.xml b/testing/phpunit.xml
index 986d8f2..d71bdc1 100644
--- a/testing/phpunit.xml
+++ b/testing/phpunit.xml
@@ -22,6 +22,7 @@

     <testsuites>
         <testsuite name="Events">
+            <directory suffix="_test.php">./tests/</directory>
             <directory suffix="_test.php">./tests/events/</directory>
         </testsuite>
     </testsuites>
diff --git a/testing/tests/model_attribute_test.php b/testing/tests/model_attribute_test.php
new file mode 100644
index 0000000..3af7a7b
--- /dev/null
+++ b/testing/tests/model_attribute_test.php
@@ -0,0 +1,262 @@
+<?php
+// Bugdar 2
+// Copyright (c) 2010 Blue Static
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program.  If not, see <http://www.gnu.org/licenses/>.
+
+require_once BUGDAR_ROOT . '/includes/model_attribute.php';
+
+class ModelAttributeTest extends BugdarTestCase
+{
+    public function testValidateTextEmtpyNoDefault()
+    {
+        $attr = new Attribute();
+        $attr->type = Attribute::TYPE_TEXT;
+        $attr->required = TRUE;
+
+        $v = $attr->Validate('   ');
+        $this->assertFalse($v[0]);
+
+        $v = $attr->Validate('');
+        $this->assertFalse($v[0]);
+    }
+
+    public function testValidateTextEmptyDefault()
+    {
+        $attr = new Attribute();
+        $attr->type = Attribute::TYPE_TEXT;
+        $attr->required = TRUE;
+        $attr->default_value = 'Cows';
+
+        $v = $attr->Validate('  ');
+        $this->assertTrue($v[0]);
+        $this->assertEquals('Cows', $v[1]);
+    }
+
+    public function testValidateNotRequiredTextEmptyDefault()
+    {
+        $attr = new Attribute();
+        $attr->type = Attribute::TYPE_TEXT;
+        $attr->required = FALSE;
+        $attr->default_value = 'Bears';
+
+        $v = $attr->Validate('');
+        $this->assertTrue($v[0]);
+        $this->assertEquals('Bears', $v[1]);
+    }
+
+    public function testValidateTextRegex()
+    {
+        $attr = new Attribute();
+        $attr->type = Attribute::TYPE_TEXT;
+        $attr->validator_pattern = 'Mo{2,}';
+
+        $v = $attr->Validate('Mo');
+        $this->assertFalse($v[0]);
+        $this->assertEquals('Mo', $v[1]);
+
+        $v = $attr->Validate('Moooo');
+        $this->assertTrue($v[0]);
+
+        $v = $attr->Validate('Mooooooooooo');
+        $this->assertTrue($v[0]);
+    }
+
+    public function testValidateBooleanTrue()
+    {
+        $attr = new Attribute();
+        $attr->type = Attribute::TYPE_BOOL;
+
+        $v = $attr->Validate('tRUe');
+        $this->assertTrue($v[0]);
+        $this->assertSame(TRUE, $v[1]);
+
+        $v = $attr->Validate('yEs');
+        $this->assertTrue($v[0]);
+        $this->assertSame(TRUE, $v[1]);
+
+        $v = $attr->Validate('1');
+        $this->assertTrue($v[0]);
+        $this->assertSame(TRUE, $v[1]);
+    }
+
+    public function testValidateBooleanFalse()
+    {
+        $attr = new Attribute();
+        $attr->type = Attribute::TYPE_BOOL;
+
+        $v = $attr->Validate('FaLsE');
+        $this->assertTrue($v[0]);
+        $this->assertSame(FALSE, $v[1]);
+
+        $v = $attr->Validate('nO');
+        $this->assertTrue($v[0]);
+        $this->assertSame(FALSE, $v[1]);
+
+        $v = $attr->Validate('0');
+        $this->assertTrue($v[0]);
+        $this->assertSame(FALSE, $v[1]);
+    }
+
+    public function testValidateBooleanDefault()
+    {
+        $attr = new Attribute();
+        $attr->type = Attribute::TYPE_BOOL;
+        $attr->default_value = TRUE;
+
+        $v = $attr->Validate(NULL);
+        $this->assertTrue($v[0]);
+        $this->assertSame(TRUE, $v[1]);
+
+        $v = $attr->Validate('false');
+        $this->assertTrue($v[0]);
+        $this->assertSame(FALSE, $v[1]);
+    }
+
+    public function testValidateList()
+    {
+        $attr = new Attribute();
+        $attr->type = Attribute::TYPE_LIST;
+        $attr->SetListOptions(array(
+            'Red',
+            'Green',
+            'Blue'
+        ));
+
+        $v = $attr->Validate('Red');
+        $this->assertTrue($v[0]);
+        $this->assertEquals('Red', $v[1]);
+
+        $v = $attr->Validate(' Green ');
+        $this->assertTrue($v[0]);
+        $this->assertEquals('Green', $v[1]);
+
+        $v = $attr->Validate('bluE');
+        $this->assertTrue($v[0]);
+        $this->assertEquals('Blue', $v[1]);
+
+        $v = $attr->Validate('Orange');
+        $this->assertFalse($v[0]);
+        $this->assertEquals('Orange', $v[1]);
+    }
+
+    public function testValidateListRequiredEmptyNoDefault()
+    {
+        $attr = new Attribute();
+        $attr->type = Attribute::TYPE_LIST;
+        $attr->required = TRUE;
+        $attr->SetListOptions(array('R', 'G', 'B'));
+
+        $v = $attr->Validate('');
+        $this->assertFalse($v[0]);
+    }
+
+    public function testValidateListRequiredEmptyDefault()
+    {
+        $attr = new Attribute();
+        $attr->type = Attribute::TYPE_LIST;
+        $attr->required = TRUE;
+        $attr->default_value = 'Undefined';
+        $attr->SetListOptions(array('A', 'b', 'C'));
+
+        $v = $attr->Validate(NULL);
+        $this->assertTrue($v[0]);
+        $this->assertEquals('Undefined', $v[1]);
+    }
+
+    public function testValidateListNotRequiredEmptyDefault()
+    {
+        $attr = new Attribute();
+        $attr->type = Attribute::TYPE_LIST;
+        $attr->required = FALSE;
+        $attr->default_value = 'Undefined';
+        $attr->SetListOptions(array('A', 'b', 'C'));
+
+        $v = $attr->Validate(NULL);
+        $this->assertTrue($v[0]);
+        $this->assertEquals('Undefined', $v[1]);
+    }
+
+    public function testValidateDate()
+    {
+        $attr = new Attribute();
+        $attr->type = Attribute::TYPE_DATE;
+
+        $v = $attr->Validate('January 1 2000');
+        $this->assertTrue($v[0]);
+        $this->assertGreaterThanOrEqual(gmmktime(0, 0, 0, 1, 1, 2000), $v[1]);
+
+        $s = 'gobbledygoook';
+        $v = $attr->Validate($s);
+        $this->assertFalse($v[0]);
+        $this->assertEquals($s, $v[1]);
+    }
+
+    public function testValidateDateDefault()
+    {
+        $attr = new Attribute();
+        $attr->default_value = TRUE;
+        $attr->type = Attribute::TYPE_DATE;
+
+        $now = time();
+
+        $v = $attr->Validate(NULL);
+        $this->assertTrue($v[0]);
+        $this->assertGreaterThanOrEqual($time, $v[1]);
+
+        $attr->required = TRUE;
+        $v = $attr->Validate('');
+        $this->assertTrue($v[0]);
+        $this->assertGreaterThanOrEqual($time, $v[1]);
+    }
+
+    public function testValidateUserRequiredEmptyDefault()
+    {
+        $attr = new Attribute();
+        $attr->type = Attribute::TYPE_USER;
+        $attr->default_value = 42;
+        $attr->required = TRUE;
+
+        $v = $attr->Validate(NULL);
+        $this->assertTrue($v[0]);
+        $this->assertEquals(42, $v[1]);
+    }
+
+    public function testValidateUserEmptyDefault()
+    {
+        $attr = new Attribute();
+        $attr->type = Attribute::TYPE_USER;
+        $attr->default_value = 42;
+        $attr->required = FALSE;
+
+        $v = $attr->Validate(NULL);
+        $this->assertTrue($v[0]);
+        $this->assertEquals(42, $v[1]);
+    }
+
+    public function testValidateUser()
+    {
+        $user = new User();
+        $user->alias = 'fluffy@bluestatic.org';
+        $user->email = 'fluffy@bluestatic.org';
+        $user->password = 'abc123';
+        $user->Insert();
+
+        $attr = new Attribute();
+        $attr->type = Attribute::TYPE_USER;
+
+        $v = $attr->Validate('fluffy@bluestatic.org');
+        $this->assertTrue($v[0]);
+        $this->assertEquals($user->user_id, $v[1]);
+    }
+}