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]);
+ }
+}