Returning Search Filters Along With Search Results In Lucee CFML 5.3.7.47
At InVision, I'm building an experimental search page for a customer that has an abnormally large amount of data. And, as I've been working on this feature, I started using a technique that I've come to really like: returning the search filters (ie, the input parameters) alongside the search results in the response payload for the client-side AJAX request. I'm finding this to be especially helpful when I have a higher chance of overlapping AJAX responses. As such, I thought I would share a quick example in Lucee CFML 5.3.7.47.
The concept is really straightforward. Instead of returning just the top-level "data", I'm returning a structure that contains both the "data" and the "inputs". So, instead of returning a search results value like this:
{
"results": [ ... ]
}
I would return something like this:
{
"filters": { ... },
"results": [ ... ]
}
Where the "filters" contain the input parameters that were used to derive the "results" collection.
A simple ColdFusion component for search might look something like this:
component
output = false
hint = "I provide service methods for search."
{
/**
* I initialize the search service to search against the given collection of contacts.
*
* @allContacts I am the base set of contacts to search.
*/
public void function init( required array allContacts ) {
variables.allContacts = arguments.allContacts;
}
// ---
// PUBLIC METHODS.
// ---
/**
* I search for the contacts that match the given filters.
*
* @searchKeywords I filter on the name.
* @searchRoleID I filter on the roleID.
*/
public struct function search(
required string searchKeywords,
required numeric searchRoleID
) {
var filteredContacts = findContacts( argumentCollection = arguments );
// When we return the search results, we also want to return the input filters
// so that the client-side code might have an easier time consuming the response
// (and associating the response with the corresponding request in the case of
// overlapping AJAX requests).
return({
filters: {
searchKeywords: searchKeywords,
searchRoleID: searchRoleID
},
results: filteredContacts.map(
( contact ) => {
return({
id: contact.id,
name: contact.name,
roleID: contact.roleID,
createdAt: contact.createdAt.getTime()
});
}
)
});
}
// ---
// PRIVATE METHODS.
// ---
/**
* I return the contacts that match the given filters.
*
* @searchKeywords I filter on the name.
* @searchRoleID I filter on the roleID.
*/
private array function findContacts(
required string searchKeywords,
required numeric searchRoleID
) {
// If all the filters are empty, we can bypass the filtering operation and just
// return the core set of contacts.
if ( ! searchKeywords.len() && ! searchRoleID ) {
return( allContacts );
}
var contacts = allContacts.filter(
( contact ) => {
if ( searchKeywords.len() && ! contact.name.findNoCase( searchKeywords ) ) {
return( false );
}
if ( searchRoleID && ( contact.roleID != searchRoleID ) ) {
return( false );
}
// If none of the filters explicitly excluded the given contact, then we
// can implicitly include the contact in the filtered results.
return( true );
}
);
return( contacts );
}
}
As you can see, the search()
method returns a Struct with keys for both the filters and the results. In this case, the "filters" object is just a simple echo of the inputs. But, if I needed to, I could override the filtering and then return the actual inputs that I used to generate the results.
In my client-side JavaScript, I can then look at the "filters" value and compare it to the page's local view-model and consume the "results" accordingly. For example, if the local view-model no longer matches the "filters" in the response, that may mean that I simply cache the results for later but skip the rendering. Pseudo-code for this might look like:
// ... truncated ...
searchService
.search( vm.filters )
.then(
function handleSuccess( response ) {
// Always cache the result based on the filters - we can use these results
// later to optimistically render subsequent searches.
cacheResults( response.filters, response.results );
// Since the search results are being collected over an asynchronous AJAX
// request, it's possible that at the time this request is resolved, the
// response is no longer relevant to the user's current search. In such a
// case, just skip the rendering.
if ( ! matchesCurrentFilters( response.filters ) ) {
return;
}
renderResults( response.results );
}
)
;
// ... truncated ...
As you can see, I am using the returned "filters" to help manage the processing of the "results". This makes it much easier to manage potentially overlapping AJAX responses.
I'm still experimenting with this concept for search results. But so far, I'm rather enjoying it. And to be clear, I'm not advocating this approach for all AJAX requests - I think this is something that is well-suited specifically for search style requests.
Want to use code from this post? Check out the license.
Reader Comments
I've used something similar in the past. Typically I will return a meta node as well as a data node in my API response. In the meta node I will typically include the URI that was used to call the API, pagination, and filter information. This way you have all the values you need in order to know how the data node was generated. This also makes it super easy when displaying time series data in something like a graph that updates periodically to have the complete URI to call to get the latest dataset.
@Peter,
It's funny that you mention time-series data because I was just now trying to understand how Loggly (our log aggregation service) was returning data, and I was looking at the Network tab and I see that it was building-up the chart by making a series of AJAX calls, each of which was returning additional series data. it seems that technique is spot-on.
I'll tell you, in the early days of our application, we had some end-points that returned an Array of data. But, I feel like in almost all cases, I've come to regret that. Even if I don't need to return anything else, I now see that returning an Object will at the very least set me up to be able to return additional properties in the future should I ever need to. But, with an Array, you're just kind of stuck returning data-only until you make a breaking change to the API.