NEW canView() check on record before indexing and before showing in search results

This commit is contained in:
Steve Boyd 2020-06-10 17:22:20 +12:00
parent 04081b1ce7
commit cfe937fbd1
21 changed files with 687 additions and 240 deletions

View File

@ -8,9 +8,81 @@
Adds support for fulltext search engines like Sphinx and Solr to SilverStripe CMS.
Compatible with PHP 7.2
## Maintainer Contact
## Important notes when upgrading to fulltextsearch 3.7.0+
* Hamish Friedlander <hamish (at) silverstripe (dot) com>
There are some significant changes from previous versions:
Draft content will no longer be automatically added to the search index. This new behaviour was previously an
opt-in behaviour that was enabled by adding the following line to a search index:
```
$this->excludeVariantState([SearchVariantVersioned::class => Versioned::DRAFT]);
```
A new `canView()` check against an anonymous user (i.e. someone not logged in) and a `ShowInSearch` check is now
performed by default against all records (DataObjects) before being added to the search index, and also before being
shown in search results. This may mean that some records that were previously being indexed and shown in search results
will no longer appear due to these additional checks.
These additional checks have been added with data security in mind, and it's assumed that records failing these
checks probably should not be indexed in the first place.
# Enable indexing of draft content:
You can index draft content with the following yml configuration:
```
SilverStripe\FullTextSearch\Search\Services\SearchableService:
variant_state_draft_excluded: false
```
However, when set to false, it will still only index draft content when a DataObject is in a published state, not a
draft-only or modified state. This is because it will still fail the new anonymous user `canView()` check in
`SearchableService::isSearchable()` and be automatically deleted from the index.
If you wish to also index draft content when a DataObject is in a draft-only or a modified state, then you'll need
to also configure `SearchableService::indexing_canview_exclude_classes`. See below for instructions on how to do this.
# Disabling the anonymous user canView() pre-index check
You can apply configuration to remove the new pre-index `canView()` check from your DataObjects if it is not necessary,
or if it impedes expected functionality (e.g. for sites where users must authenticate to view any content). This will
also disable the check for descendants of the specified DataObjects. Ensure that your implementation of fulltextsearch
is correctly performing a `canView()` check at query time before disabling the pre-index check, as this may result in
leakage of private data.
```
SilverStripe\FullTextSearch\Search\Services\SearchableService:
indexing_canview_exclude_classes:
- Some\Org\MyDataObject
# This will disable the check for all pagetypes:
- SilverStripe\CMS\Model\SiteTree
```
You can also use the `updateIsSearchable` extension point on `SearchableService` to modify the result of the method
after the `ShowInSearch` and `canView()` checks have run.
It is highly recommend you run a [solr_reindex](https://github.com/silverstripe/silverstripe-fulltextsearch/blob/3/docs/en/03_configuration.md#solr-reindex)
on your production site after upgrading from 3.6 or earlier to purge any old data that should no longer be in the search index.
These additional check can have an impact on the reindex performance due to additional queries for permission checks.
If your site also indexes content in files, such as pdf's or docx's, using the [text-extraction](https://github.com/silverstripe/silverstripe-textextraction)
module which is fairly time-intensive, then the relative performance impact of the `canView()` checks won't be as noticeable.
## Details on filtering before adding content to the solr index
- `SearchableService::isIndexable()` check in `SolrReindexBase`. Used when indexing all records during Solr reindex.
- `SearchableService::isIndexable()` check in `SearchUpdateProcessor`. Used when indexing single records during
`DataObject->write()`.
## Details on filtering when extracting results from the solr index
- `SearchableService::isViewable()` check in `SolrIndex`. This will often be used in CWP implementations that use the
`CwpSearchEngine` class, as well as most custom implementations that call `MySearchIndex->search()`
- `SearchableService::isViewable()` check in `SearchForm`. This will be used in solr implementations where a
`/SearchForm` url is used to display search results.
- Some implementations will call `SearchableService::isViewable()` twice. If this happens then the first call will be
cached in memory so there is virtually no performance penalty calling it a second time.
- If your implementation is very custom and does not subclass nor make use of either `SolrIndex` or `SearchForm`, then
it's recommended you update your implementation to call `SearchableService::isViewable()`.
## Requirements

View File

@ -1,90 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/).
## [2.2.0]
* FIX Indexes with custom index names that don't match the classname were breaking
* BUG Fix versioned writes where subtables have no fields key
* BUGFIX: Fixed issue where the $id variable would be overridden in sub sequent iterations of the derived fields loop
* adding stemming support
* BUG fix issues with search variants applying to more than one class
* API adding stemming support
* FIX: Fix initial dev/build on PDO Database.
## [2.1.1]
* Converted to PSR-2
* FIX: remove parameters from function calls
* Added standard code of conduct
* Added standard editor config
* Updated license
* Added standard gitattributes
* MINOR: Don't include Hamcrest globally so it doesn't conflict with PHPUnit
## [2.1.0]
* 3.2 Compatibility
* Add ss 3.2 and PHP 5.6 to CI
* Added standard Scrutinizer config
* Added standard Travis config
## [2.0.0]
* Fix highlight support when querying by fields (or boosting fields)
* Updating travis provisioner
* Added docs about controller and template usage
* API Enable boosted fields to be specified on the index
* BUG Prevent subsites breaking solrindexversionedtest
* Enable indexes to upload custom config
* API Additional support for custom copy_fields
* API QueuedJob support for Solr_Reindex
## [1.1.0]
* API Solr_Reindex uses configured SearchUpdater instead of always doing a direct write
* Fix class limit on delete query in SolrIndex
* Regression in SearchUpdater_ObjectHandler
* API Separate searchupdate / commit into separate queued-jobs
* API Only allow one scheduled commit job at a time
## [1.0.6]
* Make spelling suggestions more useful
* BUG Add missing addStoredFields method
## [1.0.5]
* BUG Fix Solr 4.0 compatibility issue
* BUG Fix test case not elegantly failing on missing phockito
* API SearchUpdateQueuedJobProcessor now uses batching
* Fix many_many fieldData bug
* Adding tests for SearchIndex::fieldData()
* Add a no-op query to prevent database timeouts during a long reindex
## [1.0.4]
* BUG Patch up the information leak of debug information.
* FIX: will work for postgreSQL
## [1.0.3]
Users upgrading from 1.0.2 or below will need to run the Solr_Reindex task to refresh
each SolrIndex. This is due to a change in record IDs, which are now generated from
the base class of each DataObject, rather than the instance class, as well as fixes
to integration with the subsites module.
Developers working locally should be aware that by default, all indexes will be updated
in realtime when the environment is in dev mode, rather than attempting to queue these
updates with the queued jobs module (if installed).
### Bugfixes
* BUG Fix old indexing storing against the incorrect class key
* [Don't rely on MySQL ordering of index->getAdded()](https://github.com/silverstripe-labs/silverstripe-fulltextsearch/commit/4b51393e014fc4c0cc8e192c74eb4594acaca605)
### API
* [API Disable queued processing for development environments](https://github.com/silverstripe-labs/silverstripe-fulltextsearch/commit/71fc359b3711cf5b9429d86da0f1e0b20bd43dee)

View File

@ -22,7 +22,7 @@
],
"require": {
"php": ">=7.1",
"silverstripe/framework": "^4.0",
"silverstripe/framework": "^4",
"monolog/monolog": "~1.15",
"ptcinc/solr-php-client": "^1.0",
"symfony/process": "^3.2",

View File

@ -112,12 +112,13 @@ Depending on the size of the index and how much content needs to be processed, i
If the [Queued Jobs module](https://github.com/symbiote/silverstripe-queuedjobs/) is installed, updates are queued up instead of executed in the same request. Queued jobs are usually processed every minute. Large index updates will be batched into multiple queued jobs to ensure a job can run to completion within common constraints, such as memory and execution time limits. You can check the status of jobs in an administrative interface under `admin/queuedjobs/`.
### Excluding draft content
### Draft content
By default, the `SearchUpdater` class indexes all available "variant states", so in the case of the `Versioned` extension, both "draft" and "live".
For most cases, you'll want to exclude draft content from your search results.
By default, the `SearchUpdater` class attempts to index all available "variant states", except for draft content.
Draft content is excluded by default via calls to SearchableService::variantStateExcluded().
You can either prevent the draft content from being indexed in the first place, by adding the following to your `SearchIndex::init()` method:
Excluding draft content was a new default added in 3.7.0. Prior to that, draft content was previously indexed by
default and could be excluded fron the index by adding the following to the `SearchIndex::init()` method:
```php
use Page;
@ -136,7 +137,10 @@ class MyIndex extends SolrIndex
}
```
Alternatively, you can index draft content, but simply exclude it from searches. This can be handy to preview search results on unpublished content, in case a CMS author is logged in. Before constructing your `SearchQuery`, conditionally switch to the "live" stage.
If required, you can opt-out of the secure default and index draft content, but simply exclude it from searches.
Read the inline documentation within SearchableService.php for more details on how to do this.
This can be handy to preview search results on unpublished content, in case a CMS author is logged in.
Before constructing your `SearchQuery`, conditionally switch to the "live" stage.
### Adding DataObjects

View File

@ -2,10 +2,12 @@
namespace SilverStripe\FullTextSearch\Search\Processors;
use SilverStripe\FullTextSearch\Search\Services\IndexableService;
use SilverStripe\FullTextSearch\Search\Services\SearchableService;
use SilverStripe\FullTextSearch\Search\Variants\SearchVariantVersioned;
use SilverStripe\ORM\DataObject;
use SilverStripe\FullTextSearch\Search\Variants\SearchVariant;
use SilverStripe\FullTextSearch\Search\FullTextSearch;
use SilverStripe\Versioned\Versioned;
abstract class SearchUpdateProcessor
{
@ -72,7 +74,8 @@ abstract class SearchUpdateProcessor
$dirtyIndexes = array();
$dirty = $this->getSource();
$indexes = FullTextSearch::get_indexes();
$indexableService = IndexableService::singleton();
$searchableService = SearchableService::singleton();
foreach ($dirty as $base => $statefulids) {
if (!$statefulids) {
continue;
@ -86,11 +89,15 @@ abstract class SearchUpdateProcessor
// Ensure that indexes for all new / updated objects are included
$objs = DataObject::get($base)->byIDs(array_keys($ids));
/** @var DataObject $obj */
foreach ($objs as $obj) {
foreach ($ids[$obj->ID] as $index) {
if (!$indexes[$index]->variantStateExcluded($state)) {
// Remove any existing records from index if ShowInSearch is changed to false
if (!$indexableService->isIndexable($obj)) {
if (!$searchableService->variantStateExcluded($state) &&
!$indexes[$index]->variantStateExcluded($state)
) {
// Remove any existing data from index if the object is no longer indexable
if (!$searchableService->isIndexable($obj)) {
$indexes[$index]->delete($base, $obj->ID, $state);
} else {
$indexes[$index]->add($obj);
@ -104,7 +111,9 @@ abstract class SearchUpdateProcessor
// Generate list of records that do not exist and should be removed
foreach ($ids as $id => $fromindexes) {
foreach ($fromindexes as $index) {
if (!$indexes[$index]->variantStateExcluded($state)) {
if (!$searchableService->variantStateExcluded($state) &&
!$indexes[$index]->variantStateExcluded($state)
) {
$indexes[$index]->delete($base, $id, $state);
$dirtyIndexes[$index] = $indexes[$index];
}

View File

@ -1,58 +0,0 @@
<?php
namespace SilverStripe\FullTextSearch\Search\Services;
use SilverStripe\Core\Extensible;
use SilverStripe\Core\Injector\Injectable;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\Tests\MySQLDatabaseTest\Data;
/**
* Checks if a DataObject is publically viewable thus able to be added or retrieved from a publically searchable index
* Caching results because these checks may be done multiple times as there a few different code paths that search
* results might follow in real-world search implementations
*/
class IndexableService
{
use Injectable;
use Extensible;
protected $cache = [];
public function clearCache(): void
{
$this->cache = [];
}
public function isIndexable(DataObject $obj): bool
{
// check if is a valid DataObject that has been persisted to the database
if (is_null($obj) || !$obj->ID) {
return false;
}
$key = $this->getCacheKey($obj);
if (isset($this->cache[$key])) {
return $this->cache[$key];
}
$value = true;
// This will also call $obj->getShowInSearch() if it exists
if (isset($obj->ShowInSearch) && !$obj->ShowInSearch) {
$value = false;
}
$this->extend('updateIsIndexable', $obj, $value);
$this->cache[$key] = $value;
return $value;
}
protected function getCacheKey(DataObject $obj): string
{
$key = $obj->ClassName . '_' . $obj->ID;
$this->extend('updateCacheKey', $obj, $key);
return $key;
}
}

View File

@ -0,0 +1,212 @@
<?php
namespace SilverStripe\FullTextSearch\Search\Services;
use SilverStripe\Core\Config\Configurable;
use SilverStripe\Core\Extensible;
use SilverStripe\Core\Injector\Injectable;
use SilverStripe\FullTextSearch\Search\Variants\SearchVariantVersioned;
use SilverStripe\ORM\DataObject;
use SilverStripe\Security\Member;
use SilverStripe\Versioned\Versioned;
/**
* Checks if a DataObject is publicly viewable, thus able to be added to or retrieved from a publicly searchable index.
* Results are cached because these checks may be run multiple times, as there a few different code paths that search
* results might follow in real-world search implementations.
*/
class SearchableService
{
use Injectable;
use Extensible;
use Configurable;
/**
* Skip the canView() check at a class level to increase performance of search reindex.
* Be careful as this may lead to content showing in search results that should not be there such as non-public,
* cms-user-only content. This may potentially happen via edge cases such as skipping checks where subclasses
* are involved.
*
* This has no effect on when search results as canView() must still be run there
*
* @var array namespaced classes to skip canView() check on search reindex
*/
private static $indexing_canview_exclude_classes = [];
/**
* Configurable value to index draft content. Default is true for better security.
*
* If you need to index draft content, then view README.md for instructions
*
* @var bool
*/
private static $variant_state_draft_excluded = true;
/**
* Non-persistant memory cache that only lasts the lifetime of the request
*
* @var array
*/
private $cache = [];
/**
* Clears the internal cache
*/
public function clearCache(): void
{
$this->cache = [];
}
/**
* Check to exclude a variant state
*
* @param array $state
* @return bool
*/
public function variantStateExcluded(array $state): bool
{
if (self::config()->get('variant_state_draft_excluded') && $this->isDraftVariantState($state)) {
return true;
}
return false;
}
/**
* Check if a state array represents a draft variant
*
* @param array $state
* @return bool
*/
private function isDraftVariantState(array $state): bool
{
$class = SearchVariantVersioned::class;
return isset($state[$class]) && $state[$class] == Versioned::DRAFT;
}
/**
* Used during search reindex
*
* This is considered the primary layer of protection
*
* @param DataObject $obj
* @return bool
*/
public function isIndexable(DataObject $obj): bool
{
return $this->isSearchable($obj, true);
}
/**
* Used when retrieving search results
*
* This is considered the secondary layer of protection
*
* It's important to still have this layer in conjuction with the index layer as non-searchable results may be
* in the search index because:
* a) they were added to the index pre-fulltextsearch 3.7 and a reindex to purge old records was never run, OR
* b) the DataObject has a non-deterministic canView() check such as `return $date <= $dateOfIndex;`
*
* @param DataObject $obj
* @return bool
*/
public function isViewable(DataObject $obj): bool
{
return $this->isSearchable($obj, false);
}
/**
* Checks and caches whether the given DataObject can be indexed. This is determined by two factors:
* - Whether the ShowInSearch property / getShowInSearch() method evaluates to true
* - Whether the canView method evaluates to true against an anonymous user (optional, can be disabled)
*
* @param DataObject $obj
* @param bool $indexing
* @return bool
*/
private function isSearchable(DataObject $obj, bool $indexing): bool
{
// check if is a valid DataObject that has been persisted to the database
if (is_null($obj) || !$obj->ID) {
return false;
}
$key = $this->getCacheKey($obj, $indexing);
if (isset($this->cache[$key])) {
return $this->cache[$key];
}
$value = true;
// ShowInSearch check
// This will also call $obj->getShowInSearch() if it exists
if (isset($obj->ShowInSearch) && !$obj->ShowInSearch) {
$value = false;
}
// canView() checker
if ($value) {
$objClass = $obj->getClassName();
if ($indexing) {
// Anonymous member canView() for indexing
if (!$this->classSkipsCanViewCheck($objClass)) {
$value = Member::actAs(null, function () use ($obj) {
return $obj->canView();
});
}
} else {
// Current member canView() check for retrieving search results
$value = $obj->canView();
}
}
$this->extend('updateIsSearchable', $obj, $indexing, $value);
$this->cache[$key] = $value;
return $value;
}
/**
* @param DataObject $obj
* @param bool $indexing
* @return string
*/
private function getCacheKey(DataObject $obj, bool $indexing): string
{
$type = $indexing ? 'indexing' : 'viewing';
// getUniqueKey() requires silverstripe/framework 4.6
$uniqueKey = '';
if (method_exists($obj, 'getUniqueKey')) {
try {
$uniqueKey = $obj->getUniqueKey();
} catch (\Exception $e) {
$uniqueKey = '';
}
}
if (!$uniqueKey) {
$uniqueKey = sprintf('%s-%s', $obj->ClassName, $obj->ID);
}
$key = sprintf('%s-%s', $type, $uniqueKey);
$this->extend('updateCacheKey', $obj, $indexing, $key);
return $key;
}
/**
* @param string $class
* @return bool
*/
private function classSkipsCanViewCheck(string $class): bool
{
$skipClasses = self::config()->get('indexing_canview_exclude_classes') ?? [];
if (empty($skipClasses)) {
return false;
}
if (in_array($class, $skipClasses)) {
return true;
}
foreach ($skipClasses as $skipClass) {
if (in_array($skipClass, class_parents($class))) {
return true;
}
}
return false;
}
}

View File

@ -9,7 +9,7 @@ use SilverStripe\Forms\FormAction;
use SilverStripe\Forms\TextField;
use SilverStripe\FullTextSearch\Search\FullTextSearch;
use SilverStripe\FullTextSearch\Search\Queries\SearchQuery;
use SilverStripe\FullTextSearch\Search\Services\IndexableService;
use SilverStripe\FullTextSearch\Search\Services\SearchableService;
use SilverStripe\FullTextSearch\Solr\SolrIndex;
use SilverStripe\ORM\DataObject;
use SilverStripe\View\ArrayData;
@ -83,13 +83,13 @@ class SearchForm extends Form
$index = $indexClass::singleton();
$results = $index->search($query, -1, -1, $params);
$indexableService = IndexableService::singleton();
$searchableService = SearchableService::singleton();
// filter by permission
if ($results) {
foreach ($results->Matches as $match) {
/** @var DataObject $match */
if (!$indexableService->isIndexable($match)) {
if (!$searchableService->isViewable($match)) {
$results->Matches->remove($match);
}
}

View File

@ -4,7 +4,8 @@ namespace SilverStripe\FullTextSearch\Solr\Reindex\Handlers;
use Psr\Log\LoggerInterface;
use SilverStripe\Core\Environment;
use SilverStripe\FullTextSearch\Search\Services\IndexableService;
use SilverStripe\FullTextSearch\Search\Services\SearchableService;
use SilverStripe\FullTextSearch\Search\Variants\SearchVariantVersioned;
use SilverStripe\FullTextSearch\Solr\Solr;
use SilverStripe\FullTextSearch\Solr\SolrIndex;
use SilverStripe\FullTextSearch\Search\Variants\SearchVariant;
@ -12,6 +13,7 @@ use SilverStripe\FullTextSearch\Search\Queries\SearchQuery;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\DataList;
use SilverStripe\ORM\DB;
use SilverStripe\Versioned\Versioned;
/**
* Base class for re-indexing of solr content
@ -41,6 +43,8 @@ abstract class SolrReindexBase implements SolrReindexHandler
$taskName,
$classes = null
) {
$searchableService = SearchableService::singleton();
// Filter classes for this index
$indexClasses = $this->getClassesForIndex($indexInstance, $classes);
@ -53,7 +57,9 @@ abstract class SolrReindexBase implements SolrReindexHandler
$includeSubclasses = $options['include_children'];
foreach (SearchVariant::reindex_states($class, $includeSubclasses) as $state) {
$this->processVariant($logger, $indexInstance, $state, $class, $includeSubclasses, $batchSize, $taskName);
if (!$searchableService->variantStateExcluded($state)) {
$this->processVariant($logger, $indexInstance, $state, $class, $includeSubclasses, $batchSize, $taskName);
}
}
}
}
@ -238,13 +244,12 @@ abstract class SolrReindexBase implements SolrReindexHandler
$items = $items->filter('ClassName', $class);
}
$indexableService = IndexableService::singleton();
$searchableService = SearchableService::singleton();
// ShowInSearch filter
// we cannot use $items->remove($item), as that deletes the record from the database
// Filter out objects that must not be indexed
$idsToRemove = [];
foreach ($items as $item) {
if (!$indexableService->isIndexable($item)) {
if (!$searchableService->isIndexable($item)) {
$idsToRemove[] = $item->ID;
}
}

View File

@ -9,7 +9,7 @@ use SilverStripe\FullTextSearch\Search\Indexes\SearchIndex;
use SilverStripe\FullTextSearch\Search\Queries\SearchQuery;
use SilverStripe\FullTextSearch\Search\Queries\SearchQuery_Range;
use SilverStripe\FullTextSearch\Search\SearchIntrospection;
use SilverStripe\FullTextSearch\Search\Services\IndexableService;
use SilverStripe\FullTextSearch\Search\Services\SearchableService;
use SilverStripe\FullTextSearch\Search\Variants\SearchVariant;
use SilverStripe\FullTextSearch\Search\Variants\SearchVariant_Caller;
use SilverStripe\FullTextSearch\Solr\Services\SolrService;
@ -778,15 +778,14 @@ abstract class SolrIndex extends SearchIndex
\Apache_Solr_Service::METHOD_POST
);
$indexableService = IndexableService::singleton();
$searchableService = SearchableService::singleton();
$results = new ArrayList();
if ($res->getHttpStatus() >= 200 && $res->getHttpStatus() < 300) {
foreach ($res->response->docs as $doc) {
$result = DataObject::get_by_id($doc->ClassName, $doc->ID);
if ($result) {
// Filter out any results previously added to the solr index where ShowInSearch == false
if (!$indexableService->isIndexable($result)) {
if (!$searchableService->isViewable($result)) {
continue;
}

View File

@ -7,6 +7,7 @@ use SilverStripe\Core\Config\Config;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\FullTextSearch\Search\FullTextSearch;
use SilverStripe\FullTextSearch\Search\Services\SearchableService;
use SilverStripe\FullTextSearch\Tests\BatchedProcessorTest\BatchedProcessor_QueuedJobService;
use SilverStripe\FullTextSearch\Tests\BatchedProcessorTest\BatchedProcessorTest_Index;
use SilverStripe\FullTextSearch\Tests\BatchedProcessorTest\BatchedProcessorTest_Object;
@ -120,6 +121,9 @@ class BatchedProcessorTest extends SapphireTest
*/
public function testBatching()
{
Config::modify()->set(SearchableService::class, 'indexing_canview_exclude_classes', [SiteTree::class]);
Config::modify()->set(SearchableService::class, 'variant_state_draft_excluded', false);
$index = singleton(BatchedProcessorTest_Index::class);
$index->reset();
$processor = $this->generateDirtyIds();

View File

@ -1,55 +0,0 @@
<?php
namespace SilverStripe\FullTextSearch\Tests;
use SilverStripe\CMS\Model\SiteTree;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\FullTextSearch\Search\Services\IndexableService;
class IndexableServiceTest extends SapphireTest
{
protected $usesDatabase = true;
public function setup()
{
parent::setup();
IndexableService::singleton()->clearCache();
}
public function testIsIndexable()
{
$indexableService = IndexableService::singleton();
$page = SiteTree::create();
$page->CanViewType = 'Anyone';
$page->ShowInSearch = 1;
$page->write();
$this->assertTrue($indexableService->isIndexable($page));
$page = SiteTree::create();
$page->CanViewType = 'Anyone';
$page->ShowInSearch = 0;
$page->write();
$this->assertFalse($indexableService->isIndexable($page));
}
public function testClearCache()
{
$indexableService = IndexableService::singleton();
$page = SiteTree::create();
$page->CanViewType = 'Anyone';
$page->ShowInSearch = 0;
$page->write();
$this->assertFalse($indexableService->isIndexable($page));
// test the results are cached (expect stale result)
$page->ShowInSearch = 1;
$page->write();
$this->assertFalse($indexableService->isIndexable($page));
// after clearing cache, expect fresh result
$indexableService->clearCache();
$this->assertTrue($indexableService->isIndexable($page));
}
}

View File

@ -8,6 +8,7 @@ use SilverStripe\Dev\SapphireTest;
use SilverStripe\FullTextSearch\Search\FullTextSearch;
use SilverStripe\FullTextSearch\Search\Processors\SearchUpdateProcessor;
use SilverStripe\FullTextSearch\Search\Processors\SearchUpdateImmediateProcessor;
use SilverStripe\FullTextSearch\Search\Services\SearchableService;
use SilverStripe\FullTextSearch\Search\Updaters\SearchUpdater;
use SilverStripe\FullTextSearch\Tests\SearchUpdaterTest\SearchUpdaterTest_Container;
use SilverStripe\FullTextSearch\Tests\SearchUpdaterTest\SearchUpdaterTest_HasOne;
@ -49,6 +50,9 @@ class SearchUpdaterTest extends SapphireTest
public function testHasOneHook()
{
$classesToSkip = [SearchUpdaterTest_Container::class];
Config::modify()->set(SearchableService::class, 'indexing_canview_exclude_classes', $classesToSkip);
$hasOne = new SearchUpdaterTest_HasOne();
$hasOne->write();
@ -127,6 +131,9 @@ class SearchUpdaterTest extends SapphireTest
public function testHasManyHook()
{
$classesToSkip = [SearchUpdaterTest_Container::class];
Config::modify()->set(SearchableService::class, 'indexing_canview_exclude_classes', $classesToSkip);
$container1 = new SearchUpdaterTest_Container();
$container1->write();

View File

@ -7,6 +7,7 @@ use SilverStripe\CMS\Model\SiteTree;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\FullTextSearch\Search\FullTextSearch;
use SilverStripe\FullTextSearch\Search\Indexes\SearchIndex_Recording;
use SilverStripe\FullTextSearch\Search\Services\SearchableService;
use SilverStripe\FullTextSearch\Search\Variants\SearchVariantVersioned;
use SilverStripe\FullTextSearch\Tests\SearchVariantVersionedTest\SearchVariantVersionedTest_Index;
use SilverStripe\FullTextSearch\Tests\SearchVariantVersionedTest\SearchVariantVersionedTest_Item;
@ -45,6 +46,9 @@ class SearchVariantVersionedTest extends SapphireTest
public function testPublishing()
{
// Check that write updates Stage
$classesToSkip = [SearchVariantVersionedTest_Item::class];
Config::modify()->set(SearchableService::class, 'indexing_canview_exclude_classes', $classesToSkip);
Config::modify()->set(SearchableService::class, 'variant_state_draft_excluded', false);
$item = new SearchVariantVersionedTest_Item(array('TestText' => 'Foo'));
$item->write();

View File

@ -0,0 +1,120 @@
<?php
namespace SilverStripe\FullTextSearch\Tests;
use SilverStripe\CMS\Model\SiteTree;
use SilverStripe\Core\Config\Config;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\FullTextSearch\Search\Services\SearchableService;
use SilverStripe\FullTextSearch\Search\Variants\SearchVariantVersioned;
use SilverStripe\Security\Member;
use SilverStripe\Versioned\Versioned;
class SearchableServiceTest extends SapphireTest
{
protected $usesDatabase = true;
public function setup()
{
parent::setup();
SearchableService::singleton()->clearCache();
}
public function testIsIndexable()
{
Versioned::set_draft_site_secured(false);
Versioned::set_reading_mode('Stage.' . Versioned::DRAFT);
Config::modify()->set(SearchableService::class, 'indexing_canview_exclude_classes', [SiteTree::class]);
Member::actAs(null, function () {
$searchableService = SearchableService::singleton();
$page = SiteTree::create();
$page->CanViewType = 'Anyone';
$page->ShowInSearch = 1;
$page->write();
$this->assertTrue($searchableService->isIndexable($page));
$page = SiteTree::create();
$page->CanViewType = 'Anyone';
$page->ShowInSearch = 0;
$page->write();
$this->assertFalse($searchableService->isIndexable($page));
});
}
public function testIsViewable()
{
Versioned::set_draft_site_secured(false);
Versioned::set_reading_mode('Stage.' . Versioned::DRAFT);
Member::actAs(null, function () {
$searchableService = SearchableService::singleton();
$page = SiteTree::create();
$page->CanViewType = 'Anyone';
$page->ShowInSearch = 1;
$page->write();
$this->assertTrue($searchableService->isViewable($page));
$page = SiteTree::create();
$page->CanViewType = 'LoggedInUsers';
$page->ShowInSearch = 1;
$page->write();
$this->assertFalse($searchableService->isViewable($page));
});
}
public function testClearCache()
{
Config::modify()->set(SearchableService::class, 'indexing_canview_exclude_classes', [SiteTree::class]);
$searchableService = SearchableService::singleton();
$page = SiteTree::create();
$page->CanViewType = 'Anyone';
$page->ShowInSearch = 0;
$page->write();
$this->assertFalse($searchableService->isIndexable($page));
// test the results are cached (expect stale result)
$page->ShowInSearch = 1;
$page->write();
$this->assertFalse($searchableService->isIndexable($page));
// after clearing cache, expect fresh result
$searchableService->clearCache();
$this->assertTrue($searchableService->isIndexable($page));
}
public function testSkipIndexingCanViewCheck()
{
$searchableService = SearchableService::singleton();
$page = SiteTree::create();
$page->CanViewType = 'LoggedInUsers';
$page->ShowInSearch = 1;
$page->write();
$this->assertFalse($searchableService->isIndexable($page));
Config::modify()->set(SearchableService::class, 'indexing_canview_exclude_classes', [SiteTree::class]);
$searchableService->clearCache();
$this->assertTrue($searchableService->isIndexable($page));
}
public function testVariantStateExcluded()
{
$searchableService = SearchableService::singleton();
$variantStateDraft = [SearchVariantVersioned::class => Versioned::DRAFT];
$variantStateLive = [SearchVariantVersioned::class => Versioned::LIVE];
// default variant_state_draft_excluded = true
$this->assertTrue($searchableService->variantStateExcluded($variantStateDraft));
$this->assertFalse($searchableService->variantStateExcluded($variantStateLive));
Config::modify()->set(SearchableService::class, 'variant_state_draft_excluded', false);
$this->assertFalse($searchableService->variantStateExcluded($variantStateDraft));
$this->assertFalse($searchableService->variantStateExcluded($variantStateLive));
}
}

View File

@ -13,6 +13,7 @@ use SilverStripe\Dev\SapphireTest;
use SilverStripe\FullTextSearch\Search\FullTextSearch;
use SilverStripe\FullTextSearch\Search\Processors\SearchUpdateImmediateProcessor;
use SilverStripe\FullTextSearch\Search\Processors\SearchUpdateProcessor;
use SilverStripe\FullTextSearch\Search\Services\SearchableService;
use SilverStripe\FullTextSearch\Search\Updaters\SearchUpdater;
use SilverStripe\FullTextSearch\Search\Variants\SearchVariantSubsites;
use SilverStripe\FullTextSearch\Solr\Services\Solr4Service;
@ -107,6 +108,10 @@ class SolrIndexSubsitesTest extends SapphireTest
public function testPublishing()
{
$classesToSkip = [SiteTree::class, File::class];
Config::modify()->set(SearchableService::class, 'indexing_canview_exclude_classes', $classesToSkip);
Config::modify()->set(SearchableService::class, 'variant_state_draft_excluded', false);
// Setup mocks
$serviceMock = $this->getServiceMock();
self::$index->setService($serviceMock);

View File

@ -4,7 +4,6 @@ namespace SilverStripe\FullTextSearch\Tests;
use Apache_Solr_Document;
use Page;
use SebastianBergmann\Version;
use SilverStripe\Assets\File;
use SilverStripe\CMS\Model\SiteTree;
use SilverStripe\Core\Config\Config;
@ -14,10 +13,9 @@ use SilverStripe\Core\Kernel;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\FullTextSearch\Search\FullTextSearch;
use SilverStripe\FullTextSearch\Search\Queries\SearchQuery;
use SilverStripe\FullTextSearch\Search\Services\IndexableService;
use SilverStripe\FullTextSearch\Search\Services\SearchableService;
use SilverStripe\FullTextSearch\Search\Updaters\SearchUpdater;
use SilverStripe\FullTextSearch\Search\Variants\SearchVariantSubsites;
use SilverStripe\FullTextSearch\Solr\SolrIndex;
use SilverStripe\FullTextSearch\Solr\Services\Solr3Service;
use SilverStripe\FullTextSearch\Solr\Services\Solr4Service;
use SilverStripe\FullTextSearch\Tests\SearchUpdaterTest\SearchUpdaterTest_Container;
@ -34,7 +32,6 @@ use SilverStripe\FullTextSearch\Tests\SolrIndexTest\SolrIndexTest_ShowInSearchIn
use SilverStripe\FullTextSearch\Tests\SolrIndexTest\SolrIndexTest_MyPage;
use SilverStripe\FullTextSearch\Tests\SolrIndexTest\SolrIndexTest_MyDataObjectOne;
use SilverStripe\FullTextSearch\Tests\SolrIndexTest\SolrIndexTest_MyDataObjectTwo;
use SilverStripe\ORM\DataObject;
use SilverStripe\Subsites\Model\Subsite;
use SilverStripe\Versioned\Versioned;
@ -409,8 +406,10 @@ class SolrIndexTest extends SapphireTest
*/
public function testShowInSearch()
{
$defaultMode = Versioned::get_reading_mode();
// allow anonymous users to assess draft-only content to pass canView() check (will auto-reset for next test)
Versioned::set_draft_site_secured(false);
Versioned::set_reading_mode('Stage.' . Versioned::DRAFT);
Config::modify()->set(SearchableService::class, 'variant_state_draft_excluded', false);
$serviceMock = $this->getMockBuilder(Solr4Service::class)
->setMethods(['addDocument', 'deleteById'])
@ -508,10 +507,106 @@ class SolrIndexTest extends SapphireTest
})]
);
IndexableService::singleton()->clearCache();
SearchableService::singleton()->clearCache();
SearchUpdater::flush_dirty_indexes();
}
/**
* Test that canView() check is used to exclude DataObjects from being added to the index
*
* Note: this code path that really being tested here is SearchUpdateProcessor->prepareIndexes()
* This code path is used for 'inlet' filtering on CMS->save()
* The results of this will show-up in SolrIndex->_addAs()
*/
public function testCanView()
{
// allow anonymous users to assess draft-only content to pass canView() check (will auto-reset for next test)
Versioned::set_draft_site_secured(false);
Versioned::set_reading_mode('Stage.' . Versioned::DRAFT);
Config::modify()->set(SearchableService::class, 'variant_state_draft_excluded', false);
$serviceMock = $this->getMockBuilder(Solr4Service::class)
->setMethods(['addDocument', 'deleteById'])
->getMock();
$index = new SolrIndexTest_ShowInSearchIndex();
$index->setService($serviceMock);
FullTextSearch::force_index_list($index);
// will get added
$pageA = new Page();
$pageA->Title = 'Test Page Anyone';
$pageA->CanViewType = 'Anyone';
$pageA->write();
// will get filtered out
$page = new Page();
$page->Title = 'Test Page LoggedInUsers';
$page->CanViewType = 'LoggedInUsers';
$page->write();
// will get added
$fileA = new File();
$fileA->Title = 'Test File Anyone';
$fileA->CanViewType = 'Anyone';
$fileA->write();
// will get filtered out
$file = new File();
$file->Title = 'Test File LoggedInUsers';
$file->CanViewType = 'LoggedInUsers';
$file->write();
// will get added
$objOneA = new SolrIndexTest_MyDataObjectOne();
$objOneA->Title = 'Test MyDataObjectOne true';
$objOneA->ShowInSearch = true;
$objOneA->CanViewValue = true;
$objOneA->write();
// will get filtered out
$objOne = new SolrIndexTest_MyDataObjectOne();
$objOne->Title = 'Test MyDataObjectOne false';
$objOne->ShowInSearch = true;
$objOne->CanViewValue = false;
$objOne->write();
$callback = function (Apache_Solr_Document $doc) use ($pageA, $fileA, $objOneA): bool {
$validKeys = [
Page::class . $pageA->ID,
File::class . $fileA->ID,
SolrIndexTest_MyDataObjectOne::class . $objOneA->ID
];
return in_array($this->createSolrDocKey($doc), $validKeys);
};
$serviceMock
->expects($this->exactly(3))
->method('addDocument')
->withConsecutive(
[$this->callback($callback)],
[$this->callback($callback)],
[$this->callback($callback)]
);
// This is what actually triggers all the solr stuff
SearchUpdater::flush_dirty_indexes();
Versioned::set_reading_mode($defaultMode);
// delete a solr doc by setting ShowInSearch to false
$pageA->ShowInSearch = false;
$pageA->write();
$serviceMock
->expects($this->exactly(1))
->method('deleteById')
->withConsecutive(
[$this->callback(function (string $docID) use ($pageA): bool {
return strpos($docID, $pageA->ID . '-' . SiteTree::class) !== false;
})]
);
SearchableService::singleton()->clearCache();
SearchUpdater::flush_dirty_indexes();
}
protected function createSolrDocKey(Apache_Solr_Document $doc)

View File

@ -9,8 +9,14 @@ class SolrIndexTest_MyDataObjectOne extends DataObject implements TestOnly
{
private static $db = [
'Title' => 'Varchar(255)',
'ShowInSearch' => 'Boolean'
'ShowInSearch' => 'Boolean',
'CanViewValue' => 'Boolean(true)',
];
private static $table_name = 'SolrIndexTestMyDataObjectOne';
public function canView($member = null)
{
return $this->CanViewValue;
}
}

View File

@ -6,6 +6,7 @@ use Apache_Solr_Document;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\Core\Config\Config;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\FullTextSearch\Search\Services\SearchableService;
use SilverStripe\ORM\DataObject;
use SilverStripe\FullTextSearch\Search\FullTextSearch;
use SilverStripe\FullTextSearch\Search\SearchIntrospection;
@ -112,6 +113,10 @@ class SolrIndexVersionedTest extends SapphireTest
public function testPublishing()
{
$classesToSkip = [SearchVariantVersionedTest_Item::class, SolrIndexVersionedTest_Object::class];
Config::modify()->set(SearchableService::class, 'indexing_canview_exclude_classes', $classesToSkip);
Config::modify()->set(SearchableService::class, 'variant_state_draft_excluded', false);
// Check that write updates Stage
Versioned::set_stage(Versioned::DRAFT);
@ -166,6 +171,10 @@ class SolrIndexVersionedTest extends SapphireTest
public function testDelete()
{
$classesToSkip = [SearchVariantVersionedTest_Item::class];
Config::modify()->set(SearchableService::class, 'indexing_canview_exclude_classes', $classesToSkip);
Config::modify()->set(SearchableService::class, 'variant_state_draft_excluded', false);
// Delete the live record (not the stage)
Versioned::set_stage(Versioned::DRAFT);

View File

@ -6,12 +6,14 @@ use SilverStripe\Core\Config\Config;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\FullTextSearch\Search\FullTextSearch;
use SilverStripe\FullTextSearch\Search\Services\SearchableService;
use SilverStripe\FullTextSearch\Solr\Reindex\Handlers\SolrReindexHandler;
use SilverStripe\FullTextSearch\Solr\Reindex\Handlers\SolrReindexQueuedHandler;
use SilverStripe\FullTextSearch\Solr\Reindex\Jobs\SolrReindexGroupQueuedJob;
use SilverStripe\FullTextSearch\Solr\Reindex\Jobs\SolrReindexQueuedJob;
use SilverStripe\FullTextSearch\Solr\Services\Solr4Service;
use SilverStripe\FullTextSearch\Solr\Services\SolrService;
use SilverStripe\FullTextSearch\Tests\SearchVariantVersionedTest\SearchVariantVersionedTest_Item;
use SilverStripe\FullTextSearch\Tests\SolrReindexQueuedTest\SolrReindexQueuedTest_Service;
use SilverStripe\FullTextSearch\Tests\SolrReindexTest\SolrReindexTest_Index;
use SilverStripe\FullTextSearch\Tests\SolrReindexTest\SolrReindexTest_Item;
@ -135,6 +137,9 @@ class SolrReindexQueuedTest extends SapphireTest
*/
public function testReindexSegmentsGroups()
{
$classesToSkip = [SolrReindexTest_Item::class];
Config::modify()->set(SearchableService::class, 'indexing_canview_exclude_classes', $classesToSkip);
$this->createDummyData(18);
// Deletes are performed in the main task prior to individual groups being processed
@ -194,6 +199,9 @@ class SolrReindexQueuedTest extends SapphireTest
*/
public function testRunGroup()
{
$classesToSkip = [SolrReindexTest_Item::class];
Config::modify()->set(SearchableService::class, 'indexing_canview_exclude_classes', $classesToSkip);
$this->createDummyData(18);
// Just do what the SolrReindexQueuedJob would do to create each sub

View File

@ -10,6 +10,7 @@ use SilverStripe\Core\Config\Config;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\FullTextSearch\Search\FullTextSearch;
use SilverStripe\FullTextSearch\Search\Services\SearchableService;
use SilverStripe\FullTextSearch\Search\Updaters\SearchUpdater;
use SilverStripe\FullTextSearch\Search\Variants\SearchVariant;
use SilverStripe\FullTextSearch\Search\Variants\SearchVariantVersioned;
@ -18,6 +19,7 @@ use SilverStripe\FullTextSearch\Solr\Reindex\Handlers\SolrReindexImmediateHandle
use SilverStripe\FullTextSearch\Solr\Services\Solr4Service;
use SilverStripe\FullTextSearch\Solr\Services\SolrService;
use SilverStripe\FullTextSearch\Solr\Tasks\Solr_Reindex;
use SilverStripe\FullTextSearch\Tests\SearchVariantVersionedTest\SearchVariantVersionedTest_Item;
use SilverStripe\FullTextSearch\Tests\SolrIndexTest\SolrIndexTest_MyDataObjectOne;
use SilverStripe\FullTextSearch\Tests\SolrIndexTest\SolrIndexTest_MyDataObjectTwo;
use SilverStripe\FullTextSearch\Tests\SolrIndexTest\SolrIndexTest_MyPage;
@ -235,6 +237,9 @@ class SolrReindexTest extends SapphireTest
*/
public function testRunGroup()
{
$classesToSkip = [SolrReindexTest_Item::class];
Config::modify()->set(SearchableService::class, 'indexing_canview_exclude_classes', $classesToSkip);
$this->service->method('deleteByQuery')
->with('+(ClassHierarchy:' . SolrReindexTest_Item::class . ') +_query_:"{!frange l=2 u=2}mod(ID, 6)" +(_testvariant:"1")');
@ -266,6 +271,9 @@ class SolrReindexTest extends SapphireTest
*/
public function testRunAllGroups()
{
$classesToSkip = [SolrReindexTest_Item::class];
Config::modify()->set(SearchableService::class, 'indexing_canview_exclude_classes', $classesToSkip);
$this->service->method('deleteByQuery')
->withConsecutive(
['+(ClassHierarchy:' . SolrReindexTest_Item::class . ') +_query_:"{!frange l=0 u=0}mod(ID, 6)" +(_testvariant:"1")'],
@ -305,8 +313,10 @@ class SolrReindexTest extends SapphireTest
*/
public function testShowInSearch()
{
$defaultMode = Versioned::get_reading_mode();
// allow anonymous users to assess draft-only content to pass canView() check (will auto-reset for next test)
Versioned::set_draft_site_secured(false);
Versioned::set_reading_mode('Stage.' . Versioned::DRAFT);
Config::modify()->set(SearchableService::class, 'variant_state_draft_excluded', false);
// will get added
$pageA = new Page();
@ -394,8 +404,89 @@ class SolrReindexTest extends SapphireTest
$handler->runGroup($logger, $index, $state, SiteTree::class, 1, 0);
$handler->runGroup($logger, $index, $state, File::class, 1, 0);
$handler->runGroup($logger, $index, $state, SolrIndexTest_MyDataObjectOne::class, 1, 0);
}
Versioned::set_reading_mode($defaultMode);
/**
* Test that CanView filtering is working correctly
*/
public function testCanView()
{
// allow anonymous users to assess draft-only content to pass canView() check (will auto-reset for next test)
Versioned::set_draft_site_secured(false);
Versioned::set_reading_mode('Stage.' . Versioned::DRAFT);
Config::modify()->set(SearchableService::class, 'variant_state_draft_excluded', false);
// will get added
$pageA = new Page();
$pageA->Title = 'Test Page Anyone';
$pageA->CanViewType = 'Anyone';
$pageA->write();
// will get filtered out
$page = new Page();
$page->Title = 'Test Page LoggedInUsers';
$page->CanViewType = 'LoggedInUsers';
$page->write();
// will get added
$fileA = new File();
$fileA->Title = 'Test File Anyone';
$fileA->CanViewType = 'Anyone';
$fileA->write();
// will get filtered out
$file = new File();
$file->Title = 'Test File LoggedInUsers';
$file->CanViewType = 'LoggedInUsers';
$file->write();
// will get added
$objOneA = new SolrIndexTest_MyDataObjectOne();
$objOneA->Title = 'Test MyDataObjectOne true';
$objOneA->CanViewValue = true;
$objOneA->ShowInSearch = true;
$objOneA->write();
// will get filtered out
$objOne = new SolrIndexTest_MyDataObjectOne();
$objOne->Title = 'Test MyDataObjectOne false';
$objOne->CanViewValue = false;
$objOneA->ShowInSearch = true;
$objOne->write();
$serviceMock = $this->getMockBuilder(Solr4Service::class)
->setMethods(['addDocument', 'deleteByQuery'])
->getMock();
$index = new SolrIndexTest_ShowInSearchIndex();
$index->setService($serviceMock);
FullTextSearch::force_index_list($index);
$callback = function (Apache_Solr_Document $doc) use ($pageA, $fileA, $objOneA): bool {
$validKeys = [
Page::class . $pageA->ID,
File::class . $fileA->ID,
SolrIndexTest_MyDataObjectOne::class . $objOneA->ID,
];
$solrDocKey = $this->createSolrDocKey($doc);
return in_array($this->createSolrDocKey($doc), $validKeys);
};
$serviceMock
->expects($this->exactly(3))
->method('addDocument')
->withConsecutive(
[$this->callback($callback)],
[$this->callback($callback)],
[$this->callback($callback)]
);
$logger = new SolrReindexTest_RecordingLogger();
$state = [SearchVariantVersioned::class => Versioned::DRAFT];
$handler = Injector::inst()->get(SolrReindexImmediateHandler::class);
$handler->runGroup($logger, $index, $state, SiteTree::class, 1, 0);
$handler->runGroup($logger, $index, $state, File::class, 1, 0);
$handler->runGroup($logger, $index, $state, SolrIndexTest_MyDataObjectOne::class, 1, 0);
}
protected function createSolrDocKey(Apache_Solr_Document $doc)