Making Attribute Types Searchable

Attributes aren't just useful for storing data and outputting it in certain situations; they can also enable objects to be searched in custom ways. Any attribute type added to a particular object can make that object searchable by that attribute – the developer just needs to put some effort into creating the search interface, implementing the item list filtering, and storing the proper data to make that searching possible.

Let's make our Property Location attribute searchable.

Before anything else, make sure there is a valid search index definition in the attribute type controller. Here's the search index definition for our attribute type:

protected $searchIndexFieldDefinition = array('type' => 'integer', 'options' => array('default' => 0, 'notnull' => false));

This should be fairly self-explanatory: we are creating a single field of an integer type. That means we're going to store integer values in this field. The standard behavior of the Concrete CMS attribute indexer creates these columns starting with ak_, and then continuing with the handle. So if we create a page attribute key with the handle "property_location" the column "ak_property_location" will be created on the CollectionSearchIndexAttributes table, and it will be an integer type.

(Note: the type and options that are available here directly corresponds to Doctrine ORM. See its official documentation for information on what types and options are available.)

Now, ensure that the integer that corresponds to our property location ID is actually inserted into this indexed field. We do this by implement getSearchIndexValue in our controller:

public function getSearchIndexValue()
{
    if ($this->attributeValue) {
        /**
         * @var $value PropertyLocationValue
         */
        $value = $this->attributeValue->getValueObject();
        return $value->getPropertyLocationID();
    }
}

By returning an integer, we know that the proper data type will be inserted into our index field. Let's take a look at this in action. When we save location ID 2 against page ID, this is what it looks like in the database.

mysql> select ak_property_location from CollectionSearchIndexAttributes where cID = 1; +----------------------+ | ak_property_location | +----------------------+ | 2 | +----------------------+ 1 row in set (0.00 sec)

Looks like everything is set up correctly.

Next, we'll want to create a search interface. If we don't create a custom search interface, our default form.php will be used – and this won't be useful because we're not going to enable searching via our custom label (since it's really only for display) - just for our property location ID. So create a file named search.php in the attributes/property_location folder and place these contents into it.

Remove any custom label functionality, as well as the radio button list. We're just going to use the select menu to search:

<?php defined('C5_EXECUTE') or die("Access Denied."); ?>
<select class="form-control" name="<?=$view->field('propertyLocationID')?>">
    <option value="">Select a Location</option>
    <option value="1" <?php if (isset($propertyLocationID) && $propertyLocationID == 1) { ?>selected<?php }  ?>>Crown Plaza</option>
    <option value="2" <?php if (isset($propertyLocationID) && $propertyLocationID == 2) { ?>selected<?php }  ?>>Town Square</option>
    <option value="3" <?php if (isset($propertyLocationID) && $propertyLocationID == 3) { ?>selected<?php }  ?>>Hill Road</option>
    <option value="4" <?php if (isset($propertyLocationID) && $propertyLocationID == 4) { ?>selected<?php }  ?>>Uptown Avenue</option>
</select>

The advanced page search dialog should look like this:

Now, let's implement a search() method in our controller so that we can pass the current search request values into this template:

public function search()
{
    $propertyLocationID = $this->request('propertyLocationID');
    $this->set('propertyLocationID', $propertyLocationID);
}

Next, let's implement the backend searching. Create a searchForm() method in the controller. This is run whenever an item list is filtered by an attribute key of this particular type. The $list object is the item list object that is being searched, like an Concrete\Core\Search\ItemList\Database\AttributedItemList.

Here's all we have to do:

public function searchForm($list)
{
    $list->filterByAttribute($this->attributeKey->getAttributeKeyHandle(), $this->request('propertyLocationID'));
    return $list;
}

This will search our attribute search index table's proper column by the propertyLocationID that we have saved in the request.

And that'll do it. We've made our attribute searchable.

Advanced: Searching against Multiple Fields

Sometimes you'll need to search against multiple fields. For example, the core address attribute stores multiple pieces of data, and needs to be searched in a granular way (show me all entries with the city of Portland, for example.). For this, we'll need multiple index fields. To use and search multiple fields, first ensure the search index definition returns an array of types/options:

protected $searchIndexFieldDefinition = array(
    'address1' => array(
        'type' => 'string',
        'options' => array('length' => '255', 'default' => '', 'notnull' => false),
    ),
    'address2' => array(
        'type' => 'string',
        'options' => array('length' => '255', 'default' => '', 'notnull' => false),
    ),
    'city' => array('type' => 'string', 'options' => array('length' => '255', 'default' => '', 'notnull' => false)),
    'state_province' => array(
        'type' => 'string',
        'options' => array('length' => '255', 'default' => '', 'notnull' => false),
    ),
    'country' => array(
        'type' => 'string',
        'options' => array('length' => '255', 'default' => '', 'notnull' => false),
    ),
    'postal_code' => array(
        'type' => 'string',
        'options' => array('length' => '255', 'default' => '', 'notnull' => false),
    ),
);

Next, make sure that the searchForm() method searches those appropriately.

public function searchForm($list)
{
    $address1 = $this->request('address1');
    $address2 = $this->request('address2');
    $city = $this->request('city');
    $state_province = $this->request('state_province');
    $postal_code = $this->request('postal_code');
    $country = $this->request('country');
    $akHandle = $this->attributeKey->getAttributeKeyHandle();

    if ($address1) {
        $list->filter('ak_' . $akHandle . '_address1', '%' . $address1 . '%', 'like');
    }
    if ($address2) {
        $list->filter('ak_' . $akHandle . '_address2', '%' . $address2 . '%', 'like');
    }
    if ($city) {
        $list->filter('ak_' . $akHandle . '_city', '%' . $city . '%', 'like');
    }
    if ($state_province) {
        $list->filter('ak_' . $akHandle . '_state_province', $state_province);
    }
    if ($postal_code) {
        $list->filter('ak_' . $akHandle . '_postal_code', '%' . $postal_code . '%', 'like');
    }
    if ($country) {
        $list->filter('ak_' . $akHandle . '_country', $country);
    }

    return $list;
}

Advanced: Handling Search via Keyword

Attribute keys can also opt into being included in the keyword index. This doesn't actually take the content of the attribute keys and place them in a separate index, it just ensures that the searchKeywords method will be run on each appropriate attribute type every time a keyword index is performed.

To allow your attribute type to be keyword searchable, implement a searchKeywords() method in the controller, in the following way:

public function searchKeywords($keywords, $queryBuilder)
{
}

$keywords is the text string that is being searched, and $queryBuilder corresponds to the Doctrine\DBAL\Query\QueryBuilder object that is currently being constructed for the current item list query. Now the searchKeywords method should use the currently joined index attributes table to do some keyword-based searches, using the $keywords.

Here's how the Address attribute searches all associated address data for the passed $keywords.

public function searchKeywords($keywords, $queryBuilder)
{
    $h = $this->attributeKey->getAttributeKeyHandle();

    return $queryBuilder->expr()->orX(
        $queryBuilder->expr()->like("ak_{$h}_address1", ':keywords'),
        $queryBuilder->expr()->like("ak_{$h}_address2", ':keywords'),
        $queryBuilder->expr()->like("ak_{$h}_city", ':keywords'),
        $queryBuilder->expr()->like("ak_{$h}_state_province", ':keywords'),
        $queryBuilder->expr()->like("ak_{$h}_postal_code", ':keywords'),
        $queryBuilder->expr()->like("ak_{$h}_country", ':keywords')
    );
}

First, it constructs an OR query expression, and adds multiple LIKE expressions for addresses, city, state, postal code and country. In this example $keywords isn't actually used, because a bound parameter :keywords is used instead. In a keyword search, :keywords is automatically available for the query builder to use.