Integrate ApplicationSearch with CustomField
Summary:
Ref T2625. Ref T3794. Ref T418. Ref T1703.
This is a more general version of D5278. It expands CustomField support to include real integration with ApplicationSearch.
Broadly, custom fields may elect to:
  - build indicies when objects are updated;
  - populate ApplicationSearch forms with new controls;
  - read inputs entered into those controls out of the request; and
  - apply constraints to search queries.
Some utility/helper stuff is provided to make this easier. This part could be cleaner, but seems reasonable for a first cut. In particular, the Query and SearchEngine must manually call all the hooks right now instead of everything happening magically. I think that's fine for the moment; they're pretty easy to get right.
Test Plan:
I added a new searchable "Company" field to People:
{F58229}
This also cleaned up the disable/reorder view a little bit:
{F58230}
As it did before, this field appears on the edit screen:
{F58231}
However, because it has `search`, it also appears on the search screen:
{F58232}
When queried, it returns the expected results:
{F58233}
And the actually good bit of all this is that the query can take advantage of indexes:
  mysql> explain SELECT * FROM `user` user JOIN `user_customfieldstringindex` `appsearch_0` ON `appsearch_0`.objectPHID = user.phid AND `appsearch_0`.indexKey = 'mk3Ndy476ge6' AND `appsearch_0`.indexValue IN ('phacility') ORDER BY user.id DESC LIMIT 101;
  +----+-------------+-------------+--------+-------------------+----------+---------+------------------------------------------+------+----------------------------------------------+
  | id | select_type | table       | type   | possible_keys     | key      | key_len | ref                                      | rows | Extra                                        |
  +----+-------------+-------------+--------+-------------------+----------+---------+------------------------------------------+------+----------------------------------------------+
  |  1 | SIMPLE      | appsearch_0 | ref    | key_join,key_find | key_find | 232     | const,const                              |    1 | Using where; Using temporary; Using filesort |
  |  1 | SIMPLE      | user        | eq_ref | phid              | phid     | 194     | phabricator2_user.appsearch_0.objectPHID |    1 |                                              |
  +----+-------------+-------------+--------+-------------------+----------+---------+------------------------------------------+------+----------------------------------------------+
  2 rows in set (0.00 sec)
Reviewers: btrahan
Reviewed By: btrahan
CC: aran
Maniphest Tasks: T418, T1703, T2625, T3794
Differential Revision: https://secure.phabricator.com/D6992
			
			
This commit is contained in:
		
							
								
								
									
										21
									
								
								resources/sql/patches/20130914.usercustom.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								resources/sql/patches/20130914.usercustom.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | |||||||
|  | CREATE TABLE {$NAMESPACE}_user.user_customfieldstringindex ( | ||||||
|  |   id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, | ||||||
|  |   objectPHID VARCHAR(64) NOT NULL COLLATE utf8_bin, | ||||||
|  |   indexKey VARCHAR(12) NOT NULL COLLATE utf8_bin, | ||||||
|  |   indexValue LONGTEXT NOT NULL COLLATE utf8_general_ci, | ||||||
|  |  | ||||||
|  |   KEY `key_join` (objectPHID, indexKey, indexValue(64)), | ||||||
|  |   KEY `key_find` (indexKey, indexValue(64)) | ||||||
|  |  | ||||||
|  | ) ENGINE=InnoDB, COLLATE utf8_general_ci; | ||||||
|  |  | ||||||
|  | CREATE TABLE {$NAMESPACE}_user.user_customfieldnumericindex ( | ||||||
|  |   id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, | ||||||
|  |   objectPHID VARCHAR(64) NOT NULL COLLATE utf8_bin, | ||||||
|  |   indexKey VARCHAR(12) NOT NULL COLLATE utf8_bin, | ||||||
|  |   indexValue BIGINT NOT NULL, | ||||||
|  |  | ||||||
|  |   KEY `key_join` (objectPHID, indexKey, indexValue), | ||||||
|  |   KEY `key_find` (indexKey, indexValue) | ||||||
|  |  | ||||||
|  | ) ENGINE=InnoDB, COLLATE utf8_general_ci; | ||||||
| @@ -1710,6 +1710,8 @@ phutil_register_library_map(array( | |||||||
|     'PhabricatorUserConfiguredCustomField' => 'applications/people/customfield/PhabricatorUserConfiguredCustomField.php', |     'PhabricatorUserConfiguredCustomField' => 'applications/people/customfield/PhabricatorUserConfiguredCustomField.php', | ||||||
|     'PhabricatorUserConfiguredCustomFieldStorage' => 'applications/people/storage/PhabricatorUserConfiguredCustomFieldStorage.php', |     'PhabricatorUserConfiguredCustomFieldStorage' => 'applications/people/storage/PhabricatorUserConfiguredCustomFieldStorage.php', | ||||||
|     'PhabricatorUserCustomField' => 'applications/people/customfield/PhabricatorUserCustomField.php', |     'PhabricatorUserCustomField' => 'applications/people/customfield/PhabricatorUserCustomField.php', | ||||||
|  |     'PhabricatorUserCustomFieldNumericIndex' => 'applications/people/storage/PhabricatorUserCustomFieldNumericIndex.php', | ||||||
|  |     'PhabricatorUserCustomFieldStringIndex' => 'applications/people/storage/PhabricatorUserCustomFieldStringIndex.php', | ||||||
|     'PhabricatorUserDAO' => 'applications/people/storage/PhabricatorUserDAO.php', |     'PhabricatorUserDAO' => 'applications/people/storage/PhabricatorUserDAO.php', | ||||||
|     'PhabricatorUserEditor' => 'applications/people/editor/PhabricatorUserEditor.php', |     'PhabricatorUserEditor' => 'applications/people/editor/PhabricatorUserEditor.php', | ||||||
|     'PhabricatorUserEmail' => 'applications/people/storage/PhabricatorUserEmail.php', |     'PhabricatorUserEmail' => 'applications/people/storage/PhabricatorUserEmail.php', | ||||||
| @@ -3857,6 +3859,8 @@ phutil_register_library_map(array( | |||||||
|     ), |     ), | ||||||
|     'PhabricatorUserConfiguredCustomFieldStorage' => 'PhabricatorCustomFieldStorage', |     'PhabricatorUserConfiguredCustomFieldStorage' => 'PhabricatorCustomFieldStorage', | ||||||
|     'PhabricatorUserCustomField' => 'PhabricatorCustomField', |     'PhabricatorUserCustomField' => 'PhabricatorCustomField', | ||||||
|  |     'PhabricatorUserCustomFieldNumericIndex' => 'PhabricatorCustomFieldNumericIndexStorage', | ||||||
|  |     'PhabricatorUserCustomFieldStringIndex' => 'PhabricatorCustomFieldStringIndexStorage', | ||||||
|     'PhabricatorUserDAO' => 'PhabricatorLiskDAO', |     'PhabricatorUserDAO' => 'PhabricatorLiskDAO', | ||||||
|     'PhabricatorUserEditor' => 'PhabricatorEditor', |     'PhabricatorUserEditor' => 'PhabricatorEditor', | ||||||
|     'PhabricatorUserEmail' => 'PhabricatorUserDAO', |     'PhabricatorUserEmail' => 'PhabricatorUserDAO', | ||||||
|   | |||||||
| @@ -18,4 +18,12 @@ final class PhabricatorUserConfiguredCustomField | |||||||
|     return new PhabricatorUserConfiguredCustomFieldStorage(); |     return new PhabricatorUserConfiguredCustomFieldStorage(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   protected function newStringIndexStorage() { | ||||||
|  |     return new PhabricatorUserCustomFieldStringIndex(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   protected function newNumericIndexStorage() { | ||||||
|  |     return new PhabricatorUserCustomFieldNumericIndex(); | ||||||
|  |   } | ||||||
|  |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -101,11 +101,12 @@ final class PhabricatorPeopleQuery | |||||||
|  |  | ||||||
|     $data = queryfx_all( |     $data = queryfx_all( | ||||||
|       $conn_r, |       $conn_r, | ||||||
|       'SELECT * FROM %T user %Q %Q %Q %Q', |       'SELECT * FROM %T user %Q %Q %Q %Q %Q', | ||||||
|       $table->getTableName(), |       $table->getTableName(), | ||||||
|       $this->buildJoinsClause($conn_r), |       $this->buildJoinsClause($conn_r), | ||||||
|       $this->buildWhereClause($conn_r), |       $this->buildWhereClause($conn_r), | ||||||
|       $this->buildOrderClause($conn_r), |       $this->buildOrderClause($conn_r), | ||||||
|  |       $this->buildApplicationSearchGroupClause($conn_r), | ||||||
|       $this->buildLimitClause($conn_r)); |       $this->buildLimitClause($conn_r)); | ||||||
|  |  | ||||||
|     if ($this->needPrimaryEmail) { |     if ($this->needPrimaryEmail) { | ||||||
| @@ -181,6 +182,8 @@ final class PhabricatorPeopleQuery | |||||||
|         $email_table->getTableName()); |         $email_table->getTableName()); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     $joins[] = $this->buildApplicationSearchJoinClause($conn_r); | ||||||
|  |  | ||||||
|     $joins = implode(' ', $joins); |     $joins = implode(' ', $joins); | ||||||
|     return  $joins; |     return  $joins; | ||||||
|   } |   } | ||||||
| @@ -270,4 +273,8 @@ final class PhabricatorPeopleQuery | |||||||
|     return 'user.id'; |     return 'user.id'; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   protected function getApplicationSearchObjectPHIDColumn() { | ||||||
|  |     return 'user.phid'; | ||||||
|  |   } | ||||||
|  |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -3,6 +3,10 @@ | |||||||
| final class PhabricatorPeopleSearchEngine | final class PhabricatorPeopleSearchEngine | ||||||
|   extends PhabricatorApplicationSearchEngine { |   extends PhabricatorApplicationSearchEngine { | ||||||
|  |  | ||||||
|  |   public function getCustomFieldObject() { | ||||||
|  |     return new PhabricatorUser(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   public function buildSavedQueryFromRequest(AphrontRequest $request) { |   public function buildSavedQueryFromRequest(AphrontRequest $request) { | ||||||
|     $saved = new PhabricatorSavedQuery(); |     $saved = new PhabricatorSavedQuery(); | ||||||
|  |  | ||||||
| @@ -14,6 +18,8 @@ final class PhabricatorPeopleSearchEngine | |||||||
|     $saved->setParameter('createdStart', $request->getStr('createdStart')); |     $saved->setParameter('createdStart', $request->getStr('createdStart')); | ||||||
|     $saved->setParameter('createdEnd', $request->getStr('createdEnd')); |     $saved->setParameter('createdEnd', $request->getStr('createdEnd')); | ||||||
|  |  | ||||||
|  |     $this->readCustomFieldsFromRequest($request, $saved); | ||||||
|  |  | ||||||
|     return $saved; |     return $saved; | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -57,6 +63,9 @@ final class PhabricatorPeopleSearchEngine | |||||||
|     if ($end) { |     if ($end) { | ||||||
|       $query->withDateCreatedBefore($end); |       $query->withDateCreatedBefore($end); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     $this->applyCustomFieldsToQuery($query, $saved); | ||||||
|  |  | ||||||
|     return $query; |     return $query; | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -101,6 +110,8 @@ final class PhabricatorPeopleSearchEngine | |||||||
|             pht('Show only System Agents.'), |             pht('Show only System Agents.'), | ||||||
|             $is_system_agent)); |             $is_system_agent)); | ||||||
|  |  | ||||||
|  |     $this->appendCustomFieldsToForm($form, $saved); | ||||||
|  |  | ||||||
|     $this->buildDateRange( |     $this->buildDateRange( | ||||||
|       $form, |       $form, | ||||||
|       $saved, |       $saved, | ||||||
|   | |||||||
| @@ -0,0 +1,11 @@ | |||||||
|  | <?php | ||||||
|  |  | ||||||
|  | final class PhabricatorUserCustomFieldNumericIndex | ||||||
|  |   extends PhabricatorCustomFieldNumericIndexStorage { | ||||||
|  |  | ||||||
|  |   public function getApplicationName() { | ||||||
|  |     return 'user'; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  | } | ||||||
|  |  | ||||||
| @@ -0,0 +1,11 @@ | |||||||
|  | <?php | ||||||
|  |  | ||||||
|  | final class PhabricatorUserCustomFieldStringIndex | ||||||
|  |   extends PhabricatorCustomFieldStringIndexStorage { | ||||||
|  |  | ||||||
|  |   public function getApplicationName() { | ||||||
|  |     return 'user'; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  | } | ||||||
|  |  | ||||||
| @@ -15,6 +15,7 @@ abstract class PhabricatorApplicationSearchEngine { | |||||||
|  |  | ||||||
|   private $viewer; |   private $viewer; | ||||||
|   private $errors = array(); |   private $errors = array(); | ||||||
|  |   private $customFields = false; | ||||||
|  |  | ||||||
|   public function setViewer(PhabricatorUser $viewer) { |   public function setViewer(PhabricatorUser $viewer) { | ||||||
|     $this->viewer = $viewer; |     $this->viewer = $viewer; | ||||||
| @@ -370,4 +371,153 @@ abstract class PhabricatorApplicationSearchEngine { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | /* -(  Application Search  )------------------------------------------------- */ | ||||||
|  |  | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Retrieve an object to use to define custom fields for this search. | ||||||
|  |    * | ||||||
|  |    * To integrate with custom fields, subclasses should override this method | ||||||
|  |    * and return an instance of the application object which implements | ||||||
|  |    * @{interface:PhabricatorCustomFieldInterface}. | ||||||
|  |    * | ||||||
|  |    * @return PhabricatorCustomFieldInterface|null Object with custom fields. | ||||||
|  |    * @task appsearch | ||||||
|  |    */ | ||||||
|  |   public function getCustomFieldObject() { | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Get the custom fields for this search. | ||||||
|  |    * | ||||||
|  |    * @return PhabricatorCustomFieldList|null Custom fields, if this search | ||||||
|  |    *   supports custom fields. | ||||||
|  |    * @task appsearch | ||||||
|  |    */ | ||||||
|  |   public function getCustomFieldList() { | ||||||
|  |     if ($this->customFields === false) { | ||||||
|  |       $object = $this->getCustomFieldObject(); | ||||||
|  |       if ($object) { | ||||||
|  |         $fields = PhabricatorCustomField::getObjectFields( | ||||||
|  |           $object, | ||||||
|  |           PhabricatorCustomField::ROLE_APPLICATIONSEARCH); | ||||||
|  |       } else { | ||||||
|  |         $fields = null; | ||||||
|  |       } | ||||||
|  |       $this->customFields = $fields; | ||||||
|  |     } | ||||||
|  |     return $this->customFields; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Moves data from the request into a saved query. | ||||||
|  |    * | ||||||
|  |    * @param AphrontRequest Request to read. | ||||||
|  |    * @param PhabricatorSavedQuery Query to write to. | ||||||
|  |    * @return void | ||||||
|  |    * @task appsearch | ||||||
|  |    */ | ||||||
|  |   protected function readCustomFieldsFromRequest( | ||||||
|  |     AphrontRequest $request, | ||||||
|  |     PhabricatorSavedQuery $saved) { | ||||||
|  |  | ||||||
|  |     $list = $this->getCustomFieldList(); | ||||||
|  |     if (!$list) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     foreach ($list->getFields() as $field) { | ||||||
|  |       $key = $this->getKeyForCustomField($field); | ||||||
|  |       $value = $field->readApplicationSearchValueFromRequest( | ||||||
|  |         $this, | ||||||
|  |         $request); | ||||||
|  |       $saved->setParameter($key, $value); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Applies data from a saved query to an executable query. | ||||||
|  |    * | ||||||
|  |    * @param PhabricatorCursorPagedPolicyAwareQuery Query to constrain. | ||||||
|  |    * @param PhabricatorSavedQuery Saved query to read. | ||||||
|  |    * @return void | ||||||
|  |    */ | ||||||
|  |   protected function applyCustomFieldsToQuery( | ||||||
|  |     PhabricatorCursorPagedPolicyAwareQuery $query, | ||||||
|  |     PhabricatorSavedQuery $saved) { | ||||||
|  |  | ||||||
|  |     $list = $this->getCustomFieldList(); | ||||||
|  |     if (!$list) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     foreach ($list->getFields() as $field) { | ||||||
|  |       $key = $this->getKeyForCustomField($field); | ||||||
|  |       $value = $field->applyApplicationSearchConstraintToQuery( | ||||||
|  |         $this, | ||||||
|  |         $query, | ||||||
|  |         $saved->getParameter($key)); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Get a unique key identifying a field. | ||||||
|  |    * | ||||||
|  |    * @param PhabricatorCustomField Field to identify. | ||||||
|  |    * @return string Unique identifier, suitable for use as an input name. | ||||||
|  |    */ | ||||||
|  |   public function getKeyForCustomField(PhabricatorCustomField $field) { | ||||||
|  |     return 'custom:'.$field->getFieldIndex(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Add inputs to an application search form so the user can query on custom | ||||||
|  |    * fields. | ||||||
|  |    * | ||||||
|  |    * @param AphrontFormView Form to update. | ||||||
|  |    * @param PhabricatorSavedQuery Values to prefill. | ||||||
|  |    * @return void | ||||||
|  |    */ | ||||||
|  |   protected function appendCustomFieldsToForm( | ||||||
|  |     AphrontFormView $form, | ||||||
|  |     PhabricatorSavedQuery $saved) { | ||||||
|  |  | ||||||
|  |     $list = $this->getCustomFieldList(); | ||||||
|  |     if (!$list) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     $phids = array(); | ||||||
|  |     foreach ($list->getFields() as $field) { | ||||||
|  |       $key = $this->getKeyForCustomField($field); | ||||||
|  |       $value = $saved->getParameter($key); | ||||||
|  |       $phids[$key] = $field->getRequiredHandlePHIDsForApplicationSearch($value); | ||||||
|  |     } | ||||||
|  |     $all_phids = array_mergev($phids); | ||||||
|  |  | ||||||
|  |     $handles = array(); | ||||||
|  |     if ($all_phids) { | ||||||
|  |       $handles = id(new PhabricatorHandleQuery()) | ||||||
|  |         ->setViewer($this->getViewer()) | ||||||
|  |         ->withPHIDs($all_phids) | ||||||
|  |         ->execute(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     foreach ($list->getFields() as $field) { | ||||||
|  |       $key = $this->getKeyForCustomField($field); | ||||||
|  |       $value = $saved->getParameter($key); | ||||||
|  |       $field->appendToApplicationSearchForm( | ||||||
|  |         $this, | ||||||
|  |         $form, | ||||||
|  |         $value, | ||||||
|  |         array_select_keys($handles, $phids[$key])); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -479,6 +479,19 @@ abstract class PhabricatorApplicationTransactionEditor | |||||||
|  |  | ||||||
|     $this->didApplyTransactions($xactions); |     $this->didApplyTransactions($xactions); | ||||||
|  |  | ||||||
|  |     if ($object instanceof PhabricatorCustomFieldInterface) { | ||||||
|  |       // Maybe this makes more sense to move into the search index itself? For | ||||||
|  |       // now I'm putting it here since I think we might end up with things that | ||||||
|  |       // need it to be up to date once the next page loads, but if we don't go | ||||||
|  |       // there we we could move it into search once search moves to the daemons. | ||||||
|  |  | ||||||
|  |       $fields = PhabricatorCustomField::getObjectFields( | ||||||
|  |         $object, | ||||||
|  |         PhabricatorCustomField::ROLE_APPLICATIONSEARCH); | ||||||
|  |       $fields->readFieldsFromStorage($object); | ||||||
|  |       $fields->rebuildIndexes($object); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     return $xactions; |     return $xactions; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -569,9 +569,7 @@ abstract class PhabricatorCustomField { | |||||||
|    * @task appsearch |    * @task appsearch | ||||||
|    */ |    */ | ||||||
|   protected function newStringIndexStorage() { |   protected function newStringIndexStorage() { | ||||||
|     if ($this->proxy) { |     // NOTE: This intentionally isn't proxied, to avoid call cycles. | ||||||
|       return $this->proxy->newStringIndexStorage(); |  | ||||||
|     } |  | ||||||
|     throw new PhabricatorCustomFieldImplementationIncompleteException($this); |     throw new PhabricatorCustomFieldImplementationIncompleteException($this); | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -585,9 +583,7 @@ abstract class PhabricatorCustomField { | |||||||
|    * @task appsearch |    * @task appsearch | ||||||
|    */ |    */ | ||||||
|   protected function newNumericIndexStorage() { |   protected function newNumericIndexStorage() { | ||||||
|     if ($this->proxy) { |     // NOTE: This intentionally isn't proxied, to avoid call cycles. | ||||||
|       return $this->proxy->newStringIndexStorage(); |  | ||||||
|     } |  | ||||||
|     throw new PhabricatorCustomFieldImplementationIncompleteException($this); |     throw new PhabricatorCustomFieldImplementationIncompleteException($this); | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -604,7 +600,7 @@ abstract class PhabricatorCustomField { | |||||||
|       return $this->proxy->newStringIndex(); |       return $this->proxy->newStringIndex(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     $key = $this->getFieldIndexKey(); |     $key = $this->getFieldIndex(); | ||||||
|     return $this->newStringIndexStorage() |     return $this->newStringIndexStorage() | ||||||
|       ->setIndexKey($key) |       ->setIndexKey($key) | ||||||
|       ->setIndexValue($value); |       ->setIndexValue($value); | ||||||
| @@ -622,13 +618,103 @@ abstract class PhabricatorCustomField { | |||||||
|     if ($this->proxy) { |     if ($this->proxy) { | ||||||
|       return $this->proxy->newNumericIndex(); |       return $this->proxy->newNumericIndex(); | ||||||
|     } |     } | ||||||
|     $key = $this->getFieldIndexKey(); |     $key = $this->getFieldIndex(); | ||||||
|     return $this->newNumericIndexStorage() |     return $this->newNumericIndexStorage() | ||||||
|       ->setIndexKey($key) |       ->setIndexKey($key) | ||||||
|       ->setIndexValue($value); |       ->setIndexValue($value); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Read a query value from a request, for storage in a saved query. Normally, | ||||||
|  |    * this method should, e.g., read a string out of the request. | ||||||
|  |    * | ||||||
|  |    * @param PhabricatorApplicationSearchEngine Engine building the query. | ||||||
|  |    * @param AphrontRequest Request to read from. | ||||||
|  |    * @return wild | ||||||
|  |    * @task appsearch | ||||||
|  |    */ | ||||||
|  |   public function readApplicationSearchValueFromRequest( | ||||||
|  |     PhabricatorApplicationSearchEngine $engine, | ||||||
|  |     AphrontRequest $request) { | ||||||
|  |     if ($this->proxy) { | ||||||
|  |       return $this->proxy->readApplicationSearchValueFromRequest( | ||||||
|  |         $engine, | ||||||
|  |         $request); | ||||||
|  |     } | ||||||
|  |     throw new PhabricatorCustomFieldImplementationIncompleteException($this); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Constrain a query, given a field value. Generally, this method should | ||||||
|  |    * use `with...()` methods to apply filters or other constraints to the | ||||||
|  |    * query. | ||||||
|  |    * | ||||||
|  |    * @param PhabricatorApplicationSearchEngine Engine executing the query. | ||||||
|  |    * @param PhabricatorCursorPagedPolicyAwareQuery Query to constrain. | ||||||
|  |    * @param wild Constraint provided by the user. | ||||||
|  |    * @return void | ||||||
|  |    * @task appsearch | ||||||
|  |    */ | ||||||
|  |   public function applyApplicationSearchConstraintToQuery( | ||||||
|  |     PhabricatorApplicationSearchEngine $engine, | ||||||
|  |     PhabricatorCursorPagedPolicyAwareQuery $query, | ||||||
|  |     $value) { | ||||||
|  |     if ($this->proxy) { | ||||||
|  |       return $this->proxy->applyApplicationSearchConstraintToQuery( | ||||||
|  |         $engine, | ||||||
|  |         $query, | ||||||
|  |         $value); | ||||||
|  |     } | ||||||
|  |     throw new PhabricatorCustomFieldImplementationIncompleteException($this); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Append search controls to the interface. If you need handles, use | ||||||
|  |    * @{method:getRequiredHandlePHIDsForApplicationSearch} to get them. | ||||||
|  |    * | ||||||
|  |    * @param PhabricatorApplicationSearchEngine Engine constructing the form. | ||||||
|  |    * @param AphrontFormView The form to update. | ||||||
|  |    * @param wild Value from the saved query. | ||||||
|  |    * @param list<PhabricatorObjectHandle> List of handles. | ||||||
|  |    * @return void | ||||||
|  |    * @task appsearch | ||||||
|  |    */ | ||||||
|  |   public function appendToApplicationSearchForm( | ||||||
|  |     PhabricatorApplicationSearchEngine $engine, | ||||||
|  |     AphrontFormView $form, | ||||||
|  |     $value, | ||||||
|  |     array $handles) { | ||||||
|  |     if ($this->proxy) { | ||||||
|  |       return $this->proxy->appendToApplicationSearchForm( | ||||||
|  |         $engine, | ||||||
|  |         $form, | ||||||
|  |         $value, | ||||||
|  |         $handles); | ||||||
|  |     } | ||||||
|  |     throw new PhabricatorCustomFieldImplementationIncompleteException($this); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Return a list of PHIDs which @{method:appendToApplicationSearchForm} needs | ||||||
|  |    * handles for. This is primarily useful if the field stores PHIDs and you | ||||||
|  |    * need to (for example) render a tokenizer control. | ||||||
|  |    * | ||||||
|  |    * @param wild Value from the saved query. | ||||||
|  |    * @return list<phid> List of PHIDs. | ||||||
|  |    * @task appsearch | ||||||
|  |    */ | ||||||
|  |   public function getRequiredHandlePHIDsForApplicationSearch($value) { | ||||||
|  |     if ($this->proxy) { | ||||||
|  |       return $this->proxy->getRequiredHandlePHIDsForApplicationSearch($value); | ||||||
|  |     } | ||||||
|  |     return array(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |  | ||||||
| /* -(  ApplicationTransactions  )-------------------------------------------- */ | /* -(  ApplicationTransactions  )-------------------------------------------- */ | ||||||
|  |  | ||||||
|  |  | ||||||
|   | |||||||
| @@ -151,4 +151,72 @@ final class PhabricatorCustomFieldList extends Phobject { | |||||||
|     return $xactions; |     return $xactions; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Publish field indexes into index tables, so ApplicationSearch can search | ||||||
|  |    * them. | ||||||
|  |    * | ||||||
|  |    * @return void | ||||||
|  |    */ | ||||||
|  |   public function rebuildIndexes(PhabricatorCustomFieldInterface $object) { | ||||||
|  |     $indexes = array(); | ||||||
|  |     $index_keys = array(); | ||||||
|  |  | ||||||
|  |     $phid = $object->getPHID(); | ||||||
|  |  | ||||||
|  |     $role = PhabricatorCustomField::ROLE_APPLICATIONSEARCH; | ||||||
|  |     foreach ($this->fields as $field) { | ||||||
|  |       if (!$field->shouldEnableForRole($role)) { | ||||||
|  |         continue; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       $index_keys[$field->getFieldIndex()] = true; | ||||||
|  |  | ||||||
|  |       foreach ($field->buildFieldIndexes() as $index) { | ||||||
|  |         $index->setObjectPHID($phid); | ||||||
|  |         $indexes[$index->getTableName()][] = $index; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (!$indexes) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     $any_index = head(head($indexes)); | ||||||
|  |     $conn_w = $any_index->establishConnection('w'); | ||||||
|  |  | ||||||
|  |     foreach ($indexes as $table => $index_list) { | ||||||
|  |       $sql = array(); | ||||||
|  |       foreach ($index_list as $index) { | ||||||
|  |         $sql[] = $index->formatForInsert($conn_w); | ||||||
|  |       } | ||||||
|  |       $indexes[$table] = $sql; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     $any_index->openTransaction(); | ||||||
|  |  | ||||||
|  |       foreach ($indexes as $table => $sql_list) { | ||||||
|  |         queryfx( | ||||||
|  |           $conn_w, | ||||||
|  |           'DELETE FROM %T WHERE objectPHID = %s AND indexKey IN (%Ls)', | ||||||
|  |           $table, | ||||||
|  |           $phid, | ||||||
|  |           array_keys($index_keys)); | ||||||
|  |  | ||||||
|  |         if (!$sql_list) { | ||||||
|  |           continue; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         foreach (PhabricatorLiskDAO::chunkSQL($sql_list) as $chunk) { | ||||||
|  |           queryfx( | ||||||
|  |             $conn_w, | ||||||
|  |             'INSERT INTO %T (objectPHID, indexKey, indexValue) VALUES %Q', | ||||||
|  |             $table, | ||||||
|  |             $chunk); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |     $any_index->saveTransaction(); | ||||||
|  |   } | ||||||
|  |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -56,6 +56,10 @@ final class PhabricatorStandardCustomField | |||||||
|     return $this; |     return $this; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   public function getFieldType() { | ||||||
|  |     return $this->fieldType; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   public function getFieldValue() { |   public function getFieldValue() { | ||||||
|     return $this->fieldValue; |     return $this->fieldValue; | ||||||
|   } |   } | ||||||
| @@ -71,6 +75,8 @@ final class PhabricatorStandardCustomField | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   public function setFieldConfig(array $config) { |   public function setFieldConfig(array $config) { | ||||||
|  |     $this->setFieldType('text'); | ||||||
|  |  | ||||||
|     foreach ($config as $key => $value) { |     foreach ($config as $key => $value) { | ||||||
|       switch ($key) { |       switch ($key) { | ||||||
|         case 'name': |         case 'name': | ||||||
| @@ -79,6 +85,9 @@ final class PhabricatorStandardCustomField | |||||||
|         case 'type': |         case 'type': | ||||||
|           $this->setFieldType($value); |           $this->setFieldType($value); | ||||||
|           break; |           break; | ||||||
|  |         case 'description': | ||||||
|  |           $this->setFieldDescription($value); | ||||||
|  |           break; | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|     $this->fieldConfig = $config; |     $this->fieldConfig = $config; | ||||||
| @@ -90,6 +99,7 @@ final class PhabricatorStandardCustomField | |||||||
|   } |   } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
| /* -(  PhabricatorCustomField  )--------------------------------------------- */ | /* -(  PhabricatorCustomField  )--------------------------------------------- */ | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -130,7 +140,7 @@ final class PhabricatorStandardCustomField | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   public function renderEditControl() { |   public function renderEditControl() { | ||||||
|     $type = $this->getFieldConfigValue('type', 'text'); |     $type = $this->getFieldType(); | ||||||
|     switch ($type) { |     switch ($type) { | ||||||
|       case 'text': |       case 'text': | ||||||
|       default: |       default: | ||||||
| @@ -153,5 +163,75 @@ final class PhabricatorStandardCustomField | |||||||
|     return $this->getFieldValue(); |     return $this->getFieldValue(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   public function shouldAppearInApplicationSearch() { | ||||||
|  |     return $this->getFieldConfigValue('search', false); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   protected function newStringIndexStorage() { | ||||||
|  |     return $this->getApplicationField()->newStringIndexStorage(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   protected function newNumericIndexStorage() { | ||||||
|  |     return $this->getApplicationField()->newNumericIndexStorage(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   public function buildFieldIndexes() { | ||||||
|  |     $type = $this->getFieldType(); | ||||||
|  |     switch ($type) { | ||||||
|  |       case 'text': | ||||||
|  |       default: | ||||||
|  |         return array( | ||||||
|  |           $this->newStringIndex($this->getFieldValue()), | ||||||
|  |         ); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   public function readApplicationSearchValueFromRequest( | ||||||
|  |     PhabricatorApplicationSearchEngine $engine, | ||||||
|  |     AphrontRequest $request) { | ||||||
|  |     $type = $this->getFieldType(); | ||||||
|  |     switch ($type) { | ||||||
|  |       case 'text': | ||||||
|  |       default: | ||||||
|  |         return $request->getStr('std:'.$this->getFieldIndex()); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   public function applyApplicationSearchConstraintToQuery( | ||||||
|  |     PhabricatorApplicationSearchEngine $engine, | ||||||
|  |     PhabricatorCursorPagedPolicyAwareQuery $query, | ||||||
|  |     $value) { | ||||||
|  |     $type = $this->getFieldType(); | ||||||
|  |     switch ($type) { | ||||||
|  |       case 'text': | ||||||
|  |       default: | ||||||
|  |         if (strlen($value)) { | ||||||
|  |           $query->withApplicationSearchContainsConstraint( | ||||||
|  |             $this->newStringIndex(null), | ||||||
|  |             $value); | ||||||
|  |         } | ||||||
|  |         break; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   public function appendToApplicationSearchForm( | ||||||
|  |     PhabricatorApplicationSearchEngine $engine, | ||||||
|  |     AphrontFormView $form, | ||||||
|  |     $value, | ||||||
|  |     array $handles) { | ||||||
|  |  | ||||||
|  |     $type = $this->getFieldType(); | ||||||
|  |     switch ($type) { | ||||||
|  |       case 'text': | ||||||
|  |       default: | ||||||
|  |         $form->appendChild( | ||||||
|  |           id(new AphrontFormTextControl()) | ||||||
|  |             ->setLabel($this->getFieldName()) | ||||||
|  |             ->setName('std:'.$this->getFieldIndex()) | ||||||
|  |             ->setValue($value)); | ||||||
|  |         break; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |   } | ||||||
|  |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -14,5 +14,6 @@ abstract class PhabricatorCustomFieldIndexStorage | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   abstract public function formatForInsert(AphrontDatabaseConnection $conn); |   abstract public function formatForInsert(AphrontDatabaseConnection $conn); | ||||||
|  |   abstract public function getIndexValueType(); | ||||||
|  |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -12,4 +12,8 @@ abstract class PhabricatorCustomFieldNumericIndexStorage | |||||||
|       $this->getIndexValue()); |       $this->getIndexValue()); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   public function getIndexValueType() { | ||||||
|  |     return 'int'; | ||||||
|  |   } | ||||||
|  |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -12,4 +12,8 @@ abstract class PhabricatorCustomFieldStringIndexStorage | |||||||
|       $this->getIndexValue()); |       $this->getIndexValue()); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   public function getIndexValueType() { | ||||||
|  |     return 'string'; | ||||||
|  |   } | ||||||
|  |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -3,12 +3,15 @@ | |||||||
| /** | /** | ||||||
|  * A query class which uses cursor-based paging. This paging is much more |  * A query class which uses cursor-based paging. This paging is much more | ||||||
|  * performant than offset-based paging in the presence of policy filtering. |  * performant than offset-based paging in the presence of policy filtering. | ||||||
|  |  * | ||||||
|  |  * @task appsearch Integration with ApplicationSearch | ||||||
|  */ |  */ | ||||||
| abstract class PhabricatorCursorPagedPolicyAwareQuery | abstract class PhabricatorCursorPagedPolicyAwareQuery | ||||||
|   extends PhabricatorPolicyAwareQuery { |   extends PhabricatorPolicyAwareQuery { | ||||||
|  |  | ||||||
|   private $afterID; |   private $afterID; | ||||||
|   private $beforeID; |   private $beforeID; | ||||||
|  |   private $applicationSearchConstraints = array(); | ||||||
|  |  | ||||||
|   protected function getPagingColumn() { |   protected function getPagingColumn() { | ||||||
|     return 'id'; |     return 'id'; | ||||||
| @@ -228,4 +231,163 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery | |||||||
|     return '('.implode(') OR (', $clauses).')'; |     return '('.implode(') OR (', $clauses).')'; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | /* -(  Application Search  )------------------------------------------------- */ | ||||||
|  |  | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Constrain the query with an ApplicationSearch index. This adds a constraint | ||||||
|  |    * which requires objects to have one or more corresponding rows in the index | ||||||
|  |    * with one of the given values. Combined with appropriate indexes, it can | ||||||
|  |    * build the most common types of queries, like: | ||||||
|  |    * | ||||||
|  |    *   - Find users with shirt sizes "X" or "XL". | ||||||
|  |    *   - Find shoes with size "13". | ||||||
|  |    * | ||||||
|  |    * @param PhabricatorCustomFieldIndexStorage Table where the index is stored. | ||||||
|  |    * @param string|list<string> One or more values to filter by. | ||||||
|  |    * @task appsearch | ||||||
|  |    */ | ||||||
|  |   public function withApplicationSearchContainsConstraint( | ||||||
|  |     PhabricatorCustomFieldIndexStorage $index, | ||||||
|  |     $value) { | ||||||
|  |  | ||||||
|  |     $this->applicationSearchConstraints[] = array( | ||||||
|  |       'type'  => $index->getIndexValueType(), | ||||||
|  |       'cond'  => '=', | ||||||
|  |       'table' => $index->getTableName(), | ||||||
|  |       'index' => $index->getIndexKey(), | ||||||
|  |       'value' => $value, | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     return $this; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Get the name of the query's primary object PHID column, for constructing | ||||||
|  |    * JOIN clauses. Normally (and by default) this is just `"phid"`, but if the | ||||||
|  |    * query construction requires a table alias it may be something like | ||||||
|  |    * `"task.phid"`. | ||||||
|  |    * | ||||||
|  |    * @return string Column name. | ||||||
|  |    * @task appsearch | ||||||
|  |    */ | ||||||
|  |   protected function getApplicationSearchObjectPHIDColumn() { | ||||||
|  |     return 'phid'; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Determine if the JOINs built by ApplicationSearch might cause each primary | ||||||
|  |    * object to return multiple result rows. Generally, this means the query | ||||||
|  |    * needs an extra GROUP BY clause. | ||||||
|  |    * | ||||||
|  |    * @return bool True if the query may return multiple rows for each object. | ||||||
|  |    * @task appsearch | ||||||
|  |    */ | ||||||
|  |   protected function getApplicationSearchMayJoinMultipleRows() { | ||||||
|  |     foreach ($this->applicationSearchConstraints as $constraint) { | ||||||
|  |       $type = $constraint['type']; | ||||||
|  |       $value = $constraint['value']; | ||||||
|  |  | ||||||
|  |       switch ($type) { | ||||||
|  |         case 'string': | ||||||
|  |         case 'int': | ||||||
|  |           if (count((array)$value) > 1) { | ||||||
|  |             return true; | ||||||
|  |           } | ||||||
|  |           break; | ||||||
|  |         default: | ||||||
|  |           throw new Exception("Unknown constraint type '{$type}!"); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return false; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Construct a GROUP BY clause appropriate for ApplicationSearch constraints. | ||||||
|  |    * | ||||||
|  |    * @param AphrontDatabaseConnection Connection executing the query. | ||||||
|  |    * @return string Group clause. | ||||||
|  |    * @task appsearch | ||||||
|  |    */ | ||||||
|  |   protected function buildApplicationSearchGroupClause( | ||||||
|  |     AphrontDatabaseConnection $conn_r) { | ||||||
|  |  | ||||||
|  |     if ($this->getApplicationSearchMayJoinMultipleRows()) { | ||||||
|  |       return qsprintf( | ||||||
|  |         $conn_r, | ||||||
|  |         'GROUP BY %Q', | ||||||
|  |         $this->getApplicationSearchObjectPHIDColumn()); | ||||||
|  |     } else { | ||||||
|  |       return ''; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Construct a JOIN clause appropriate for applying ApplicationSearch | ||||||
|  |    * constraints. | ||||||
|  |    * | ||||||
|  |    * @param AphrontDatabaseConnection Connection executing the query. | ||||||
|  |    * @return string Join clause. | ||||||
|  |    * @task appsearch | ||||||
|  |    */ | ||||||
|  |   protected function buildApplicationSearchJoinClause( | ||||||
|  |     AphrontDatabaseConnection $conn_r) { | ||||||
|  |  | ||||||
|  |     $joins = array(); | ||||||
|  |     foreach ($this->applicationSearchConstraints as $key => $constraint) { | ||||||
|  |       $table = $constraint['table']; | ||||||
|  |       $alias = 'appsearch_'.$key; | ||||||
|  |       $index = $constraint['index']; | ||||||
|  |       $cond = $constraint['cond']; | ||||||
|  |       $phid_column = $this->getApplicationSearchObjectPHIDColumn(); | ||||||
|  |       if ($cond !== '=') { | ||||||
|  |         throw new Exception("Unknown constraint condition '{$cond}'!"); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       $type = $constraint['type']; | ||||||
|  |       switch ($type) { | ||||||
|  |         case 'string': | ||||||
|  |           $joins[] = qsprintf( | ||||||
|  |             $conn_r, | ||||||
|  |             'JOIN %T %T ON %T.objectPHID = %Q | ||||||
|  |               AND %T.indexKey = %s | ||||||
|  |               AND %T.indexValue IN (%Ls)', | ||||||
|  |             $table, | ||||||
|  |             $alias, | ||||||
|  |             $alias, | ||||||
|  |             $phid_column, | ||||||
|  |             $alias, | ||||||
|  |             $index, | ||||||
|  |             $alias, | ||||||
|  |             (array)$constraint['value']); | ||||||
|  |           break; | ||||||
|  |         case 'int': | ||||||
|  |           $joins[] = qsprintf( | ||||||
|  |             $conn_r, | ||||||
|  |             'JOIN %T %T ON %T.objectPHID = %Q | ||||||
|  |               AND %T.indexKey = %s | ||||||
|  |               AND %T.indexValue IN (%Ld)', | ||||||
|  |             $table, | ||||||
|  |             $alias, | ||||||
|  |             $alias, | ||||||
|  |             $phid_column, | ||||||
|  |             $alias, | ||||||
|  |             $index, | ||||||
|  |             $alias, | ||||||
|  |             (array)$constraint['value']); | ||||||
|  |           break; | ||||||
|  |         default: | ||||||
|  |           throw new Exception("Unknown constraint type '{$type}'!"); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return implode(' ', $joins); | ||||||
|  |   } | ||||||
|  |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1592,6 +1592,10 @@ final class PhabricatorBuiltinPatchList extends PhabricatorSQLPatchList { | |||||||
|         'type' => 'php', |         'type' => 'php', | ||||||
|         'name' => $this->getPatchPath('20130913.maniphest.1.migratesearch.php'), |         'name' => $this->getPatchPath('20130913.maniphest.1.migratesearch.php'), | ||||||
|       ), |       ), | ||||||
|  |       '20130914.usercustom.sql' => array( | ||||||
|  |         'type' => 'sql', | ||||||
|  |         'name' => $this->getPatchPath('20130914.usercustom.sql'), | ||||||
|  |       ), | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 epriestley
					epriestley