SeekquarrySeekquarry - Yioop Repo - Seekquarry

/ src / library / index_bundle_iterators / DocIterator.php⤓
<?php
/**
 * SeekQuarry/Yioop --
 * Open Source Pure PHP Search Engine, Crawler, and Indexer
 *
 * Copyright (C) 2009 - 2026  Chris Pollett chris@pollett.org
 *
 * LICENSE:
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * END LICENSE
 *
 * @author Chris Pollett chris@pollett.org
 * @license https://www.gnu.org/licenses/ GPL3
 * @link https://www.seekquarry.com/
 * @copyright 2009 - 2026
 * @filesource
 */
namespace seekquarry\yioop\library\index_bundle_iterators;

use seekquarry\yioop\library\storage_formats\IndexDocumentBundle;
use seekquarry\yioop\library\IndexManager;
use seekquarry\yioop\library\storage_formats\IndexShard;

/**
 * DocIterator walks all the documents of an IndexDocumentBundle, with
 * any words they hold, partition by partition, in the order of each
 * partition's document map. NegationIterator walks it beside the walk of
 * a word to find the documents without the word, and a query for all the
 * documents of an index walks it alone.
 *
 * A place in the walk is a partition and a row number of that partition's
 * document map. This is also the DOC_MAP_INDEX a WordIterator gives for the
 * document, so the two walks can be compared and moved to the same place.
 * The rows are read through the bundle's doc_map_tools, which keeps a
 * document map and the positions of its rows once read, so the walks of
 * one index share that work.
 *
 * @author Chris Pollett
 */
class DocIterator extends IndexBundleIterator
{
    /**
     * next_offset is the row number of the current partition's document map
     * the walk moves to on advance: the row after the last row the current
     * block read.
     * @var int
     */
    public $next_offset;
    /**
     * last_offset is the number of the last row of the current partition's
     * document map, -1 when it has no rows.
     * @var int
     */
    public $last_offset;
    /**
     * current_offset is the row number of the current partition's document
     * map the walk is at.
     * @var int
     */
    public $current_offset;
    /**
     * num_generations is the number of partitions of the bundle: the number
     * of the partition being saved to, plus one.
     * @var int
     */
    public $num_generations;
    /**
     * current_generation is the number of the partition the walk is in.
     * @var int
     */
    public $current_generation;
    /**
     * HOST_KEY_POS is where the hash of a document's host starts in its
     * document key, one byte past the byte that gives the document's type.
     */
    const HOST_KEY_POS = 17;
    /**
     * KEY_LEN is the length in bytes of the hash of a document's host.
     */
    const KEY_LEN = 8;
    /**
     * __construct sets up a walk of all the documents of the bundle
     * $index_name and moves it to the walk's first document.
     *
     * @param string $index_name the name of the IndexDocumentBundle to
     *      walk, which is the timestamp of its crawl
     * @param SearchfiltersModel $filter model that says which hosts are
     *      kept out of search results; their documents are left out of the
     *      blocks of the walk
     * @param int $results_per_block the most documents a block of the walk
     *      gives at once
     * @param int $direction self::ASCENDING to walk from the first
     *      document added to the last, self::DESCENDING to walk from the
     *      last to the first
     * @param array $ranking_factors weights computeDocRank uses to score
     *      each document's rank
     */
    public function __construct(public $index_name, public $filter = null,
        $results_per_block = IndexBundleIterator::RESULTS_PER_BLOCK,
        public $direction = self::ASCENDING,
        public $ranking_factors = [])
    {
        $index = IndexManager::getIndex($index_name, $direction);
        $this->results_per_block = $results_per_block;
        if (empty($index)) {
            $this->num_docs = 0;
            $this->num_generations = 0;
        } else {
            $info = $index->getArchiveInfo($index->dir_name);
            $this->num_docs = ($info['COUNT'] ?? 0) + ($info['ACTIVE'] ?? 0);
            $this->num_generations = $info['SAVE_PARTITION'] + 1;
        }
        $this->reset();
    }
    /**
     * reset moves the walk back to its first document: the first row of
     * the first partition for an ascending walk, the last row of the last
     * partition for a descending one.
     */
    public function reset()
    {
        $is_ascending = ($this->direction == self::ASCENDING);
        $this->count_block = 0;
        $this->seen_docs = 0;
        $this->current_block_fresh = false;
        $this->moveToGeneration(($is_ascending) ? 0 :
            $this->num_generations - 1);
    }
    /**
     * docMapFilename gives the path of the document map of partition
     * $generation of the bundle.
     *
     * @param int $generation number of the partition
     * @return string path of the partition's document map file
     */
    public function docMapFilename($generation)
    {
        $index = IndexManager::getIndex($this->index_name);
        return $index->getPartitionBaseFolder($generation) . "/" .
            IndexDocumentBundle::DOC_MAP_FILENAME;
    }
    /**
     * numRows gives the number of rows of the document map of partition
     * $generation: one more than the number of \xFF (byte 255) bytes
     * between its rows, or 0 when the map is empty or missing.
     *
     * @param int $generation number of the partition
     * @return int the number of rows of its document map
     */
    public function numRows($generation)
    {
        $index = IndexManager::getIndex($this->index_name);
        if (empty($index)) {
            return 0;
        }
        $doc_map_tools = $index->doc_map_tools;
        $filename = $this->docMapFilename($generation);
        if ($doc_map_tools->load($filename, $doc_map_tools::AS_STRING_MODE,
            true) === "") {
            return 0;
        }
        return count($doc_map_tools->getEntryMarkers($filename)) + 1;
    }
    /**
     * moveToGeneration moves the walk to the start of partition
     * $generation: its first row ascending, its last row descending. A
     * partition without rows is passed over for the next one in the walk's
     * direction. When no partition is left, current_generation ends up
     * past the last partition ascending, or below 0 descending.
     *
     * @param int $generation number of the partition to move to
     */
    public function moveToGeneration($generation)
    {
        $is_ascending = ($this->direction == self::ASCENDING);
        $step = ($is_ascending) ? 1 : -1;
        $this->last_offset = -1;
        while ($generation >= 0 && $generation < $this->num_generations) {
            $this->last_offset = $this->numRows($generation) - 1;
            if ($this->last_offset >= 0) {
                break;
            }
            $generation += $step;
        }
        $this->current_generation = $generation;
        $this->current_offset = ($is_ascending) ? 0 :
            max($this->last_offset, 0);
        $this->next_offset = $this->current_offset;
    }
    /**
     * isDone says if the walk has passed its last document.
     *
     * @return bool true when no partition is left to walk
     */
    public function isDone()
    {
        return $this->current_generation < 0 ||
            $this->current_generation >= $this->num_generations;
    }
    /**
     * findDocsWithWord reads the next block of the walk: from the current
     * row, at most results_per_block documents of the current partition in
     * the walk's direction. A row that is not a document, such as a link,
     * is passed over, and a document whose host the filter keeps out of
     * results is left out of the block. currentDocsWithWord calls it when
     * the block it holds is used up.
     *
     * @return mixed array of document key => document data for the block,
     *      which is empty when every document of it was left out; -1 when
     *      the walk has passed its last document
     */
    public function findDocsWithWord()
    {
        if ($this->isDone()) {
            return -1;
        }
        $is_ascending = ($this->direction == self::ASCENDING);
        $step = ($is_ascending) ? 1 : -1;
        $index = IndexManager::getIndex($this->index_name);
        $doc_map_tools = $index->doc_map_tools;
        $filename = $this->docMapFilename($this->current_generation);
        $docid_len = IndexDocumentBundle::DOCID_LEN;
        $termsfilter_len = IndexDocumentBundle::TERMSFILTER_LEN;
        $results = [];
        $num_docs_so_far = 0;
        $row = $this->current_offset;
        while ($num_docs_so_far < $this->results_per_block &&
            $row >= 0 && $row <= $this->last_offset) {
            $entry = $doc_map_tools->findEntryAtIndexTableName($filename,
                $row);
            $doc_row = $row;
            $row += $step;
            $doc_key = substr($entry, 0, $docid_len);
            if (strlen($entry) <= $docid_len ||
                IndexDocumentBundle::isType($doc_key, "link")) {
                continue;
            }
            $num_docs_so_far++;
            /* a row of a newer index has a 't' and a bloom filter of the
               document's terms between its key and its values */
            $values = (strlen($entry) > $docid_len + $termsfilter_len &&
                $entry[$docid_len] == 't') ?
                substr($entry, $docid_len + $termsfilter_len + 1) :
                substr($entry, $docid_len);
            $doc_info = $doc_map_tools->unpack($values);
            $host_key = substr($doc_key, self::HOST_KEY_POS, self::KEY_LEN);
            if (empty($doc_info) || count($doc_info) < 2 ||
                (!empty($this->filter) &&
                $this->filter->isFiltered($host_key))) {
                continue;
            }
            $item = [self::GENERATION => $this->current_generation];
            $num_rows = $this->last_offset + 1;
            $item[self::DOC_RANK] = $this->computeDocRank($doc_key,
                $doc_row, $this->current_generation,
                $this->num_generations, $num_rows, $num_rows, $num_rows,
                $this->ranking_factors, $is_ascending);
            list($item[self::DOC_LEN], ) =
                array_values(array_shift($doc_info));
            $item[self::SCORE] = $item[self::DOC_RANK];
            list(, $num_description_scores) =
                array_values(array_shift($doc_info));
            $item[self::DESCRIPTION_SCORES] = array_slice($doc_info, 0,
                $num_description_scores);
            $item[self::USER_RANKS] = array_slice($doc_info,
                $num_description_scores);
            $item[self::IS_DOC] = true;
            $item[self::KEY] = $doc_key;
            list(, $item[self::HASH], $item[self::INLINKS]) =
                str_split($doc_key, IndexShard::DOC_KEY_LEN);
            $item[self::CRAWL_TIME] = $this->index_name;
            $results[$doc_key] = $item;
        }
        $this->next_offset = $row;
        $this->count_block = count($results);
        $this->pages = $results;
        return $results;
    }
    /**
     * advanceSeenDocs adds the documents of the current block to
     * seen_docs, reading the block first if it has not been read, which
     * also sets next_offset to the row after the block.
     */
    public function advanceSeenDocs()
    {
        if ($this->current_block_fresh != true) {
            $this->currentDocsWithWord();
        }
        $this->current_block_fresh = false;
        $this->seen_docs += $this->count_block;
    }
    /**
     * advance moves the walk past its current block, or to the first
     * document at or past a given place in the walk's direction.
     *
     * @param array $gen_doc_offset a pair of a partition number and a row
     *     number of its document map. If given, the walk moves to that
     *     row, or to the next partition in the walk's direction when that
     *     partition has no such row. A place the walk has already passed
     *     leaves it where it is.
     */
    public function advance($gen_doc_offset = null)
    {
        if ($this->isDone()) {
            return;
        }
        $is_ascending = ($this->direction == self::ASCENDING);
        if ($gen_doc_offset === null) {
            $this->advanceSeenDocs();
            $this->current_offset = $this->next_offset;
        } else {
            $current = [$this->current_generation, $this->current_offset];
            if ($this->genDocOffsetCmp($current, $gen_doc_offset,
                $this->direction) >= 0) {
                return;
            }
            $this->current_block_fresh = false;
            if ($this->current_generation != $gen_doc_offset[0]) {
                $this->moveToGeneration($gen_doc_offset[0]);
            }
            if ($this->current_generation == $gen_doc_offset[0]) {
                $this->current_offset = ($is_ascending) ?
                    $gen_doc_offset[1] :
                    min($gen_doc_offset[1], $this->last_offset);
            }
        }
        $this->next_offset = $this->current_offset;
        if ($this->current_offset < 0 ||
            $this->current_offset > $this->last_offset) {
            $this->moveToGeneration($this->current_generation +
                (($is_ascending) ? 1 : -1));
        }
    }
    /**
     * currentGenDocOffsetWithWord gives the place of the walk: its
     * partition and the row number of its document map.
     *
     * @return mixed a pair of the partition number and the row number, or
     *     -1 when the walk has passed its last document
     */
    public function currentGenDocOffsetWithWord()
    {
        if ($this->isDone()) {
            return -1;
        }
        return [$this->current_generation, $this->current_offset];
    }
}