SeekquarrySeekquarry - Yioop Repo - Seekquarry

/ tests / PackedTableToolsTest.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
 *
 * This file contains unit tests of the PackedTableTools class
 *
 * @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\tests;

use seekquarry\yioop\configs as C;
use seekquarry\yioop\library as L;
use seekquarry\yioop\models\Model;
use seekquarry\yioop\library\UnitTest;
use seekquarry\yioop\library\data_structures as LDS;

/**
 * Used to test the PackedTableTools class. PackedTableTools are used for
 * reading and storing rows with respect to some signature
 */
 class PackedTableToolsTest extends UnitTest
{
    /**
     * Prefix of folders for testing PackedTable's
     */
    const TEST_DIR = C\TEST_DIR . '/test_files/packed_test';
    /**
     * Creates test directory for the tests
     */
    public function setUp()
    {
        if (!file_exists(self::TEST_DIR)) {
            mkdir(self::TEST_DIR);
        }
    }
    /**
     * After the tests delete test directory
     */
    public function tearDown()
    {
        $model = new Model();
        $model->db->unlinkRecursive(self::TEST_DIR);
    }
    /**
     * Auxiliary function used during test that takes a PackedTableTools
     * object instantiated according to some row signature and an array
     * rows in that signature and checks that for all rows packing the row
     * followed by unpacking it returns the original row. IF a row
     * fails a testCase message is output
     *
     * @param PackedTableTools $table_factory used for pack and unpacking rows
     * @param array $rows rows according to $table_factory's signature
     */
    public function checkPackUnpackTableRows($table_factory, $rows)
    {
        foreach($rows as $row) {
            $packed_row = $table_factory->pack($row);
            $pack_unpack_row = $table_factory->unpack($packed_row);
            $id = $row['ID'];
            unset($row['ID']);
            $this->assertEqual($row, $pack_unpack_row[0],
                "Row $id pack unpack produces same.");
        }
    }
    /**
     * This tests packing and unpacking rows according to a PackedTableTools
     * signature in the case that there is only one record associated with the
     * primary key (no-clustering).
     */
    public function packUnpackSingleRowTestCase()
    {
        $table_factory = new LDS\PackedTableTools(
            ["PRIMARY KEY" => "ID", "A" => "INT", "B" => "TEXT",
             "C" => "INT"]);
        $rows = [
            ["ID" => 1, "A" => 5, "B" => "Hello World", "C"=> 256],
            ["ID" => 2, "A" => 65000, "B" => "yo", "C"=> 2000000]
        ];
        $this->checkPackUnpackTableRows($table_factory, $rows);
        $table_factory = new LDS\PackedTableTools(
            ["PRIMARY KEY" => "ID", "A" => "DOUBLE", "B" => "TEXT",
             "C" => "TEXT", "D" => "BOOL", "E" => "INT"]);
        $rows = [
            ["ID" => 1, "A" => 1.59, "B" => "Bong", "C" => "yockledoo",
                "D" => true, "E" => 1000],
            ["ID" => 2, "A" => 100.4, "B" => "Blah", "C" => "doodoo",
                "D" => false, "E" => 9990],
        ];
        $this->checkPackUnpackTableRows($table_factory, $rows);
    }
    /**
     * This tests packing and unpacking rows according to a PackedTableTools
     * signature in the case that where more than one record can be associated
     * with the primary key (clustering).
     */
    public function packUnpackMultiRowTestCase()
    {
        $table_factory = new LDS\PackedTableTools(
            ["PRIMARY KEY" => "ID", "A" => "INT", "B" => "TEXT",
             "C" => "INT"]);
        $rows = [
            ["A" => 5, "B" => "Hello World", "C"=> 256],
            ["A" => 20000, "B" => "laladida", "C"=> 5600]
        ];
        $packed_rows = $table_factory->pack($rows);
        $pack_unpack_rows = $table_factory->unpack($packed_rows);
        $this->assertEqual($rows, $pack_unpack_rows,
            "Two row pack unpack produces same.");
    }
    /**
     * This tests if a set of rows packed according to a PackedTableTools record
     * is saved that it can be reloaded and unpacked and still get the same
     * results
     */
    public function saveAddLoadTestCase()
    {
        $table_factory = new LDS\PackedTableTools(
            ["PRIMARY KEY" => "ID", "A" => "INT", "B" => "TEXT",
             "C" => "INT"]);
        $table_factory2 = new LDS\PackedTableTools(
            ["PRIMARY KEY" => "ID", "A" => "INT", "B" => "TEXT",
             "C" => "INT"], C\NS_COMPRESSORS . "GzipCompressor");
        $table = [];
        $hash_key1 = md5("1", true);
        $table_factory->add($table, $hash_key1,
            $table_factory->pack(["A" => 5, "B" => "Hello World", "C"=> 256]),
            mode: LDS\PackedTableTools::APPEND_MODE);
        $table_factory->add($table, $hash_key1,
            $table_factory->pack(["A" => 6, "B" => "Hello World2", "C"=> 257]),
            mode: LDS\PackedTableTools::APPEND_MODE);
        $hash_key2 = md5("2", true);
        $table_factory->add($table, $hash_key2,
            $table_factory->pack(["A" => 20000, "B" => "laladida",
            "C"=> 5600]));
        $table_factory->save(self::TEST_DIR . "/save.txt", $table);
        $loaded_table = $table_factory->load(self::TEST_DIR . "/save.txt");
        $this->assertEqual($table, $loaded_table,
            "Add two rows, save, load table gives same result.");
        $table_factory2->save(self::TEST_DIR . "/save2.txt", $table);
        $loaded_table2 = $table_factory2->load(self::TEST_DIR . "/save2.txt");
        $this->assertEqual($table, $loaded_table2,
            "Add two rows, save, load compressed table gives same result.");
        $entry1 = $loaded_table[$hash_key1];
        $start = 0;
        $num_entries = L\vByteDecode($entry1, $start);
        $this->assertEqual($num_entries, 2,
            "Row one has correct number of sub-entries.");
    }
    /**
     * Tests that the in-memory table cache stays within its byte budget,
     * drops the tables used longest ago first, spares a table that was just
     * used, and drops a table's entry markers in step with the table. This
     * guards the fix for the slow web-server memory growth that came from
     * the cache holding every partition it was ever asked for.
     */
    public function tableCacheBoundTestCase()
    {
        $signature = ["PRIMARY KEY" => "ID", "A" => "INT", "B" => "TEXT"];
        $tools = new LDS\PackedTableTools($signature);
        $paths = [];
        for ($i = 1; $i <= 4; $i++) {
            $one_table = [];
            $tools->add($one_table, md5((string)$i, true),
                $tools->pack(["A" => $i, "B" => "row for cache table $i"]));
            $paths[$i] = self::TEST_DIR . "/cache_$i.txt";
            $tools->save($paths[$i], $one_table);
        }
        $sample = $tools->load($paths[1],
            LDS\PackedTableTools::AS_STRING_MODE, true);
        $entry_size = strlen($sample);
        $tools = new LDS\PackedTableTools($signature);
        $tools->max_table_cache_bytes = 2 * $entry_size + 1;
        foreach ([1, 2, 3] as $number) {
            $tools->load($paths[$number],
                LDS\PackedTableTools::AS_STRING_MODE, true);
        }
        $this->assertTrue(
            $tools->table_cache_bytes <= $tools->max_table_cache_bytes,
            "Cache bytes stay within the budget after loading three tables.");
        $this->assertEqual(count($tools->table_cache), 2,
            "Only the two most recent tables stay cached.");
        $this->assertTrue(!isset($tools->table_cache[L\crawlHash($paths[1])]),
            "The least recently used table was evicted.");
        $tools->load($paths[2],
            LDS\PackedTableTools::AS_STRING_MODE, true);
        $tools->load($paths[4],
            LDS\PackedTableTools::AS_STRING_MODE, true);
        $this->assertTrue(!isset($tools->table_cache[L\crawlHash($paths[3])]),
            "Using a table spares it and the next oldest goes instead.");
        $this->assertTrue(isset($tools->table_cache[L\crawlHash($paths[2])]),
            "The recently used table is kept.");
        $tight = new LDS\PackedTableTools($signature);
        $tight->max_table_cache_bytes = $entry_size + 1;
        $tight->getEntryMarkers($paths[1]);
        $this->assertTrue(
            isset($tight->table_entry_markers[L\crawlHash($paths[1])]),
            "Markers are cached when a table is read.");
        $tight->getEntryMarkers($paths[2]);
        $this->assertTrue(
            !isset($tight->table_entry_markers[L\crawlHash($paths[1])]),
            "Evicting a table drops its markers too.");
    }
    /**
     * emptyTableIsSavedTestCase checks that save writes an empty table as
     * an empty file. A partition stopped before any document was saved has
     * empty tables, and its build is whole only once all four of its files
     * exist; when save left them out, a stopped crawl waited for them.
     */
    public function emptyTableIsSavedTestCase()
    {
        $tools = new LDS\PackedTableTools(["PRIMARY KEY" => ["TERM", 8],
            "COUNT" => "INT"]);
        $path = C\TEST_DIR . "/test_files/empty_table_test";
        @unlink($path);
        $this->assertTrue($tools->save($path, []), "saving an empty table " .
            "succeeds");
        $this->assertTrue(file_exists($path), "and leaves its file");
        @unlink($path);
    }
    /**
     * firstRowWithEscapedKeyByteIsFoundTestCase checks that
     * findRowFromKeyTableString finds a table's first row when its key holds
     * a \xFE byte. save writes such a byte as two bytes, and the lookup
     * compared the first row with the key as given, so the row was missed.
     * A partition's index of documents is read this way, and a document id
     * is a hash that may hold such a byte.
     */
    public function firstRowWithEscapedKeyByteIsFoundTestCase()
    {
        $tools = new LDS\PackedTableTools(["PRIMARY KEY" => ["KEY", 16],
            "VALUE" => "INT"]);
        $path = self::TEST_DIR . "/escaped_first_key.txt";
        $first_key = "\xFE" . str_repeat("a", 15);
        $tools->save($path, [$first_key => $tools->pack(["VALUE" => 7]),
            str_repeat("b", 16) => $tools->pack(["VALUE" => 8])]);
        $table_string = $tools->load($path,
            LDS\PackedTableTools::AS_STRING_MODE);
        $row = $tools->findRowFromKeyTableString($table_string, $first_key);
        $this->assertEqual($tools->unpack($row ?? "")[0]["VALUE"] ?? null, 7,
            "The first row is found though its key holds a \\xFE byte");
    }
    /**
     * rowWithKeyOfAnyLengthIsFoundTestCase checks that
     * findRowFromKeyTableString finds rows in a table whose keys vary in
     * length. Such a row holds its key's length before the key, and the
     * lookup searched for the key without it, so it found no row.
     */
    public function rowWithKeyOfAnyLengthIsFoundTestCase()
    {
        $tools = new LDS\PackedTableTools(["PRIMARY KEY" => ["KEY", -1],
            "VALUE" => "INT"]);
        $path = self::TEST_DIR . "/varied_keys.txt";
        $keys = ["apple" => 1, "b" => 2, "cherry_pie" => 3];
        $table = [];
        foreach ($keys as $key => $value) {
            $table[$key] = $tools->pack(["VALUE" => $value]);
        }
        $tools->save($path, $table);
        $table_string = $tools->load($path,
            LDS\PackedTableTools::AS_STRING_MODE);
        $found = [];
        foreach ($keys as $key => $value) {
            $row = $tools->findRowFromKeyTableString($table_string, $key);
            $found[$key] = $tools->unpack($row ?? "")[0]["VALUE"] ?? null;
        }
        $this->assertEqual($found, $keys, "Every key is found with its value");
    }
    /**
     * rowIsAppendedToFileAtPathTestCase checks that add with ADD_FILE_PATH
     * writes rows to the file at the path given, so the file loads back as
     * the table. add called fsize, which PHP does not have, so the first
     * such call stopped with an error.
     */
    public function rowIsAppendedToFileAtPathTestCase()
    {
        $tools = new LDS\PackedTableTools(["PRIMARY KEY" => ["KEY", 16],
            "VALUE" => "INT"]);
        $path = self::TEST_DIR . "/added_by_path.txt";
        $table = [str_repeat("a", 16) => $tools->pack(["VALUE" => 1]),
            str_repeat("b", 16) => $tools->pack(["VALUE" => 2])];
        try {
            foreach ($table as $key => $row) {
                $tools->add($path, $key, $row,
                    LDS\PackedTableTools::ADD_FILE_PATH);
            }
            $loaded = $tools->load($path);
        } catch (\Error $e) {
            $loaded = $e->getMessage();
        }
        $this->assertEqual($loaded, $table, "The two rows load back");
    }
    /**
     * emptyTableLoadsWithNoRowsTestCase checks that a table saved with no
     * rows loads as an empty array. load split the empty string into one
     * row whose key and records were empty, so a partition with no
     * documents gave back a row for an empty key.
     */
    public function emptyTableLoadsWithNoRowsTestCase()
    {
        $tools = new LDS\PackedTableTools(["PRIMARY KEY" => ["KEY", 16],
            "VALUE" => "INT"]);
        $path = self::TEST_DIR . "/no_rows.txt";
        $tools->save($path, []);
        $this->assertEqual($tools->load($path), [], "No rows are loaded");
    }
    /**
     * negativeIntKeepsItsSignTestCase checks that an INT column below -127,
     * which pack writes in two or more bytes, unpacks as the same negative
     * number. pack left out the sign bit for such numbers, so they came back
     * positive.
     */
    public function negativeIntKeepsItsSignTestCase()
    {
        $tools = new LDS\PackedTableTools(["PRIMARY KEY" => ["KEY", 16],
            "SMALL" => "INT", "MIDDLE" => "INT", "LARGE" => "INT"]);
        $record = ["SMALL" => -5, "MIDDLE" => -200, "LARGE" => -70000];
        $this->assertEqual($tools->unpack($tools->pack($record))[0], $record,
            "Each negative number keeps its sign");
    }
    /**
     * eightByteIntBesideOneByteIntsTestCase checks a record of three INT
     * columns whose second needs eight bytes and whose others need one.
     * Their size codes then make the byte 0x30, the character "0", which
     * PHP's empty takes as empty, and unpack read every column as 0.
     */
    public function eightByteIntBesideOneByteIntsTestCase()
    {
        $tools = new LDS\PackedTableTools(["PRIMARY KEY" => ["KEY", 16],
            "FIRST" => "INT", "SECOND" => "INT", "THIRD" => "INT"]);
        $record = ["FIRST" => 1, "SECOND" => 5000000000, "THIRD" => 2];
        $this->assertEqual($tools->unpack($tools->pack($record))[0], $record,
            "Each column unpacks as packed");
    }
    /**
     * rowPastTheLastIsNotFoundTestCase checks that findEntryAtIndexTableName
     * gives the empty string for a row number past a table's last row. For
     * such a number it gave the whole table as if it were one row, so a
     * document map read with a bad number gave a page other than the one
     * asked for.
     */
    public function rowPastTheLastIsNotFoundTestCase()
    {
        $tools = new LDS\PackedTableTools(["PRIMARY KEY" => ["KEY", 16],
            "VALUE" => "INT"]);
        $path = self::TEST_DIR . "/three_rows.txt";
        $table = [];
        foreach (["a", "b", "c"] as $i => $letter) {
            $table[str_repeat($letter, 16)] = $tools->pack(["VALUE" => $i]);
        }
        $tools->save($path, $table);
        $this->assertEqual(substr($tools->findEntryAtIndexTableName($path, 2),
            0, 16), str_repeat("c", 16), "The last row is found");
        $this->assertEqual($tools->findEntryAtIndexTableName($path, 3), "",
            "Row 3 of rows 0 to 2 is not found");
    }
}