MDL-59434 core_search: Alternate result orders including by context

Implements a mechanism by which search engines can provide different
result orderings, and implements a 'by location' ordering within the
Solr search engine (available whenever the user starts their search
from within a course or activity).
This commit is contained in:
sam marshall 2017-11-23 10:55:07 +00:00
parent b63a3b04b1
commit fc440796e9
9 changed files with 211 additions and 0 deletions

View file

@ -81,6 +81,9 @@ $string['notitle'] = 'No title';
$string['normalsearch'] = 'Normal search'; $string['normalsearch'] = 'Normal search';
$string['openedon'] = 'opened on'; $string['openedon'] = 'opened on';
$string['optimize'] = 'Optimize'; $string['optimize'] = 'Optimize';
$string['order'] = 'Results order';
$string['order_location'] = 'Prioritise results related to {$a}';
$string['order_relevance'] = 'Most relevant results first';
$string['priority'] = 'Priority'; $string['priority'] = 'Priority';
$string['priority_reindexing'] = 'Reindexing'; $string['priority_reindexing'] = 'Reindexing';
$string['priority_normal'] = 'Normal'; $string['priority_normal'] = 'Normal';

View file

@ -543,4 +543,17 @@ abstract class engine {
public function supports_group_filtering() { public function supports_group_filtering() {
return false; return false;
} }
/**
* Obtain a list of results orders (and names for them) that are supported by this
* search engine in the given context.
*
* By default, engines sort by relevance only.
*
* @param \context $context Context that the user requested search from
* @return array Array from order name => display text
*/
public function get_supported_orders(\context $context) {
return ['relevance' => get_string('order_relevance', 'search')];
}
} }

View file

@ -684,6 +684,8 @@ class manager {
* - q (query text) * - q (query text)
* - courseids (optional list of course ids to restrict) * - courseids (optional list of course ids to restrict)
* - contextids (optional list of context ids to restrict) * - contextids (optional list of context ids to restrict)
* - context (Moodle context object for location user searched from)
* - order (optional ordering, one of the types supported by the search engine e.g. 'relevance')
* *
* @param \stdClass $formdata Query input data (usually from search form) * @param \stdClass $formdata Query input data (usually from search form)
* @param int $limit The maximum number of documents to return * @param int $limit The maximum number of documents to return

View file

@ -55,6 +55,15 @@ class search extends \moodleform {
$mform->setDefault('searchwithin', ''); $mform->setDefault('searchwithin', '');
} }
// If the search engine provides multiple ways to order results, show options.
if (!empty($this->_customdata['orderoptions']) &&
count($this->_customdata['orderoptions']) > 1) {
$mform->addElement('select', 'order', get_string('order', 'search'),
$this->_customdata['orderoptions']);
$mform->setDefault('order', 'relevance');
}
$mform->addElement('header', 'filtersection', get_string('filterheader', 'search')); $mform->addElement('header', 'filtersection', get_string('filterheader', 'search'));
$mform->setExpanded('filtersection', false); $mform->setExpanded('filtersection', false);

View file

@ -65,6 +65,12 @@ class engine extends \core_search\engine {
*/ */
const HIGHLIGHT_END = '@@HI_E@@'; const HIGHLIGHT_END = '@@HI_E@@';
/** @var float Boost value for matching course in location-ordered searches */
const COURSE_BOOST = 1;
/** @var float Boost value for matching context (in addition to course boost) */
const CONTEXT_BOOST = 0.5;
/** /**
* @var \SolrClient * @var \SolrClient
*/ */
@ -370,6 +376,16 @@ class engine extends \core_search\engine {
$query->addFilterQuery('type:'.\core_search\manager::TYPE_TEXT); $query->addFilterQuery('type:'.\core_search\manager::TYPE_TEXT);
} }
// If ordering by location, add in boost for the relevant course or context ids.
if (!empty($filters->order) && $filters->order === 'location') {
$coursecontext = $filters->context->get_course_context();
$query->addBoostQuery('courseid', $coursecontext->instanceid, self::COURSE_BOOST);
if ($filters->context->contextlevel !== CONTEXT_COURSE) {
// If it's a block or activity, also add a boost for the specific context id.
$query->addBoostQuery('contextid', $filters->context->id, self::CONTEXT_BOOST);
}
}
return $query; return $query;
} }
@ -1357,4 +1373,24 @@ class engine extends \core_search\engine {
return true; return true;
} }
/**
* Solr supports sort by location within course contexts or below.
*
* @param \context $context Context that the user requested search from
* @return array Array from order name => display text
*/
public function get_supported_orders(\context $context) {
$orders = parent::get_supported_orders($context);
// If not within a course, no other kind of sorting supported.
$coursecontext = $context->get_course_context(false);
if ($coursecontext) {
// Within a course or activity/block, support sort by location.
$orders['location'] = get_string('order_location', 'search',
$context->get_context_name());
}
return $orders;
}
} }

View file

@ -950,4 +950,130 @@ class search_solr_engine_testcase extends advanced_testcase {
sort($expected); sort($expected);
$this->assertEquals($expected, $titles); $this->assertEquals($expected, $titles);
} }
/**
* Tests the get_supported_orders function for contexts where we can only use relevance
* (system, category).
*/
public function test_get_supported_orders_relevance_only() {
global $DB;
// System or category context: relevance only.
$orders = $this->engine->get_supported_orders(\context_system::instance());
$this->assertCount(1, $orders);
$this->assertArrayHasKey('relevance', $orders);
$categoryid = $DB->get_field_sql('SELECT MIN(id) FROM {course_categories}');
$orders = $this->engine->get_supported_orders(\context_coursecat::instance($categoryid));
$this->assertCount(1, $orders);
$this->assertArrayHasKey('relevance', $orders);
}
/**
* Tests the get_supported_orders function for contexts where we support location as well
* (course, activity, block).
*/
public function test_get_supported_orders_relevance_and_location() {
global $DB;
// Test with course context.
$generator = $this->getDataGenerator();
$course = $generator->create_course(['fullname' => 'Frogs']);
$coursecontext = \context_course::instance($course->id);
$orders = $this->engine->get_supported_orders($coursecontext);
$this->assertCount(2, $orders);
$this->assertArrayHasKey('relevance', $orders);
$this->assertArrayHasKey('location', $orders);
$this->assertContains('Course: Frogs', $orders['location']);
// Test with activity context.
$page = $generator->create_module('page', ['course' => $course->id, 'name' => 'Toads']);
$orders = $this->engine->get_supported_orders(\context_module::instance($page->cmid));
$this->assertCount(2, $orders);
$this->assertArrayHasKey('relevance', $orders);
$this->assertArrayHasKey('location', $orders);
$this->assertContains('Page: Toads', $orders['location']);
// Test with block context.
$instance = (object)['blockname' => 'html', 'parentcontextid' => $coursecontext->id,
'showinsubcontexts' => 0, 'pagetypepattern' => 'course-view-*',
'defaultweight' => 0, 'timecreated' => 1, 'timemodified' => 1,
'configdata' => ''];
$blockid = $DB->insert_record('block_instances', $instance);
$blockcontext = \context_block::instance($blockid);
$orders = $this->engine->get_supported_orders($blockcontext);
$this->assertCount(2, $orders);
$this->assertArrayHasKey('relevance', $orders);
$this->assertArrayHasKey('location', $orders);
$this->assertContains('Block: HTML', $orders['location']);
}
/**
* Tests ordering by relevance vs location.
*/
public function test_ordering() {
// Create 2 courses and 2 activities.
$generator = $this->getDataGenerator();
$course1 = $generator->create_course(['fullname' => 'Course 1']);
$course1context = \context_course::instance($course1->id);
$course1page = $generator->create_module('page', ['course' => $course1]);
$course1pagecontext = \context_module::instance($course1page->cmid);
$course2 = $generator->create_course(['fullname' => 'Course 2']);
$course2context = \context_course::instance($course2->id);
$course2page = $generator->create_module('page', ['course' => $course2]);
$course2pagecontext = \context_module::instance($course2page->cmid);
// Create one search record in each activity and course.
$this->create_search_record($course1->id, $course1context->id, 'C1', 'Xyzzy');
$this->create_search_record($course1->id, $course1pagecontext->id, 'C1P', 'Xyzzy');
$this->create_search_record($course2->id, $course2context->id, 'C2', 'Xyzzy');
$this->create_search_record($course2->id, $course2pagecontext->id, 'C2P', 'Xyzzy plugh');
$this->search->index();
// Default search works by relevance so the one with both words should be top.
$querydata = new stdClass();
$querydata->q = 'xyzzy plugh';
$results = $this->search->search($querydata);
$this->assertCount(4, $results);
$this->assertEquals('C2P', $results[0]->get('title'));
// Same if you explicitly specify relevance.
$querydata->order = 'relevance';
$results = $this->search->search($querydata);
$this->assertEquals('C2P', $results[0]->get('title'));
// If you specify order by location and you are in C2 or C2P then results are the same.
$querydata->order = 'location';
$querydata->context = $course2context;
$results = $this->search->search($querydata);
$this->assertEquals('C2P', $results[0]->get('title'));
$querydata->context = $course2pagecontext;
$results = $this->search->search($querydata);
$this->assertEquals('C2P', $results[0]->get('title'));
// But if you are in C1P then you get different results (C1P first).
$querydata->context = $course1pagecontext;
$results = $this->search->search($querydata);
$this->assertEquals('C1P', $results[0]->get('title'));
}
/**
* Adds a record to the mock search area, so that the search engine can find it later.
*
* @param int $courseid Course id
* @param int $contextid Context id
* @param string $title Title for search index
* @param string $content Content for search index
*/
protected function create_search_record($courseid, $contextid, $title, $content) {
$record = new \stdClass();
$record->content = $content;
$record->title = $title;
$record->courseid = $courseid;
$record->contextid = $contextid;
$this->generator->create_record($record);
}
} }

View file

@ -71,6 +71,9 @@ if ($contextid) {
} }
$customdata['searchwithin'] = $searchwithin; $customdata['searchwithin'] = $searchwithin;
} }
// Get available ordering options from search engine.
$customdata['orderoptions'] = $search->get_engine()->get_supported_orders($context);
} }
$mform = new \core_search\output\form\search(null, $customdata); $mform = new \core_search\output\form\search(null, $customdata);
@ -112,6 +115,11 @@ if ($data && !empty($data->searchwithin)) {
} }
} }
// Inform search engine about source context.
if (!empty($context) && $data) {
$data->context = $context;
}
// Set the page URL. // Set the page URL.
$urlparams = array('page' => $page); $urlparams = array('page' => $page);
if ($data) { if ($data) {

View file

@ -119,4 +119,14 @@ class search_engine_testcase extends advanced_testcase {
$updates = $engine->get_and_clear_schema_updates(); $updates = $engine->get_and_clear_schema_updates();
$this->assertCount(0, $updates); $this->assertCount(0, $updates);
} }
/**
* Tests the get_supported_orders stub function.
*/
public function test_get_supported_orders() {
$engine = new \mock_search\engine();
$orders = $engine->get_supported_orders(\context_system::instance());
$this->assertCount(1, $orders);
$this->assertArrayHasKey('relevance', $orders);
}
} }

View file

@ -9,6 +9,10 @@ information provided here is intended especially for developers.
contexts first. If not implemented, the default behaviour for modules and blocks is to reindex contexts first. If not implemented, the default behaviour for modules and blocks is to reindex
the newest items first; for other types of search area it will just index the whole system the newest items first; for other types of search area it will just index the whole system
context, oldest data first. context, oldest data first.
* Search engines may now implement get_supported_orders function to provide multiple ordering
options (other than 'relevance' which is default). If there is more than one order then a choice
will be shown to users. (This is an optional feature, existing search engine plugins do not need
to be modified in order to continue working.)
* Module search areas that wish to support group filtering should set the new optional search * Module search areas that wish to support group filtering should set the new optional search
document field groupid (note: to remain compatible with earlier versions, do this inside an if document field groupid (note: to remain compatible with earlier versions, do this inside an if