From 48a83964add93327051fa73730bcf245808b1f8d Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Wed, 31 Dec 2025 22:23:28 +0000 Subject: [PATCH 01/14] VUFIND-1210: Use Solr JSON request API --- .../VuFind/Hierarchy/TreeDataSource/Solr.php | 2 + .../src/VuFind/RecordDriver/SolrDefault.php | 4 +- .../Factory/AbstractSolrBackendFactory.php | 6 +- .../Solr/InjectHighlightingListener.php | 10 +- .../VuFind/src/VuFind/Search/Solr/Params.php | 25 ++- .../src/VuFindSearch/Backend/Solr/Backend.php | 76 ++++--- .../VuFindSearch/Backend/Solr/Connector.php | 33 ++- .../Backend/Solr/QueryBuilder.php | 20 +- .../src/VuFindSearch/ParamBag.php | 38 ++-- .../src/VuFindSearch/ParamBagBag.php | 206 ++++++++++++++++++ 10 files changed, 319 insertions(+), 101 deletions(-) create mode 100644 module/VuFindSearch/src/VuFindSearch/ParamBagBag.php diff --git a/module/VuFind/src/VuFind/Hierarchy/TreeDataSource/Solr.php b/module/VuFind/src/VuFind/Hierarchy/TreeDataSource/Solr.php index ac952e829d4..54c45992768 100644 --- a/module/VuFind/src/VuFind/Hierarchy/TreeDataSource/Solr.php +++ b/module/VuFind/src/VuFind/Hierarchy/TreeDataSource/Solr.php @@ -151,6 +151,7 @@ public function getXML($id, $options = []) */ protected function getDefaultSearchParams(): array { + // Needs adjustment return [ 'fq' => $this->filters, 'hl' => ['false'], @@ -199,6 +200,7 @@ protected function searchSolrCursor(Query $query, $rows): array $records = []; while ($cursorMark !== $prevCursorMark) { $params = new ParamBag( + // Needs adjustment $this->getDefaultSearchParams() + [ // Sort is required 'sort' => ['id asc'], diff --git a/module/VuFind/src/VuFind/RecordDriver/SolrDefault.php b/module/VuFind/src/VuFind/RecordDriver/SolrDefault.php index 645f680397a..3aa21fdf71c 100644 --- a/module/VuFind/src/VuFind/RecordDriver/SolrDefault.php +++ b/module/VuFind/src/VuFind/RecordDriver/SolrDefault.php @@ -33,6 +33,8 @@ namespace VuFind\RecordDriver; use VuFindSearch\Command\SearchCommand; +use VuFindSearch\ParamBag; +use VuFindSearch\ParamBagBag; use function count; use function in_array; @@ -317,7 +319,7 @@ public function getChildRecordCount() 'hierarchy_parent_id:"' . $safeId . '"' ); // Disable highlighting for efficiency; not needed here: - $params = new \VuFindSearch\ParamBag(['hl' => ['false']]); + $params = new ParamBagBag(['params' => new ParamBag(['hl' => ['false']])]); $command = new SearchCommand($this->sourceIdentifier, $query, 0, 0, $params); return $this->searchService ->invoke($command)->getResult()->getTotal(); diff --git a/module/VuFind/src/VuFind/Search/Factory/AbstractSolrBackendFactory.php b/module/VuFind/src/VuFind/Search/Factory/AbstractSolrBackendFactory.php index 3b819062e26..c9c2fa84201 100644 --- a/module/VuFind/src/VuFind/Search/Factory/AbstractSolrBackendFactory.php +++ b/module/VuFind/src/VuFind/Search/Factory/AbstractSolrBackendFactory.php @@ -494,8 +494,8 @@ protected function createConnector() $handlers = [ 'select' => [ 'fallback' => true, - 'defaults' => ['fl' => $defaultFields], - 'appends' => ['fq' => []], + 'defaults' => ['fields' => $defaultFields], + 'appends' => ['filter' => []], ], 'terms' => [ 'functions' => ['terms'], @@ -503,7 +503,7 @@ protected function createConnector() ]; foreach ($this->getHiddenFilters() as $filter) { - array_push($handlers['select']['appends']['fq'], $filter); + array_push($handlers['select']['appends']['filter'], $filter); } $connector = new $this->connectorClass( diff --git a/module/VuFind/src/VuFind/Search/Solr/InjectHighlightingListener.php b/module/VuFind/src/VuFind/Search/Solr/InjectHighlightingListener.php index 0e286f545c2..05de9eb581d 100644 --- a/module/VuFind/src/VuFind/Search/Solr/InjectHighlightingListener.php +++ b/module/VuFind/src/VuFind/Search/Solr/InjectHighlightingListener.php @@ -32,6 +32,7 @@ use Laminas\EventManager\EventInterface; use Laminas\EventManager\SharedEventManagerInterface; use VuFindSearch\Backend\BackendInterface; +use VuFindSearch\ParamBagBag; use VuFindSearch\Service; /** @@ -120,8 +121,9 @@ public function onSearchPre(EventInterface $event) } if ($command->getTargetIdentifier() === $this->backend->getIdentifier()) { if ($params = $command->getSearchParameters()) { + $params = ParamBagBag::from($params); // Set highlighting parameters unless explicitly disabled: - $hl = $params->get('hl'); + $hl = $params->getNested('params', 'hl'); if (($hl[0] ?? 'true') != 'false') { $this->active = true; // Set extra parameters first so they don't override necessary @@ -129,9 +131,9 @@ public function onSearchPre(EventInterface $event) foreach ($this->extraHighlightingParameters as $key => $val) { $params->set($key, $val); } - $params->set('hl', 'true'); - $params->set('hl.simple.pre', '{{{{START_HILITE}}}}'); - $params->set('hl.simple.post', '{{{{END_HILITE}}}}'); + $params->setNested('params', 'hl', 'true'); + $params->setNested('params', 'hl.simple.pre', '{{{{START_HILITE}}}}'); + $params->setNested('params', 'hl.simple.post', '{{{{END_HILITE}}}}'); // Turn on hl.q generation in query builder: $this->backend->getQueryBuilder() diff --git a/module/VuFind/src/VuFind/Search/Solr/Params.php b/module/VuFind/src/VuFind/Search/Solr/Params.php index f4136e8942d..1f1cd240b86 100644 --- a/module/VuFind/src/VuFind/Search/Solr/Params.php +++ b/module/VuFind/src/VuFind/Search/Solr/Params.php @@ -32,7 +32,7 @@ use VuFind\Config\Config; use VuFind\Config\ConfigManagerInterface; -use VuFindSearch\ParamBag; +use VuFindSearch\ParamBagBag; use function count; use function in_array; @@ -567,14 +567,15 @@ protected function normalizeSort($sort) /** * Create search backend parameters for advanced features. * - * @return ParamBag + * @return ParamBagBag */ public function getBackendParameters() { - $backendParams = new ParamBag(); + $backendParams = new ParamBagBag(); // Spellcheck - $backendParams->set( + $backendParams->setNested( + 'params', 'spellcheck', $this->getOptions()->spellcheckEnabled() ? 'true' : 'false' ); @@ -582,20 +583,20 @@ public function getBackendParameters() // Facets $facets = $this->getFacetSettings(); if (!empty($facets)) { - $backendParams->add('facet', 'true'); + $backendParams->addNested('params', 'facet', 'true'); foreach ($facets as $key => $value) { // prefix keys with "facet" unless they already have a "f." prefix: $fullKey = str_starts_with($key, 'f.') ? $key : "facet.$key"; - $backendParams->add($fullKey, $value); + $backendParams->addNested('params', $fullKey, $value); } - $backendParams->add('facet.mincount', 1); + $backendParams->addNested('params', 'facet.mincount', 1); } // Filters $filters = $this->getFilterSettings(); foreach ($filters as $filter) { - $backendParams->add('fq', $filter); + $backendParams->add('filter', $filter); } // Shards @@ -611,7 +612,7 @@ public function getBackendParameters() foreach ($shards as $current) { $selectedShards[$current] = $allShards[$current]; } - $backendParams->add('shards', implode(',', $selectedShards)); + $backendParams->addNested('params', 'shards', implode(',', $selectedShards)); } // Sort @@ -633,14 +634,14 @@ public function getBackendParameters() // Highlighting -- on by default, but we should disable if necessary: if (!$this->getOptions()->highlightEnabled()) { - $backendParams->add('hl', 'false'); + $backendParams->addNested('params', 'hl', 'false'); } // Pivot facets for visual results if ($pf = $this->getPivotFacets()) { - $backendParams->add('facet.pivot', $pf); - $backendParams->set('facet', 'true'); + $backendParams->addNested('params', 'facet.pivot', $pf); + $backendParams->setNested('params', 'facet', 'true'); } return $backendParams; diff --git a/module/VuFindSearch/src/VuFindSearch/Backend/Solr/Backend.php b/module/VuFindSearch/src/VuFindSearch/Backend/Solr/Backend.php index 6658f99708a..b51b791717d 100644 --- a/module/VuFindSearch/src/VuFindSearch/Backend/Solr/Backend.php +++ b/module/VuFindSearch/src/VuFindSearch/Backend/Solr/Backend.php @@ -41,6 +41,7 @@ use VuFindSearch\Feature\RetrieveBatchInterface; use VuFindSearch\Feature\SimilarInterface; use VuFindSearch\ParamBag; +use VuFindSearch\ParamBagBag; use VuFindSearch\Query\AbstractQuery; use VuFindSearch\Query\WorkKeysQuery; use VuFindSearch\Response\RecordCollectionFactoryInterface; @@ -135,6 +136,7 @@ public function search( $limit, ?ParamBag $params = null ) { + $params = ParamBagBag::from($params); if ($query instanceof WorkKeysQuery) { return $this->workKeysSearch($query, $offset, $limit, $params); } @@ -159,13 +161,14 @@ public function rawJsonSearch( AbstractQuery $query, $offset, $limit, - ?ParamBag $params = null + ?ParamBagBag $params = null ) { - $params = $params ?: new ParamBag(); + $params = $params ?: new ParamBagBag(); + $params = ParamBagBag::from($params); $this->injectResponseWriter($params); - $params->set('rows', $limit); - $params->set('start', $offset); + $params->set('limit', $limit); + $params->set('offset', $offset); $params->mergeWith($this->getQueryBuilder()->build($query, $params)); return $this->connector->search($params); } @@ -211,14 +214,14 @@ public function getIds( $params = $params ?: new ParamBag(); $this->injectResponseWriter($params); - $params->set('rows', $limit); - $params->set('start', $offset); - $flParts = [$this->getConnector()->getUniqueKey()]; - if ($fl = $params->get('fl')) { + $params->set('limit', $limit); + $params->set('offset', $offset); + $fieldParts = [$this->getConnector()->getUniqueKey()]; + if ($fields = $params->get('fields')) { // Merge multiple values if necessary, then split on delimiter: - $flParts = array_unique(array_merge($flParts, explode(',', implode(',', $fl)))); + $fieldParts = array_unique(array_merge($fieldParts, explode(',', implode(',', $fields)))); } - $params->set('fl', implode(',', $flParts)); + $params->set('fields', implode(',', $fieldParts)); $params->mergeWith($this->getQueryBuilder()->build($query)); $response = $this->connector->search($params); $collection = $this->createRecordCollection($response); @@ -261,7 +264,8 @@ public function random( */ public function retrieve($id, ?ParamBag $params = null) { - $params = $params ?: new ParamBag(); + $params = $params ?: new ParamBagBag(); + $params = ParamBagBag::from($params); $this->injectResponseWriter($params); $response = $this->connector->retrieve($id, $params); @@ -292,9 +296,9 @@ public function retrieveBatch($ids, ?ParamBag $params = null) while (count($ids) > 0) { $currentPage = array_splice($ids, 0, $this->pageSize, []); $currentPage = array_map($formatIds, $currentPage); - $params->set('q', 'id:(' . implode(' OR ', $currentPage) . ')'); - $params->set('start', 0); - $params->set('rows', $this->pageSize); + $params->set('query', 'id:(' . implode(' OR ', $currentPage) . ')'); + $params->set('offset', 0); + $params->set('limit', $this->pageSize); $this->injectResponseWriter($params); $next = $this->createRecordCollection( $this->connector->search($params) @@ -345,7 +349,7 @@ public function terms( string|ParamBag|null $field = null, ?string $start = null, ?int $limit = null, - ?ParamBag $params = null + ?ParamBagBag $params = null ) { // Support alternate syntax with ParamBag as first parameter: if ($field instanceof ParamBag && $params === null) { @@ -354,29 +358,30 @@ public function terms( } // Create empty ParamBag if none provided: - $params = $params ?: new ParamBag(); + $params = $params ?: new ParamBagBag(); + $params = ParamBagBag::from($params); $this->injectResponseWriter($params); // Always enable terms: - $params->set('terms', 'true'); + $params->setNested('params', 'terms', 'true'); // Use parameters if provided: if (null !== $field) { - $params->set('terms.fl', $field); + $params->setNested('params', 'terms.fl', $field); } if (null !== $start) { - $params->set('terms.lower', $start); + $params->setNested('params', 'terms.lower', $start); } if (null !== $limit) { - $params->set('terms.limit', $limit); + $params->setNested('params', 'terms.limit', $limit); } // Set defaults unless overridden: - if (!$params->hasParam('terms.lower.incl')) { - $params->set('terms.lower.incl', 'false'); + if (!$params->hasNestedParam('params', 'terms.lower.incl')) { + $params->setNested('params', 'terms.lower.incl', 'false'); } - if (!$params->hasParam('terms.sort')) { - $params->set('terms.sort', 'index'); + if (!$params->hasNestedParam('params', 'terms.sort')) { + $params->setNested('params', 'terms.sort', 'index'); } $response = $this->connector->terms($params); @@ -405,6 +410,7 @@ public function alphabeticBrowse( $params = null, $offsetDelta = 0 ) { + // Does alphabrowse also need to be converted? Custom request handler... $params = $params ?: new ParamBag(); $this->injectResponseWriter($params); @@ -607,26 +613,26 @@ protected function refineBrowseException(RemoteErrorException $e) * @throws InvalidArgumentException Response writer and named list * implementation already set to an incompatible type. */ - protected function injectResponseWriter(ParamBag $params) + protected function injectResponseWriter(ParamBagBag $params) { - if (array_diff($params->get('wt') ?: [], ['json'])) { + if (array_diff($params->getNested('params', 'wt') ?: [], ['json'])) { throw new InvalidArgumentException( sprintf( 'Invalid response writer type: %s', - implode(', ', $params->get('wt')) + implode(', ', $params->getNested('params', 'wt')) ) ); } - if (array_diff($params->get('json.nl') ?: [], ['arrarr'])) { + if (array_diff($params->getNested('params', 'json.nl') ?: [], ['arrarr'])) { throw new InvalidArgumentException( sprintf( 'Invalid named list implementation type: %s', - implode(', ', $params->get('json.nl')) + implode(', ', $params->getNested('params', 'json.nl')) ) ); } - $params->set('wt', ['json']); - $params->set('json.nl', ['arrarr']); + $params->setNested('params', 'wt', ['json']); + $params->setNested('params', 'json.nl', ['arrarr']); } /** @@ -660,12 +666,12 @@ protected function workKeysSearch( $params = $defaultParams ? clone $defaultParams : new \VuFindSearch\ParamBag(); $this->injectResponseWriter($params); - $params->set('q', "{!terms f=work_keys_str_mv separator=\"\u{001f}\"}" . implode("\u{001f}", $workKeys)); + $params->set('query', "{!terms f=work_keys_str_mv separator=\"\u{001f}\"}" . implode("\u{001f}", $workKeys)); if (!$query->getIncludeSelf()) { - $params->add('fq', sprintf('-id:"%s"', addcslashes($id, '"'))); + $params->add('filter', sprintf('-id:"%s"', addcslashes($id, '"'))); } - $params->set('rows', $limit); - $params->set('start', $offset); + $params->set('limit', $limit); + $params->set('offset', $offset); if (!$params->hasParam('sort')) { $params->add('sort', 'publishDateSort desc, title_sort asc'); } diff --git a/module/VuFindSearch/src/VuFindSearch/Backend/Solr/Connector.php b/module/VuFindSearch/src/VuFindSearch/Backend/Solr/Connector.php index a8cc5a9e947..465ae4c389b 100644 --- a/module/VuFindSearch/src/VuFindSearch/Backend/Solr/Connector.php +++ b/module/VuFindSearch/src/VuFindSearch/Backend/Solr/Connector.php @@ -42,6 +42,7 @@ use VuFindSearch\Backend\Solr\Document\DocumentInterface; use VuFindSearch\Exception\InvalidArgumentException; use VuFindSearch\ParamBag; +use VuFindSearch\ParamBagBag; use function call_user_func_array; use function count; @@ -199,7 +200,7 @@ public function retrieve($id, ?ParamBag $params = null) { $params = $params ?: new ParamBag(); $params - ->set('q', sprintf('%s:"%s"', $this->uniqueKey, addcslashes($id, '"'))); + ->set('query', sprintf('%s:"%s"', $this->uniqueKey, addcslashes($id, '"'))); $handler = $this->map->getHandler(__FUNCTION__); $this->map->prepare(__FUNCTION__, $params); @@ -299,30 +300,24 @@ public function write( /** * Send query to SOLR and return response body. * - * @param string $handler SOLR request handler to use - * @param ParamBag $params Request parameters - * @param bool $cacheable Whether the query is cacheable + * @param string $handler SOLR request handler to use + * @param ParamBagBag $params Request parameters + * @param bool $cacheable Whether the query is cacheable * * @return string Response body */ - public function query($handler, ParamBag $params, bool $cacheable = false) + public function query($handler, ParamBagBag $params, bool $cacheable = false) { $urlSuffix = '/' . $handler; - $paramString = implode('&', $params->request()); - if (strlen($paramString) > self::MAX_GET_URL_LENGTH) { - $method = Request::METHOD_POST; - $callback = function ($client) use ($paramString): void { - $client->setRawBody($paramString); - $client->setEncType(HttpClient::ENC_URLENCODED); - $client->setHeaders(['Content-Length' => strlen($paramString)]); - }; - } else { - $method = Request::METHOD_GET; - $urlSuffix .= '?' . $paramString; - $callback = null; - } + $body = $params->json(); + $method = Request::METHOD_POST; + $callback = function ($client) use ($body): void { + $client->setRawBody($body); + $client->setEncType('application/json'); + $client->setHeaders(['Content-Length' => strlen($body)]); + }; - $this->debug(sprintf('Query %s', $paramString)); + $this->debug(sprintf('Query body %s', $body)); return $this->trySolrUrls($method, $urlSuffix, $callback, $cacheable); } diff --git a/module/VuFindSearch/src/VuFindSearch/Backend/Solr/QueryBuilder.php b/module/VuFindSearch/src/VuFindSearch/Backend/Solr/QueryBuilder.php index 38eb0732141..61f018d259b 100644 --- a/module/VuFindSearch/src/VuFindSearch/Backend/Solr/QueryBuilder.php +++ b/module/VuFindSearch/src/VuFindSearch/Backend/Solr/QueryBuilder.php @@ -32,6 +32,7 @@ namespace VuFindSearch\Backend\Solr; use VuFindSearch\ParamBag; +use VuFindSearch\ParamBagBag; use VuFindSearch\Query\AbstractQuery; use VuFindSearch\Query\Query; use VuFindSearch\Query\QueryGroup; @@ -133,12 +134,13 @@ public function __construct( */ public function build(AbstractQuery $query, ?ParamBag $params = null) { - $newParams = new ParamBag(); + $newParams = new ParamBagBag(); // Add spelling query if applicable -- note that we must set this up before // we process the main query in order to avoid unwanted extra syntax: if ($this->createSpellingQuery) { - $newParams->set( + $newParams->setNested( + 'params', 'spellcheck.q', $this->getLuceneHelper()->extractSearchTerms($query->getAllTerms()) ); @@ -173,13 +175,13 @@ public function build(AbstractQuery $query, ?ParamBag $params = null) } } } elseif ($handler->hasDismax()) { - $newParams->set('qf', implode(' ', $handler->getDismaxFields())); - $newParams->set('qt', $handler->getDismaxHandler()); + $newParams->setNested('params', 'qf', implode(' ', $handler->getDismaxFields())); + $newParams->setNested('params', 'qt', $handler->getDismaxHandler()); foreach ($handler->getDismaxParams() as $param) { - $newParams->add(reset($param), next($param)); + $newParams->addNested('params', reset($param), next($param)); } if ($handler->hasFilterQuery()) { - $newParams->add('fq', $handler->getFilterQuery()); + $newParams->add('filter', $handler->getFilterQuery()); } } else { $string = $handler->createSimpleQueryString($string); @@ -188,9 +190,9 @@ public function build(AbstractQuery $query, ?ParamBag $params = null) // Set an appropriate highlight field list when applicable: if ($highlight) { $filter = $handler ? $handler->getAllFields() : []; - $newParams->add('hl.fl', $this->getFieldsToHighlight($filter)); + $newParams->addNested('params', 'hl.fl', $this->getFieldsToHighlight($filter)); } - $newParams->set('q', $string); + $newParams->set('query', $string); // Handle any extra parameters: foreach ($this->globalExtraParams as $extraParam) { @@ -203,7 +205,7 @@ public function build(AbstractQuery $query, ?ParamBag $params = null) continue; } foreach ((array)$extraParam['value'] as $value) { - $newParams->add($extraParam['param'], $value); + $newParams->addNested('params', $extraParam['param'], $value); } } diff --git a/module/VuFindSearch/src/VuFindSearch/ParamBag.php b/module/VuFindSearch/src/VuFindSearch/ParamBag.php index e5e480857b9..7f951e2fe7b 100644 --- a/module/VuFindSearch/src/VuFindSearch/ParamBag.php +++ b/module/VuFindSearch/src/VuFindSearch/ParamBag.php @@ -54,7 +54,7 @@ class ParamBag implements \Countable * * @var array */ - protected $params = []; + protected $items = []; /** * Constructor. @@ -79,7 +79,7 @@ public function __construct(array $initial = []) */ public function get($name) { - return $this->params[$name] ?? null; + return $this->items[$name] ?? null; } /** @@ -89,7 +89,7 @@ public function get($name) */ public function count(): int { - return count($this->params); + return count($this->items); } /** @@ -101,7 +101,7 @@ public function count(): int */ public function hasParam($name) { - return isset($this->params[$name]); + return isset($this->items[$name]); } /** @@ -128,7 +128,7 @@ public function contains($name, $value) */ public function set($name, $value) { - $this->params[$name] = is_array($value) ? $value : [$value]; + $this->items[$name] = is_array($value) ? $value : [$value]; } /** @@ -140,8 +140,8 @@ public function set($name, $value) */ public function remove($name) { - if (isset($this->params[$name])) { - unset($this->params[$name]); + if (isset($this->items[$name])) { + unset($this->items[$name]); } } @@ -156,22 +156,24 @@ public function remove($name) */ public function add($name, $value, $deduplicate = true) { - if (!isset($this->params[$name])) { - $this->params[$name] = []; + if (!isset($this->items[$name])) { + $this->items[$name] = []; } if (is_array($value)) { - $this->params[$name] = array_merge_recursive($this->params[$name], $value); + $this->items[$name] = array_merge_recursive($this->items[$name], $value); + } elseif ($value instanceof ParamBag) { + $bar = 1; } else { - $this->params[$name][] = $value; + $this->items[$name][] = $value; } if ($deduplicate) { // Avoid deduplicating associative array params (like Primo filterList): - foreach ($this->params[$name] as $key => $current) { + foreach ($this->items[$name] as $key => $current) { if (!is_numeric($key) || is_array($current)) { return; } } - $this->params[$name] = array_values(array_unique($this->params[$name])); + $this->items[$name] = array_values(array_unique($this->items[$name])); } } @@ -184,7 +186,7 @@ public function add($name, $value, $deduplicate = true) */ public function mergeWith(ParamBag $bag) { - foreach ($bag->params as $key => $value) { + foreach ($bag->items as $key => $value) { if (!empty($value)) { $this->add($key, $value); } @@ -212,7 +214,7 @@ public function mergeWithAll(array $bags) */ public function getArrayCopy() { - return $this->params; + return $this->items; } /** @@ -224,8 +226,8 @@ public function getArrayCopy() */ public function exchangeArray(array $input) { - $current = $this->params; - $this->params = []; + $current = $this->items; + $this->items = []; foreach ($input as $key => $value) { $this->set($key, $value); } @@ -243,7 +245,7 @@ public function exchangeArray(array $input) public function request() { $request = []; - foreach ($this->params as $name => $values) { + foreach ($this->items as $name => $values) { if (!empty($values)) { $request = array_merge( $request, diff --git a/module/VuFindSearch/src/VuFindSearch/ParamBagBag.php b/module/VuFindSearch/src/VuFindSearch/ParamBagBag.php new file mode 100644 index 00000000000..4f261065c3b --- /dev/null +++ b/module/VuFindSearch/src/VuFindSearch/ParamBagBag.php @@ -0,0 +1,206 @@ +. + * + * @category VuFind + * @package Search + * @author Maccabee Levine + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org + */ + +namespace VuFindSearch; + +use function count; +use function is_array; + +/** + * Bag of (string) parameters or nested ParamBags. + * + * @category VuFind + * @package Search + * @author Maccabee Levine + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org + */ +class ParamBagBag extends ParamBag +{ + /** + * Transform any ParamBag into a ParamBagBag. + * + * @param ?ParamBag $original The original ParamBag + * + * @return ?ParamBagBag + */ + public static function from(ParamBag $original): ?ParamBagBag + { + if (!$original) { + return null; + } + if ($original instanceof ParamBagBag) { + return $original; + } + $bag = new ParamBagBag(); + $bag->mergeWith($original); + return $bag; + } + + /** + * Return nested parameter value. + * + * @param string $name Parameter name + * @param string $nestedName Nested parameter name + * + * @return mixed|null Parameter value or NULL if not set + */ + public function getNested(string $name, string $nestedName): string|ParamBag|null + { + $nestedBag = $this->get($name); + if (!$nestedBag || !($nestedBag instanceof ParamBag)) { + return null; + } + return $nestedBag->get($nestedName); + } + + /** + * Return true if the bag contains any value(s) for the specified parameters. + * + * @param string $name Parameter name + * @param string $nestedName Nested parameter name + * + * @return bool + */ + public function hasNestedParam($name, $nestedName): bool + { + $nestedBag = $this->get($name); + if (!$nestedBag) { + return false; + } + return $nestedBag->hasParam($nestedName); + } + + /** + * Set a nested parameter. + * + * @param string $name Parameter name + * @param string $nestedName Nested parameter name + * @param string $nestedValue Nested parameter value + * + * @return void + */ + public function setNested($name, $nestedName, $nestedValue): void + { + $nestedBag = $this->items[$name] ?? null; + if (!$nestedBag) { + $nestedBag = [new ParamBag()]; + $this->set($name, $nestedBag); + } + $nestedBag[0]->set($nestedName, $nestedValue); + } + + /** + * Add a nested parameter value. + * + * @param string $name Parameter name + * @param string $nestedName Nested parameter name + * @param string $nestedValue Nested parameter value + * + * @return void + */ + public function addNested($name, $nestedName, $nestedValue): void + { + $nestedBag = $this->items[$name] ?? null; + if (!$nestedBag) { + $nestedBag = new ParamBag(); + $this->set($name, $nestedBag); + } + $nestedBag[0]->add($nestedName, $nestedValue); + } + + /** + * Add parameter value. + * + * @param string $name Parameter name + * @param mixed $value Parameter value + * @param bool $deduplicate Deduplicate parameter values + * + * @return void + */ + public function add($name, $value, $deduplicate = true): void + { + // Merge as needed so there is only one ParamBag for any $name + if (is_array($value) && count($value) && $value[0] instanceof ParamBag) { + $existingValues = $this->items[$name] ?? []; + if (count($existingValues) && $existingValues[0] instanceof ParamBag) { + $existingValues[0]->mergeWith($value[0]); + return; + } + throw new \Exception('WTF are we combining?'); + } + + parent::add($name, $value, $deduplicate); + } + + /** + * Return JSON string of params ready to be used in a HTTP POST body. + * + * @return string + */ + public function json(): string + { + $jsonObject = []; + foreach ($this->items as $name => $values) { + if (is_array($values) && count($values) > 1) { + throw new \Exception('got more than one value for ' . $name); + } + if (count($values) == 1) { + $value = $values[0]; + if ($value instanceof ParamBag) { + $nestedValues = $value->getArrayCopy(); + $jsonObject[$name] = []; + foreach ($nestedValues as $nestedName => $nestedValue) { + $jsonObject[$name][$nestedName] = $nestedValue[0] ?? $nestedValue; + } + } + else { + $jsonObject[$name] = $value; + } + } + else { + $jsonObject[$name] = $values; + } + } + return json_encode($jsonObject); + } + + /** + * Return array of params ready to be used in a HTTP request. + * + * Returns a numerical array with all request parameters as properly URL + * encoded key-value pairs. + * + * @return array + */ + public function request() + { + throw new \Exception('fix this...cannot happen?'); + } +} From caae5eb4f4200c90339dfec8413af68e7005443c Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Fri, 2 Jan 2026 14:33:06 +0000 Subject: [PATCH 02/14] Fix spelling listener --- .../Search/Solr/InjectSpellingListener.php | 20 +++++++++++-------- .../src/VuFindSearch/ParamBagBag.php | 16 +++++++-------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/module/VuFind/src/VuFind/Search/Solr/InjectSpellingListener.php b/module/VuFind/src/VuFind/Search/Solr/InjectSpellingListener.php index bee6156eeeb..c31b7a2584a 100644 --- a/module/VuFind/src/VuFind/Search/Solr/InjectSpellingListener.php +++ b/module/VuFind/src/VuFind/Search/Solr/InjectSpellingListener.php @@ -35,7 +35,7 @@ use VuFind\Log\LoggerAwareTrait; use VuFindSearch\Backend\BackendInterface; use VuFindSearch\Backend\Solr\Response\Json\Spellcheck; -use VuFindSearch\ParamBag; +use VuFindSearch\ParamBagBag; use VuFindSearch\Query\Query; use VuFindSearch\Service; @@ -128,8 +128,10 @@ public function onSearchPre(EventInterface $event) } if ($command->getTargetIdentifier() === $this->backend->getIdentifier()) { if ($params = $command->getSearchParameters()) { + $params == ParamBagBag::from($params); + // Set spelling parameters when enabled: - $sc = $params->get('spellcheck'); + $sc = $params->getNested('params', 'spellcheck'); if (isset($sc[0]) && $sc[0] != 'false') { $this->active = true; if (empty($this->dictionaries)) { @@ -140,8 +142,9 @@ public function onSearchPre(EventInterface $event) // Set relevant Solr parameters: reset($this->dictionaries); - $params->set('spellcheck', 'true'); - $params->set( + $params->setNested('params', 'spellcheck', 'true'); + $params->setNested( + 'params', 'spellcheck.dictionary', current($this->dictionaries) ); @@ -175,7 +178,8 @@ public function onSearchPost(EventInterface $event) if ($command->getTargetIdentifier() === $this->backend->getIdentifier()) { $result = $command->getResult(); $params = $command->getSearchParameters(); - $spellcheckQuery = $params->get('spellcheck.q'); + $params = ParamBagBag::from($params); + $spellcheckQuery = $params->getNested('params', 'spellcheck.q'); if (!empty($spellcheckQuery)) { $this->aggregateSpellcheck( $result->getSpellcheck(), @@ -197,9 +201,9 @@ public function onSearchPost(EventInterface $event) protected function aggregateSpellcheck(Spellcheck $spellcheck, $query) { while (next($this->dictionaries) !== false) { - $params = new ParamBag(); - $params->set('spellcheck', 'true'); - $params->set('spellcheck.dictionary', current($this->dictionaries)); + $params = new ParamBagBag(); + $params->setNested('params', 'spellcheck', 'true'); + $params->setNested('params', 'spellcheck.dictionary', current($this->dictionaries)); $queryObj = new Query($query, 'AllFields'); try { $collection = $this->backend->search($queryObj, 0, 0, $params); diff --git a/module/VuFindSearch/src/VuFindSearch/ParamBagBag.php b/module/VuFindSearch/src/VuFindSearch/ParamBagBag.php index 4f261065c3b..0b4baff29d3 100644 --- a/module/VuFindSearch/src/VuFindSearch/ParamBagBag.php +++ b/module/VuFindSearch/src/VuFindSearch/ParamBagBag.php @@ -69,15 +69,15 @@ public static function from(ParamBag $original): ?ParamBagBag * @param string $name Parameter name * @param string $nestedName Nested parameter name * - * @return mixed|null Parameter value or NULL if not set + * @return ?array Array of parameter values or NULL if not set */ - public function getNested(string $name, string $nestedName): string|ParamBag|null + public function getNested(string $name, string $nestedName): ?array { $nestedBag = $this->get($name); - if (!$nestedBag || !($nestedBag instanceof ParamBag)) { + if (!$nestedBag) { return null; } - return $nestedBag->get($nestedName); + return $nestedBag[0]->get($nestedName); } /** @@ -94,7 +94,7 @@ public function hasNestedParam($name, $nestedName): bool if (!$nestedBag) { return false; } - return $nestedBag->hasParam($nestedName); + return $nestedBag[0]->hasParam($nestedName); } /** @@ -179,12 +179,10 @@ public function json(): string foreach ($nestedValues as $nestedName => $nestedValue) { $jsonObject[$name][$nestedName] = $nestedValue[0] ?? $nestedValue; } - } - else { + } else { $jsonObject[$name] = $value; } - } - else { + } else { $jsonObject[$name] = $values; } } From 8e80568b74f33b6ca06ef3f7bcb6ace35f49a14c Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Fri, 2 Jan 2026 15:11:35 +0000 Subject: [PATCH 03/14] First pass at several listeners --- .../Search/Solr/CustomFilterListener.php | 2 +- .../Search/Solr/DeduplicationListener.php | 10 ++++----- .../Search/Solr/DefaultParametersListener.php | 1 + .../src/VuFind/Search/Solr/Explanation.php | 22 +++++++++++-------- .../Solr/FilterFieldConversionListener.php | 12 +++++----- .../Solr/InjectConditionalFilterListener.php | 10 ++++----- .../Search/Solr/InjectSpellingListener.php | 2 +- .../VuFind/Search/Solr/MultiIndexListener.php | 7 ++++-- .../VuFind/src/VuFind/Search/Solr/Results.php | 6 +++-- 9 files changed, 41 insertions(+), 31 deletions(-) diff --git a/module/VuFind/src/VuFind/Search/Solr/CustomFilterListener.php b/module/VuFind/src/VuFind/Search/Solr/CustomFilterListener.php index 4c5b03702cf..bfe486a16a8 100644 --- a/module/VuFind/src/VuFind/Search/Solr/CustomFilterListener.php +++ b/module/VuFind/src/VuFind/Search/Solr/CustomFilterListener.php @@ -74,7 +74,7 @@ class CustomFilterListener * * @var string */ - protected $filterParam = 'fq'; + protected $filterParam = 'filter'; /** * Constructor. diff --git a/module/VuFind/src/VuFind/Search/Solr/DeduplicationListener.php b/module/VuFind/src/VuFind/Search/Solr/DeduplicationListener.php index ed653963e50..1936e8ec4df 100644 --- a/module/VuFind/src/VuFind/Search/Solr/DeduplicationListener.php +++ b/module/VuFind/src/VuFind/Search/Solr/DeduplicationListener.php @@ -154,15 +154,15 @@ public function onSearchPre(EventInterface $event) $this->enabled && 'getids' !== $context && !$this->hasChildFilter($params) ) { - $fq = '-merged_child_boolean:true'; + $filter = '-merged_child_boolean:true'; if ($context == 'similar' && $id = $event->getParam('id')) { - $fq .= ' AND -local_ids_str_mv:"' + $filter .= ' AND -local_ids_str_mv:"' . addcslashes($id, '"') . '"'; } } else { - $fq = '-merged_boolean:true'; + $filter = '-merged_boolean:true'; } - $params->add('fq', $fq); + $params->add('filter', $filter); } } return $event; @@ -177,7 +177,7 @@ public function onSearchPre(EventInterface $event) */ public function hasChildFilter($params) { - $filters = $params->get('fq'); + $filters = $params->get('filter'); return $filters != null && in_array('merged_child_boolean:true', $filters); } diff --git a/module/VuFind/src/VuFind/Search/Solr/DefaultParametersListener.php b/module/VuFind/src/VuFind/Search/Solr/DefaultParametersListener.php index 9871d506065..74f93d79b11 100644 --- a/module/VuFind/src/VuFind/Search/Solr/DefaultParametersListener.php +++ b/module/VuFind/src/VuFind/Search/Solr/DefaultParametersListener.php @@ -120,6 +120,7 @@ public function onSearchPre(EventInterface $event) if (!isset($parts[1])) { continue; } + // This will need some config changes to know how to nest $params->add(urldecode($parts[0]), urldecode($parts[1])); } } diff --git a/module/VuFind/src/VuFind/Search/Solr/Explanation.php b/module/VuFind/src/VuFind/Search/Solr/Explanation.php index e5c241061a2..8f49b812ee3 100644 --- a/module/VuFind/src/VuFind/Search/Solr/Explanation.php +++ b/module/VuFind/src/VuFind/Search/Solr/Explanation.php @@ -32,6 +32,7 @@ use VuFindSearch\Backend\Solr\Command\RawJsonSearchCommand; use VuFindSearch\ParamBag; +use VuFindSearch\ParamBagBag; use function count; use function floatval; @@ -249,15 +250,18 @@ public function performRequest($recordId) // prepare search params $params = $this->getParams()->getBackendParameters(); - $params->set('spellcheck', 'false'); - $explainParams = new ParamBag([ - 'fl' => 'id,score', - 'facet' => 'true', - 'debug' => 'true', - 'indent' => 'true', - 'param' => 'q', - 'echoParams' => 'all', - 'explainOther' => 'id:"' . addcslashes($recordId, '"') . '"', + $params = ParamBagBag::from($params); + $params->setNested('params', 'spellcheck', 'false'); + $explainParams = new ParamBagBag([ + 'fields' => 'id,score', + 'facet' => 'true', // This field will need an update when I do the facet API upgrade + 'params' => new ParamBag([ + 'debug' => 'true', + 'indent' => 'true', + 'param' => 'query', // Is this change correct? + 'echoParams' => 'all', + 'explainOther' => 'id:"' . addcslashes($recordId, '"') . '"', + ]), ]); $params->mergeWith($explainParams); diff --git a/module/VuFind/src/VuFind/Search/Solr/FilterFieldConversionListener.php b/module/VuFind/src/VuFind/Search/Solr/FilterFieldConversionListener.php index febfa19f488..d60259e8f86 100644 --- a/module/VuFind/src/VuFind/Search/Solr/FilterFieldConversionListener.php +++ b/module/VuFind/src/VuFind/Search/Solr/FilterFieldConversionListener.php @@ -91,12 +91,12 @@ public function attach(SharedEventManagerInterface $manager) public function onSearchPre(EventInterface $event) { $params = $event->getParam('command')->getSearchParameters(); - $fq = $params->get('fq'); - if (is_array($fq) && !empty($fq)) { + $filters = $params->get('filter'); + if (is_array($filters) && !empty($filters)) { // regex lookahead to ignore strings inside quotes: $lookahead = '(?=(?:[^\"]*+\"[^\"]*+\")*+[^\"]*+$)'; - $new_fq = []; - foreach ($fq as $currentFilter) { + $new_filters = []; + foreach ($filters as $currentFilter) { foreach ($this->map as $oldField => $newField) { $currentFilter = preg_replace( "/\b$oldField:$lookahead/", @@ -104,9 +104,9 @@ public function onSearchPre(EventInterface $event) $currentFilter ); } - $new_fq[] = $currentFilter; + $new_filters[] = $currentFilter; } - $params->set('fq', $new_fq); + $params->set('filter', $new_filters); } return $event; diff --git a/module/VuFind/src/VuFind/Search/Solr/InjectConditionalFilterListener.php b/module/VuFind/src/VuFind/Search/Solr/InjectConditionalFilterListener.php index 47470d9938b..dc9a8a5d33a 100644 --- a/module/VuFind/src/VuFind/Search/Solr/InjectConditionalFilterListener.php +++ b/module/VuFind/src/VuFind/Search/Solr/InjectConditionalFilterListener.php @@ -147,12 +147,12 @@ public function onSearchPre(EventInterface $event) } $params = $command->getSearchParameters(); - $fq = $params->get('fq'); - if (!is_array($fq)) { - $fq = []; + $filters = $params->get('filter'); + if (!is_array($filters)) { + $filters = []; } - $new_fq = array_merge($fq, $this->filterList); - $params->set('fq', $new_fq); + $new_filters = array_merge($filters, $this->filterList); + $params->set('filters', $new_filters); return $event; } diff --git a/module/VuFind/src/VuFind/Search/Solr/InjectSpellingListener.php b/module/VuFind/src/VuFind/Search/Solr/InjectSpellingListener.php index c31b7a2584a..8036433a204 100644 --- a/module/VuFind/src/VuFind/Search/Solr/InjectSpellingListener.php +++ b/module/VuFind/src/VuFind/Search/Solr/InjectSpellingListener.php @@ -128,7 +128,7 @@ public function onSearchPre(EventInterface $event) } if ($command->getTargetIdentifier() === $this->backend->getIdentifier()) { if ($params = $command->getSearchParameters()) { - $params == ParamBagBag::from($params); + $params = ParamBagBag::from($params); // Set spelling parameters when enabled: $sc = $params->getNested('params', 'spellcheck'); diff --git a/module/VuFind/src/VuFind/Search/Solr/MultiIndexListener.php b/module/VuFind/src/VuFind/Search/Solr/MultiIndexListener.php index cee2cbb8f27..5788ca838ad 100644 --- a/module/VuFind/src/VuFind/Search/Solr/MultiIndexListener.php +++ b/module/VuFind/src/VuFind/Search/Solr/MultiIndexListener.php @@ -32,6 +32,7 @@ use Laminas\EventManager\EventInterface; use Laminas\EventManager\SharedEventManagerInterface; use VuFindSearch\Backend\BackendInterface; +use VuFindSearch\ParamBagBag; use VuFindSearch\Service; use function in_array; @@ -126,11 +127,12 @@ public function onSearchPre(EventInterface $event) $command = $event->getParam('command'); if ($command->getTargetIdentifier() === $this->backend->getIdentifier()) { $params = $command->getSearchParameters(); + $params = ParamBagBag::from($params); $allShardsContexts = ['retrieve', 'retrieveBatch']; if (in_array($command->getContext(), $allShardsContexts)) { // If we're retrieving by id(s), we should pull all shards to be // sure we find the right record(s). - $params->set('shards', implode(',', $this->shards)); + $params->setNested('params', 'shards', implode(',', $this->shards)); } else { // In any other context, we should make sure our field values are // all legal. @@ -138,7 +140,7 @@ public function onSearchPre(EventInterface $event) // Normalize array of strings containing comma-separated values to // simple array of values; check if $params->get('shards') returns // an array to prevent invalid argument warnings. - $shards = $params->get('shards'); + $shards = $params->getNested('params', 'shards'); $shards = explode( ',', implode(',', (is_array($shards) ? $shards : [])) @@ -146,6 +148,7 @@ public function onSearchPre(EventInterface $event) $fields = $this->getFields($shards); $specs = $this->getSearchSpecs($fields); $this->backend->getQueryBuilder()->setSpecs($specs); + // This will need an update for the JSON Facet API $facets = $params->get('facet.field') ?: []; $params->set('facet.field', array_diff($facets, $fields)); } diff --git a/module/VuFind/src/VuFind/Search/Solr/Results.php b/module/VuFind/src/VuFind/Search/Solr/Results.php index f0041be3cc5..a0984deda08 100644 --- a/module/VuFind/src/VuFind/Search/Solr/Results.php +++ b/module/VuFind/src/VuFind/Search/Solr/Results.php @@ -30,6 +30,7 @@ namespace VuFind\Search\Solr; use VuFindSearch\Command\SearchCommand; +use VuFindSearch\ParamBagBag; use VuFindSearch\Query\AbstractQuery; use VuFindSearch\Query\QueryGroup; @@ -194,13 +195,14 @@ protected function performSearch() $limit = $this->getParams()->getLimit(); $offset = $this->getStartRecord() - 1; $params = $this->getParams()->getBackendParameters(); + $params = ParamBagBag::from($params); $searchService = $this->getSearchService(); $cursorMark = $this->getCursorMark(); if (null !== $cursorMark) { - $params->set('cursorMark', '' === $cursorMark ? '*' : $cursorMark); + $params->setNested('params', 'cursorMark', '' === $cursorMark ? '*' : $cursorMark); // Override any default timeAllowed since it cannot be used with // cursorMark - $params->set('timeAllowed', -1); + $params->setNested('params', 'timeAllowed', -1); } try { From e82a8e9ffddc6aba20da9643c7cf56e3a3636221 Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Fri, 2 Jan 2026 18:50:49 +0000 Subject: [PATCH 04/14] First version of JSON Facets api, working at basic level --- .../VuFind/Search/Params/FacetLimitTrait.php | 8 +-- .../VuFind/src/VuFind/Search/Solr/Params.php | 49 +++++++++--------- .../VuFindSearch/Backend/Solr/Connector.php | 5 ++ .../Solr/Response/Json/RecordCollection.php | 29 ++++++----- .../src/VuFindSearch/ParamBagBag.php | 50 ++++++++++++++++--- 5 files changed, 93 insertions(+), 48 deletions(-) diff --git a/module/VuFind/src/VuFind/Search/Params/FacetLimitTrait.php b/module/VuFind/src/VuFind/Search/Params/FacetLimitTrait.php index 8618e24b3ea..a664b137684 100644 --- a/module/VuFind/src/VuFind/Search/Params/FacetLimitTrait.php +++ b/module/VuFind/src/VuFind/Search/Params/FacetLimitTrait.php @@ -77,10 +77,10 @@ trait FacetLimitTrait protected function initFacetLimitsFromConfig(?Config $config = null) { if (is_numeric($config->facet_limit ?? null)) { - $this->setFacetLimit($config->facet_limit); + $this->setFacetLimit((int)($config->facet_limit)); } foreach ($config->facet_limit_by_field ?? [] as $k => $v) { - $this->facetLimitByField[$k] = $v; + $this->facetLimitByField[$k] = (int)$v; } } @@ -91,7 +91,7 @@ protected function initFacetLimitsFromConfig(?Config $config = null) * * @return void */ - public function setFacetLimit($l) + public function setFacetLimit(int $l): void { $this->facetLimit = $l; } @@ -125,7 +125,7 @@ public function getHierarchicalFacetLimit() * * @return void */ - public function setHierarchicalFacetLimit($limit) + public function setHierarchicalFacetLimit(int $limit) { $this->hierarchicalFacetLimit = $limit; } diff --git a/module/VuFind/src/VuFind/Search/Solr/Params.php b/module/VuFind/src/VuFind/Search/Solr/Params.php index 1f1cd240b86..27948debcb2 100644 --- a/module/VuFind/src/VuFind/Search/Solr/Params.php +++ b/module/VuFind/src/VuFind/Search/Solr/Params.php @@ -237,20 +237,9 @@ public function getFacetSettings() if (!empty($this->facetConfig)) { $dateRangeTypes = $this->getOptions()->getDateRangeFieldTypes(); - $facetSet['limit'] = $this->facetLimit; foreach (array_keys($this->facetConfig) as $facetField) { - $fieldLimit = $this->getFacetLimitForField($facetField); - if ($fieldLimit != $this->facetLimit) { - $facetSet["f.{$facetField}.facet.limit"] = $fieldLimit; - } - $fieldPrefix = $this->getFacetPrefixForField($facetField); - if (!empty($fieldPrefix)) { - $facetSet["f.{$facetField}.facet.prefix"] = $fieldPrefix; - } - $fieldMatches = $this->getFacetMatchesForField($facetField); - if (!empty($fieldMatches)) { - $facetSet["f.{$facetField}.facet.matches"] = $fieldMatches; - } + + // Figure out date range field if ('DateRangeField' === ($dateRangeTypes[$facetField] ?? null)) { $startYear = $this->getOptions()->getDateRangeSliderMinValue($facetField) ?? VUFIND_DEFAULT_EARLIEST_YEAR; @@ -263,10 +252,29 @@ public function getFacetSettings() $facetSet["f.{$facetField}.facet.range.gap"] = '+1YEAR'; $facetSet['range'][] = $facetField; } else { - if ($this->getFacetOperator($facetField) == 'OR') { - $facetField = '{!ex=' . $facetField . '_filter}' . $facetField; + $facetFieldName = $facetField; + $fieldLimit = $this->getFacetLimitForField($facetField); + + // TODO Deal with prefix and suffix + $fieldPrefix = $this->getFacetPrefixForField($facetField); + if (!empty($fieldPrefix)) { + $facetSet["f.{$facetField}.facet.prefix"] = $fieldPrefix; } - $facetSet['field'][] = $facetField; + $fieldMatches = $this->getFacetMatchesForField($facetField); + if (!empty($fieldMatches)) { + $facetSet["f.{$facetField}.facet.matches"] = $fieldMatches; + } + + // TODO have to replace this + // if ($this->getFacetOperator($facetField) == 'OR') { + // $facetField = '{!ex=' . $facetField . '_filter}' . $facetField; + // } + + $facetSet[$facetFieldName] = [ + 'type' => 'terms', + 'field' => $facetField, + 'limit' => $fieldLimit + ]; } } if ($this->facetContains != null) { @@ -583,14 +591,7 @@ public function getBackendParameters() // Facets $facets = $this->getFacetSettings(); if (!empty($facets)) { - $backendParams->addNested('params', 'facet', 'true'); - - foreach ($facets as $key => $value) { - // prefix keys with "facet" unless they already have a "f." prefix: - $fullKey = str_starts_with($key, 'f.') ? $key : "facet.$key"; - $backendParams->addNested('params', $fullKey, $value); - } - $backendParams->addNested('params', 'facet.mincount', 1); + $backendParams->addMultiNested('facet', $facets); } // Filters diff --git a/module/VuFindSearch/src/VuFindSearch/Backend/Solr/Connector.php b/module/VuFindSearch/src/VuFindSearch/Backend/Solr/Connector.php index 465ae4c389b..fb189592c54 100644 --- a/module/VuFindSearch/src/VuFindSearch/Backend/Solr/Connector.php +++ b/module/VuFindSearch/src/VuFindSearch/Backend/Solr/Connector.php @@ -319,6 +319,11 @@ public function query($handler, ParamBagBag $params, bool $cacheable = false) $this->debug(sprintf('Query body %s', $body)); return $this->trySolrUrls($method, $urlSuffix, $callback, $cacheable); + // $responseBody = $this->trySolrUrls($method, $urlSuffix, $callback, $cacheable); + // if (strlen($body) > 500) { + // $this->debug(sprintf('Response body to long query %s', $responseBody)); + // } + // return $responseBody; } /** diff --git a/module/VuFindSearch/src/VuFindSearch/Backend/Solr/Response/Json/RecordCollection.php b/module/VuFindSearch/src/VuFindSearch/Backend/Solr/Response/Json/RecordCollection.php index 3829a56f6ab..6a23df4f2c9 100644 --- a/module/VuFindSearch/src/VuFindSearch/Backend/Solr/Response/Json/RecordCollection.php +++ b/module/VuFindSearch/src/VuFindSearch/Backend/Solr/Response/Json/RecordCollection.php @@ -144,22 +144,25 @@ public function getFacets() { if (null === $this->facetFields) { $this->facetFields = []; - $facetFieldData = $this->response['facet_counts']['facet_fields'] ?? []; + $facetFieldData = $this->response['facets'] ?? []; foreach ($facetFieldData as $field => $facetData) { - $values = []; - foreach ($facetData as $value) { - $values[$value[0]] = $value[1]; + if (is_array($facetData)) { + $values = []; + foreach ($facetData['buckets'] as $bucket) { + $values[$bucket['val']] = $bucket['count']; + } + $this->facetFields[$field] = $values; } - $this->facetFields[$field] = $values; - } - $facetRangeData = $this->response['facet_counts']['facet_ranges'] ?? []; - foreach ($facetRangeData as $field => $facetData) { - $values = []; - foreach ($facetData['counts'] as $value) { - $values[$value[0]] = $value[1]; - } - $this->facetFields[$field] = $values; } + // TODO Fix this + // $facetRangeData = $this->response['facet_counts']['facet_ranges'] ?? []; + // foreach ($facetRangeData as $field => $facetData) { + // $values = []; + // foreach ($facetData['counts'] as $value) { + // $values[$value[0]] = $value[1]; + // } + // $this->facetFields[$field] = $values; + // } } return $this->facetFields; } diff --git a/module/VuFindSearch/src/VuFindSearch/ParamBagBag.php b/module/VuFindSearch/src/VuFindSearch/ParamBagBag.php index 0b4baff29d3..f5d7a707acb 100644 --- a/module/VuFindSearch/src/VuFindSearch/ParamBagBag.php +++ b/module/VuFindSearch/src/VuFindSearch/ParamBagBag.php @@ -135,6 +135,31 @@ public function addNested($name, $nestedName, $nestedValue): void $nestedBag[0]->add($nestedName, $nestedValue); } + /** + * Parse n-deep arrays to add values. + * + * @param string $name Parameter name + * @param string $value Some n-deep array of arrays into parameters + * + * @return void + */ + public function addMultiNested($name, $value): void + { + if (is_array($value)) { + $nestedBag = $this->items[$name] ?? null; + if (!$nestedBag) { + $nestedBag = new ParamBagBag(); + $this->set($name, $nestedBag); + } + foreach ($value as $nestedName => $nestedValue) { + $nestedBag->addMultiNested($nestedName, $nestedValue); + } + } + else { + $this->add($name, $value); + } + } + /** * Add parameter value. * @@ -166,8 +191,20 @@ public function add($name, $value, $deduplicate = true): void */ public function json(): string { - $jsonObject = []; - foreach ($this->items as $name => $values) { + $jsonObject = $this->jsonObject($this->items); + return json_encode($jsonObject); + } + + /** + * Parse ParamBag items into an array, recursively. + * + * @param array $items + * + * @return array + */ + protected function jsonObject($items) + { + foreach ($items as $name => $values) { if (is_array($values) && count($values) > 1) { throw new \Exception('got more than one value for ' . $name); } @@ -175,18 +212,17 @@ public function json(): string $value = $values[0]; if ($value instanceof ParamBag) { $nestedValues = $value->getArrayCopy(); - $jsonObject[$name] = []; - foreach ($nestedValues as $nestedName => $nestedValue) { - $jsonObject[$name][$nestedName] = $nestedValue[0] ?? $nestedValue; - } + $jsonObject[$name] = $this->jsonObject($nestedValues); } else { $jsonObject[$name] = $value; } } else { + // TODO This can't work properly...need unique names? + // But will JSON ever require non-unique? If not, then using the array-based parambag is not needed? $jsonObject[$name] = $values; } } - return json_encode($jsonObject); + return $jsonObject; } /** From 27c2e6dc9df2cc6323459bca45c9b00da4d49b36 Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Fri, 2 Jan 2026 18:52:32 +0000 Subject: [PATCH 05/14] Fix styles --- module/VuFind/src/VuFind/Search/Solr/Params.php | 5 ++--- .../Backend/Solr/Response/Json/RecordCollection.php | 3 ++- module/VuFindSearch/src/VuFindSearch/ParamBagBag.php | 5 ++--- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/module/VuFind/src/VuFind/Search/Solr/Params.php b/module/VuFind/src/VuFind/Search/Solr/Params.php index 27948debcb2..72c53767079 100644 --- a/module/VuFind/src/VuFind/Search/Solr/Params.php +++ b/module/VuFind/src/VuFind/Search/Solr/Params.php @@ -238,7 +238,6 @@ public function getFacetSettings() if (!empty($this->facetConfig)) { $dateRangeTypes = $this->getOptions()->getDateRangeFieldTypes(); foreach (array_keys($this->facetConfig) as $facetField) { - // Figure out date range field if ('DateRangeField' === ($dateRangeTypes[$facetField] ?? null)) { $startYear = $this->getOptions()->getDateRangeSliderMinValue($facetField) @@ -265,7 +264,7 @@ public function getFacetSettings() $facetSet["f.{$facetField}.facet.matches"] = $fieldMatches; } - // TODO have to replace this + // TODO have to replace this // if ($this->getFacetOperator($facetField) == 'OR') { // $facetField = '{!ex=' . $facetField . '_filter}' . $facetField; // } @@ -273,7 +272,7 @@ public function getFacetSettings() $facetSet[$facetFieldName] = [ 'type' => 'terms', 'field' => $facetField, - 'limit' => $fieldLimit + 'limit' => $fieldLimit, ]; } } diff --git a/module/VuFindSearch/src/VuFindSearch/Backend/Solr/Response/Json/RecordCollection.php b/module/VuFindSearch/src/VuFindSearch/Backend/Solr/Response/Json/RecordCollection.php index 6a23df4f2c9..771bf7209cb 100644 --- a/module/VuFindSearch/src/VuFindSearch/Backend/Solr/Response/Json/RecordCollection.php +++ b/module/VuFindSearch/src/VuFindSearch/Backend/Solr/Response/Json/RecordCollection.php @@ -32,6 +32,7 @@ use VuFindSearch\Response\AbstractRecordCollection; use function array_key_exists; +use function is_array; /** * Simple JSON-based record collection. @@ -149,7 +150,7 @@ public function getFacets() if (is_array($facetData)) { $values = []; foreach ($facetData['buckets'] as $bucket) { - $values[$bucket['val']] = $bucket['count']; + $values[$bucket['val']] = $bucket['count']; } $this->facetFields[$field] = $values; } diff --git a/module/VuFindSearch/src/VuFindSearch/ParamBagBag.php b/module/VuFindSearch/src/VuFindSearch/ParamBagBag.php index f5d7a707acb..59c34ae1f01 100644 --- a/module/VuFindSearch/src/VuFindSearch/ParamBagBag.php +++ b/module/VuFindSearch/src/VuFindSearch/ParamBagBag.php @@ -154,8 +154,7 @@ public function addMultiNested($name, $value): void foreach ($value as $nestedName => $nestedValue) { $nestedBag->addMultiNested($nestedName, $nestedValue); } - } - else { + } else { $this->add($name, $value); } } @@ -198,7 +197,7 @@ public function json(): string /** * Parse ParamBag items into an array, recursively. * - * @param array $items + * @param array $items The array from a ParamBag * * @return array */ From 2a56ef97465aa4939a9d11c0d34cab277bae1f10 Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Fri, 2 Jan 2026 18:59:50 +0000 Subject: [PATCH 06/14] Mark some TODOs --- module/VuFind/src/VuFind/Hierarchy/TreeDataSource/Solr.php | 4 ++-- .../src/VuFind/Search/Solr/DefaultParametersListener.php | 2 +- module/VuFind/src/VuFind/Search/Solr/MultiIndexListener.php | 2 +- module/VuFind/src/VuFind/Search/Solr/Params.php | 2 +- module/VuFindSearch/src/VuFindSearch/Backend/Solr/Backend.php | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/module/VuFind/src/VuFind/Hierarchy/TreeDataSource/Solr.php b/module/VuFind/src/VuFind/Hierarchy/TreeDataSource/Solr.php index 54c45992768..fa5d8a46a59 100644 --- a/module/VuFind/src/VuFind/Hierarchy/TreeDataSource/Solr.php +++ b/module/VuFind/src/VuFind/Hierarchy/TreeDataSource/Solr.php @@ -151,7 +151,7 @@ public function getXML($id, $options = []) */ protected function getDefaultSearchParams(): array { - // Needs adjustment + // TODO Needs adjustment return [ 'fq' => $this->filters, 'hl' => ['false'], @@ -200,7 +200,7 @@ protected function searchSolrCursor(Query $query, $rows): array $records = []; while ($cursorMark !== $prevCursorMark) { $params = new ParamBag( - // Needs adjustment + // TODO Needs adjustment $this->getDefaultSearchParams() + [ // Sort is required 'sort' => ['id asc'], diff --git a/module/VuFind/src/VuFind/Search/Solr/DefaultParametersListener.php b/module/VuFind/src/VuFind/Search/Solr/DefaultParametersListener.php index 74f93d79b11..4f39a0e1891 100644 --- a/module/VuFind/src/VuFind/Search/Solr/DefaultParametersListener.php +++ b/module/VuFind/src/VuFind/Search/Solr/DefaultParametersListener.php @@ -120,7 +120,7 @@ public function onSearchPre(EventInterface $event) if (!isset($parts[1])) { continue; } - // This will need some config changes to know how to nest + // TODO This will need some config changes to know how to nest $params->add(urldecode($parts[0]), urldecode($parts[1])); } } diff --git a/module/VuFind/src/VuFind/Search/Solr/MultiIndexListener.php b/module/VuFind/src/VuFind/Search/Solr/MultiIndexListener.php index 5788ca838ad..184de7a94fb 100644 --- a/module/VuFind/src/VuFind/Search/Solr/MultiIndexListener.php +++ b/module/VuFind/src/VuFind/Search/Solr/MultiIndexListener.php @@ -148,7 +148,7 @@ public function onSearchPre(EventInterface $event) $fields = $this->getFields($shards); $specs = $this->getSearchSpecs($fields); $this->backend->getQueryBuilder()->setSpecs($specs); - // This will need an update for the JSON Facet API + // TODO This will need an update for the JSON Facet API $facets = $params->get('facet.field') ?: []; $params->set('facet.field', array_diff($facets, $fields)); } diff --git a/module/VuFind/src/VuFind/Search/Solr/Params.php b/module/VuFind/src/VuFind/Search/Solr/Params.php index 72c53767079..526ec225178 100644 --- a/module/VuFind/src/VuFind/Search/Solr/Params.php +++ b/module/VuFind/src/VuFind/Search/Solr/Params.php @@ -238,7 +238,7 @@ public function getFacetSettings() if (!empty($this->facetConfig)) { $dateRangeTypes = $this->getOptions()->getDateRangeFieldTypes(); foreach (array_keys($this->facetConfig) as $facetField) { - // Figure out date range field + // TODO Figure out date range field if ('DateRangeField' === ($dateRangeTypes[$facetField] ?? null)) { $startYear = $this->getOptions()->getDateRangeSliderMinValue($facetField) ?? VUFIND_DEFAULT_EARLIEST_YEAR; diff --git a/module/VuFindSearch/src/VuFindSearch/Backend/Solr/Backend.php b/module/VuFindSearch/src/VuFindSearch/Backend/Solr/Backend.php index b51b791717d..e51204659aa 100644 --- a/module/VuFindSearch/src/VuFindSearch/Backend/Solr/Backend.php +++ b/module/VuFindSearch/src/VuFindSearch/Backend/Solr/Backend.php @@ -410,7 +410,7 @@ public function alphabeticBrowse( $params = null, $offsetDelta = 0 ) { - // Does alphabrowse also need to be converted? Custom request handler... + // TODO Does alphabrowse also need to be converted? Custom request handler... $params = $params ?: new ParamBag(); $this->injectResponseWriter($params); From f6d550a44e090a9b1bb7b2ae7085fe097b12ec41 Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Fri, 16 Jan 2026 15:00:53 +0000 Subject: [PATCH 07/14] First attempt at MultiIndexListener --- .../src/VuFind/Search/Solr/MultiIndexListener.php | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/module/VuFind/src/VuFind/Search/Solr/MultiIndexListener.php b/module/VuFind/src/VuFind/Search/Solr/MultiIndexListener.php index 184de7a94fb..e2081de6043 100644 --- a/module/VuFind/src/VuFind/Search/Solr/MultiIndexListener.php +++ b/module/VuFind/src/VuFind/Search/Solr/MultiIndexListener.php @@ -32,6 +32,7 @@ use Laminas\EventManager\EventInterface; use Laminas\EventManager\SharedEventManagerInterface; use VuFindSearch\Backend\BackendInterface; +use VuFindSearch\ParamBag; use VuFindSearch\ParamBagBag; use VuFindSearch\Service; @@ -148,9 +149,16 @@ public function onSearchPre(EventInterface $event) $fields = $this->getFields($shards); $specs = $this->getSearchSpecs($fields); $this->backend->getQueryBuilder()->setSpecs($specs); - // TODO This will need an update for the JSON Facet API - $facets = $params->get('facet.field') ?: []; - $params->set('facet.field', array_diff($facets, $fields)); + if ($params->get('facet')) { + $facets = $params->get('facet')[0]->getArrayCopy(); + $facets = array_filter( + $facets, + fn ($facet) => + (!($facet[0] instanceof ParamBag)) + || !in_array($facet[0]->getArrayCopy()['field'][0], $fields) + ); + $params->set('facet', new ParamBag($facets)); + } } } return $event; From e35bfe1179c2091597ddea32ea1e6bbd3097a587 Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Fri, 16 Jan 2026 17:19:50 +0000 Subject: [PATCH 08/14] Support facet prefix --- module/VuFind/src/VuFind/Search/Solr/Params.php | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/module/VuFind/src/VuFind/Search/Solr/Params.php b/module/VuFind/src/VuFind/Search/Solr/Params.php index 526ec225178..a1bd2e010ba 100644 --- a/module/VuFind/src/VuFind/Search/Solr/Params.php +++ b/module/VuFind/src/VuFind/Search/Solr/Params.php @@ -254,11 +254,17 @@ public function getFacetSettings() $facetFieldName = $facetField; $fieldLimit = $this->getFacetLimitForField($facetField); - // TODO Deal with prefix and suffix + $facet = [ + 'type' => 'terms', + 'field' => $facetField, + 'limit' => $fieldLimit, + ]; + $fieldPrefix = $this->getFacetPrefixForField($facetField); if (!empty($fieldPrefix)) { - $facetSet["f.{$facetField}.facet.prefix"] = $fieldPrefix; + $facet['prefix'] = $fieldPrefix; } + // TODO Deal with matches $fieldMatches = $this->getFacetMatchesForField($facetField); if (!empty($fieldMatches)) { $facetSet["f.{$facetField}.facet.matches"] = $fieldMatches; @@ -269,11 +275,7 @@ public function getFacetSettings() // $facetField = '{!ex=' . $facetField . '_filter}' . $facetField; // } - $facetSet[$facetFieldName] = [ - 'type' => 'terms', - 'field' => $facetField, - 'limit' => $fieldLimit, - ]; + $facetSet[$facetFieldName] = $facet; } } if ($this->facetContains != null) { From 6206ef4a0c2e0c8c46775bb3196366bfc8243d09 Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Fri, 16 Jan 2026 17:34:44 +0000 Subject: [PATCH 09/14] Support OR facets --- module/VuFind/src/VuFind/Search/Solr/Params.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/module/VuFind/src/VuFind/Search/Solr/Params.php b/module/VuFind/src/VuFind/Search/Solr/Params.php index a1bd2e010ba..0ebd66212f9 100644 --- a/module/VuFind/src/VuFind/Search/Solr/Params.php +++ b/module/VuFind/src/VuFind/Search/Solr/Params.php @@ -270,10 +270,10 @@ public function getFacetSettings() $facetSet["f.{$facetField}.facet.matches"] = $fieldMatches; } - // TODO have to replace this - // if ($this->getFacetOperator($facetField) == 'OR') { - // $facetField = '{!ex=' . $facetField . '_filter}' . $facetField; - // } + if ($this->getFacetOperator($facetField) == 'OR') { + $facet['domain'] ??= []; + $facet['domain']['excludeTags'] = $facetField . '_filter'; + } $facetSet[$facetFieldName] = $facet; } From 5db7650976a136f333cb364211117a2f38c13a90 Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Fri, 16 Jan 2026 17:48:27 +0000 Subject: [PATCH 10/14] Simply creating a ParamBagBag from null --- .../VuFindSearch/src/VuFindSearch/Backend/Solr/Backend.php | 7 ++----- module/VuFindSearch/src/VuFindSearch/ParamBagBag.php | 5 +++-- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/module/VuFindSearch/src/VuFindSearch/Backend/Solr/Backend.php b/module/VuFindSearch/src/VuFindSearch/Backend/Solr/Backend.php index e51204659aa..9d7b743b92e 100644 --- a/module/VuFindSearch/src/VuFindSearch/Backend/Solr/Backend.php +++ b/module/VuFindSearch/src/VuFindSearch/Backend/Solr/Backend.php @@ -136,7 +136,7 @@ public function search( $limit, ?ParamBag $params = null ) { - $params = ParamBagBag::from($params); + $params = ParamBagBag::from($params, false); if ($query instanceof WorkKeysQuery) { return $this->workKeysSearch($query, $offset, $limit, $params); } @@ -163,7 +163,6 @@ public function rawJsonSearch( $limit, ?ParamBagBag $params = null ) { - $params = $params ?: new ParamBagBag(); $params = ParamBagBag::from($params); $this->injectResponseWriter($params); @@ -264,8 +263,7 @@ public function random( */ public function retrieve($id, ?ParamBag $params = null) { - $params = $params ?: new ParamBagBag(); - $params = ParamBagBag::from($params); + $params = ParamBagBag::from($params, false); $this->injectResponseWriter($params); $response = $this->connector->retrieve($id, $params); @@ -358,7 +356,6 @@ public function terms( } // Create empty ParamBag if none provided: - $params = $params ?: new ParamBagBag(); $params = ParamBagBag::from($params); $this->injectResponseWriter($params); diff --git a/module/VuFindSearch/src/VuFindSearch/ParamBagBag.php b/module/VuFindSearch/src/VuFindSearch/ParamBagBag.php index 59c34ae1f01..59e6e2fd2ca 100644 --- a/module/VuFindSearch/src/VuFindSearch/ParamBagBag.php +++ b/module/VuFindSearch/src/VuFindSearch/ParamBagBag.php @@ -47,13 +47,14 @@ class ParamBagBag extends ParamBag * Transform any ParamBag into a ParamBagBag. * * @param ?ParamBag $original The original ParamBag + * @param bool $createIfNull Create an empty ParamBag if $original is null * * @return ?ParamBagBag */ - public static function from(ParamBag $original): ?ParamBagBag + public static function from(ParamBag $original, bool $createIfNull = true): ?ParamBagBag { if (!$original) { - return null; + return $createIfNull ? new ParamBagBag() : null; } if ($original instanceof ParamBagBag) { return $original; From 09be7e136e65cda91c0e8d1326ecfdc518cfa3f1 Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Fri, 16 Jan 2026 18:28:04 +0000 Subject: [PATCH 11/14] Handle multiple values with the same name, i.e. filters. --- .../src/VuFindSearch/ParamBag.php | 2 - .../src/VuFindSearch/ParamBagBag.php | 37 +++++++++++-------- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/module/VuFindSearch/src/VuFindSearch/ParamBag.php b/module/VuFindSearch/src/VuFindSearch/ParamBag.php index 7f951e2fe7b..c7603ac536f 100644 --- a/module/VuFindSearch/src/VuFindSearch/ParamBag.php +++ b/module/VuFindSearch/src/VuFindSearch/ParamBag.php @@ -161,8 +161,6 @@ public function add($name, $value, $deduplicate = true) } if (is_array($value)) { $this->items[$name] = array_merge_recursive($this->items[$name], $value); - } elseif ($value instanceof ParamBag) { - $bar = 1; } else { $this->items[$name][] = $value; } diff --git a/module/VuFindSearch/src/VuFindSearch/ParamBagBag.php b/module/VuFindSearch/src/VuFindSearch/ParamBagBag.php index 59e6e2fd2ca..59438ba5a74 100644 --- a/module/VuFindSearch/src/VuFindSearch/ParamBagBag.php +++ b/module/VuFindSearch/src/VuFindSearch/ParamBagBag.php @@ -46,8 +46,8 @@ class ParamBagBag extends ParamBag /** * Transform any ParamBag into a ParamBagBag. * - * @param ?ParamBag $original The original ParamBag - * @param bool $createIfNull Create an empty ParamBag if $original is null + * @param ?ParamBag $original The original ParamBag + * @param bool $createIfNull Create an empty ParamBag if $original is null * * @return ?ParamBagBag */ @@ -204,22 +204,29 @@ public function json(): string */ protected function jsonObject($items) { + $jsonObject = []; foreach ($items as $name => $values) { - if (is_array($values) && count($values) > 1) { - throw new \Exception('got more than one value for ' . $name); - } - if (count($values) == 1) { - $value = $values[0]; - if ($value instanceof ParamBag) { - $nestedValues = $value->getArrayCopy(); - $jsonObject[$name] = $this->jsonObject($nestedValues); - } else { - $jsonObject[$name] = $value; + if (is_array($values)) { + if ( + count($values) > 1 && + array_filter($values, fn ($value) => $value instanceof ParamBag) + ) { + throw new \Exception('More than one value for name ' . $name . ' including at least one ParamBag.'); + } + + if (count($values) > 1) { + $jsonObject[$name] = $values; + } elseif (count($values) == 1) { + $value = $values[0]; + if ($value instanceof ParamBag) { + $nestedValues = $value->getArrayCopy(); + $jsonObject[$name] = $this->jsonObject($nestedValues); + } else { + $jsonObject[$name] = $value; + } } } else { - // TODO This can't work properly...need unique names? - // But will JSON ever require non-unique? If not, then using the array-based parambag is not needed? - $jsonObject[$name] = $values; + throw new \Exception('ParamBag values for ' . $name . ' is not an array.'); } } return $jsonObject; From 2f2d089b6620667b3e404cebd270bd1eec8f1277 Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Fri, 16 Jan 2026 18:59:37 +0000 Subject: [PATCH 12/14] Implement JsonSerializable --- .../src/VuFindSearch/Backend/Solr/Connector.php | 2 +- .../src/VuFindSearch/ParamBagBag.php | 16 +++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/module/VuFindSearch/src/VuFindSearch/Backend/Solr/Connector.php b/module/VuFindSearch/src/VuFindSearch/Backend/Solr/Connector.php index fb189592c54..a9b8260893c 100644 --- a/module/VuFindSearch/src/VuFindSearch/Backend/Solr/Connector.php +++ b/module/VuFindSearch/src/VuFindSearch/Backend/Solr/Connector.php @@ -309,7 +309,7 @@ public function write( public function query($handler, ParamBagBag $params, bool $cacheable = false) { $urlSuffix = '/' . $handler; - $body = $params->json(); + $body = json_encode($params); $method = Request::METHOD_POST; $callback = function ($client) use ($body): void { $client->setRawBody($body); diff --git a/module/VuFindSearch/src/VuFindSearch/ParamBagBag.php b/module/VuFindSearch/src/VuFindSearch/ParamBagBag.php index 59438ba5a74..9998d362dff 100644 --- a/module/VuFindSearch/src/VuFindSearch/ParamBagBag.php +++ b/module/VuFindSearch/src/VuFindSearch/ParamBagBag.php @@ -29,6 +29,8 @@ namespace VuFindSearch; +use JsonSerializable; + use function count; use function is_array; @@ -41,7 +43,7 @@ * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License * @link https://vufind.org */ -class ParamBagBag extends ParamBag +class ParamBagBag extends ParamBag implements JsonSerializable { /** * Transform any ParamBag into a ParamBagBag. @@ -185,14 +187,14 @@ public function add($name, $value, $deduplicate = true): void } /** - * Return JSON string of params ready to be used in a HTTP POST body. + * Return a serializable object, for json_encode use into a POST body. * * @return string */ - public function json(): string + public function jsonSerialize(): mixed { - $jsonObject = $this->jsonObject($this->items); - return json_encode($jsonObject); + $serializable = $this->jsonSerializeItems($this->items); + return $serializable; } /** @@ -202,7 +204,7 @@ public function json(): string * * @return array */ - protected function jsonObject($items) + protected function jsonSerializeItems($items) { $jsonObject = []; foreach ($items as $name => $values) { @@ -220,7 +222,7 @@ protected function jsonObject($items) $value = $values[0]; if ($value instanceof ParamBag) { $nestedValues = $value->getArrayCopy(); - $jsonObject[$name] = $this->jsonObject($nestedValues); + $jsonObject[$name] = $this->jsonSerializeItems($nestedValues); } else { $jsonObject[$name] = $value; } From d943442369dc8e0bc77c3cc6b5e16d6e11ef23ba Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Fri, 16 Jan 2026 19:05:27 +0000 Subject: [PATCH 13/14] Throw any JSON encoding error --- module/VuFindSearch/src/VuFindSearch/Backend/Solr/Connector.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/VuFindSearch/src/VuFindSearch/Backend/Solr/Connector.php b/module/VuFindSearch/src/VuFindSearch/Backend/Solr/Connector.php index a9b8260893c..2ef4acaa376 100644 --- a/module/VuFindSearch/src/VuFindSearch/Backend/Solr/Connector.php +++ b/module/VuFindSearch/src/VuFindSearch/Backend/Solr/Connector.php @@ -309,7 +309,7 @@ public function write( public function query($handler, ParamBagBag $params, bool $cacheable = false) { $urlSuffix = '/' . $handler; - $body = json_encode($params); + $body = json_encode($params, JSON_THROW_ON_ERROR); $method = Request::METHOD_POST; $callback = function ($client) use ($body): void { $client->setRawBody($body); From 9451ac2713c81f30c4dcd3793adc85b23a4efa43 Mon Sep 17 00:00:00 2001 From: Maccabee Levine Date: Fri, 16 Jan 2026 19:43:00 +0000 Subject: [PATCH 14/14] Support TreeDataSource, maybe --- .../VuFind/Hierarchy/TreeDataSource/Solr.php | 30 ++++++++++--------- .../src/VuFindSearch/ParamBagBag.php | 16 ++++++++++ 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/module/VuFind/src/VuFind/Hierarchy/TreeDataSource/Solr.php b/module/VuFind/src/VuFind/Hierarchy/TreeDataSource/Solr.php index fa5d8a46a59..40d511e514e 100644 --- a/module/VuFind/src/VuFind/Hierarchy/TreeDataSource/Solr.php +++ b/module/VuFind/src/VuFind/Hierarchy/TreeDataSource/Solr.php @@ -31,7 +31,7 @@ use VuFind\Hierarchy\TreeDataFormatter\PluginManager as FormatterManager; use VuFindSearch\Backend\Solr\Command\RawJsonSearchCommand; -use VuFindSearch\ParamBag; +use VuFindSearch\ParamBagBag; use VuFindSearch\Query\Query; use VuFindSearch\Service; @@ -151,14 +151,15 @@ public function getXML($id, $options = []) */ protected function getDefaultSearchParams(): array { - // TODO Needs adjustment return [ - 'fq' => $this->filters, - 'hl' => ['false'], - 'fl' => ['title,id,hierarchy_parent_id,hierarchy_top_id,' + 'filter' => $this->filters, + 'fields' => ['title,id,hierarchy_parent_id,hierarchy_top_id,' . 'is_hierarchy_id,hierarchy_sequence,title_in_hierarchy'], - 'wt' => ['json'], - 'json.nl' => ['arrarr'], + 'params' => [ + 'hl' => ['false'], + 'wt' => ['json'], + 'json.nl' => ['arrarr'], + ], ]; } @@ -173,7 +174,7 @@ protected function getDefaultSearchParams(): array */ protected function searchSolrLegacy(Query $query, $rows): array { - $params = new ParamBag($this->getDefaultSearchParams()); + $params = ParamBagBag::fromArray($this->getDefaultSearchParams()); $command = new RawJsonSearchCommand( $this->backendId, $query, @@ -199,15 +200,16 @@ protected function searchSolrCursor(Query $query, $rows): array $cursorMark = '*'; $records = []; while ($cursorMark !== $prevCursorMark) { - $params = new ParamBag( - // TODO Needs adjustment + $params = ParamBagBag::fromArray( $this->getDefaultSearchParams() + [ // Sort is required 'sort' => ['id asc'], - // Override any default timeAllowed since it cannot be used with - // cursorMark - 'timeAllowed' => -1, - 'cursorMark' => $cursorMark, + 'params' => [ + // Override any default timeAllowed since it cannot be used with + // cursorMark + 'timeAllowed' => -1, + 'cursorMark' => $cursorMark, + ], ] ); $command = new RawJsonSearchCommand( diff --git a/module/VuFindSearch/src/VuFindSearch/ParamBagBag.php b/module/VuFindSearch/src/VuFindSearch/ParamBagBag.php index 9998d362dff..aa50c7eb889 100644 --- a/module/VuFindSearch/src/VuFindSearch/ParamBagBag.php +++ b/module/VuFindSearch/src/VuFindSearch/ParamBagBag.php @@ -66,6 +66,22 @@ public static function from(ParamBag $original, bool $createIfNull = true): ?Par return $bag; } + /** + * Transform a potentially nested array of values into a ParamBagBag. + * + * @param array $values Source values + * + * @return ParamBagBag + */ + public static function fromArray(array $values): ParamBagBag + { + $bag = new ParamBagBag(); + foreach ($values as $name => $value) { + $bag->addMultiNested($name, $value); + } + return $bag; + } + /** * Return nested parameter value. *