<?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\models;

use seekquarry\yioop\configs as C;
use seekquarry\yioop\library as L;
use seekquarry\yioop\library\CrawlConstants;
use seekquarry\yioop\library\FetchUrl;
use seekquarry\yioop\library\UrlParser;

/**  For crawlHash function and Yioop Project constants */
require_once __DIR__."/../library/Utility.php";
/**
 *
 * This is a base class for all models
 * in the SeekQuarry search engine. It provides
 * support functions for formatting search results
 *
 * @author Chris Pollett
 */
class Model implements CrawlConstants
{
    /**
     * Default minimum characters of title prefix kept verbatim at the
     * start of a snippet before highlighted match-windows begin
     */
    const SNIPPET_TITLE_LENGTH = 20;
    /**
     * Hard cap on the leading title-prefix region of a snippet
     */
    const MAX_SNIPPET_TITLE_LENGTH = 20;
    /**
     * Number of characters kept to the left of a match term when
     * building a search-result snippet window
     */
    const SNIPPET_LENGTH_LEFT = 20;
    /**
     * Number of characters kept to the right of a match term when
     * building a search-result snippet window
     */
    const SNIPPET_LENGTH_RIGHT = 40;
    /**
     * Minimum total snippet length before the snippet generator gives
     * up trying to build match windows and falls back to a prefix
     */
    const MIN_SNIPPET_LENGTH = 100;
    /**
     * Default maximum character length of a search summary
     */
    const DEFAULT_DESCRIPTION_LENGTH = 150;
    /** Reference to a DatasourceManager
     * @var object
     */
    public $db;
    /** Reference to a private DatasourceManager
     * @var object
     */
    public $private_db;
    /** Name of the private search engine database
     * @var string
     */
    public $private_db_name;
    /**
     * Associative array of page summaries which might be used to
     * override default page summaries if set.
     * @var array
     */
    public $edited_page_summaries = null;
    /**
     * These fields if present in $search_array (used by @see getRows() ),
     * but with value "-1", will be skipped as part of the where clause
     * but will be used for order by clause
     * @var array
     */
    public $any_fields = [];
    /**
     * Associations of the form
     *     name of field for web forms => database column names/abbreviations
     * @var array
     */
    public $search_table_column_map = [];
    /**
     * Cache object to be used if we are doing caching
     * @var object
     */
    public static $cache;
    /**
     * Sets up the database manager that will be used and name of the search
     * engine database
     *
     * @param string $db_name the name of the search engine database
     * @param bool $connect whether to connect to the database by
     *      default after making the datasource class
     * @param WebSite $web_site an optional object that might be used
     *      to serve webpages when Yioop run in CLI mode. This object
     *      has fileGetContents and filePutContents methods which
     *      allow RAM caching of files.
     */
    public function __construct(public $db_name = C\DB_NAME,
        $connect = true, public $web_site = null)
    {
        $db_class = C\NS_DATASOURCES . ucfirst(C\p('DBMS')). "Manager";
        $private_db_class = C\NS_DATASOURCES . ucfirst(C\p('PRIVATE_DBMS')).
            "Manager";
        $this->db = new $db_class();
        $this->private_db = new $private_db_class();
        if ($connect) {
            $this->db->connect();
            $this->private_db->connect(C\p('PRIVATE_DB_HOST'),
                C\p('PRIVATE_DB_USER'),
                C\p('PRIVATE_DB_PASSWORD'), C\p('PRIVATE_DB_NAME'));
        }
        $this->private_db_name = C\p('PRIVATE_DB_NAME');
    }
    /**
     * Either a wrapper for file_get_contents, or if a WebSite object is being
     * used to serve pages, it reads it in using blocking I/O
     * file_get_contents() and caches it before return its string contents.
     * Note this function assumes that only the web server is performing I/O
     * with this file. filemtime() can be used to see if a file on disk has been
     * changed and then you can use $force_read = true below to force re-
     * reading the file into the cache
     *
     * @param string $filename name of file to get contents of
     * @param bool $force_read whether to force the file to be read from
     *      persistent storage rather than the cache
     * @return string contents of the file given by $filename
     */
    public function fileGetContents($filename, $force_read = false)
    {
        if (!empty($this->web_site)) {
            return $this->web_site->fileGetContents($filename, $force_read);
        }
        return file_get_contents($filename);
    }
    /**
     * Either a wrapper for file_put_contents, or if a WebSite object is being
     * used to serve pages, writes $data to the persistent file with name
     * $filename. Saves a copy in the RAM cache if there is a copy already
     * there.
     *
     * @param string $filename name of file to write to persistent storages
     * @param string $data string of data to store in file
     * @return int|false bytes written on success, false on failure
     *      (return value of either WebSite::filePutContents or
     *      file_put_contents depending on the run mode)
     */
    public function filePutContents($filename, $data)
    {
        if (!empty($this->web_site)) {
            return $this->web_site->filePutContents($filename, $data);
        }
        return file_put_contents($filename, $data);
    }
    /**
     * Creates a  directory and sets it to world permission if it doesn't
     * already exist
     *
     * @param string $directory name of directory to create
     * @return int -1 on failure, 0 if already existed, 1 if created
     */
    public function createIfNecessaryDirectory($directory)
    {
        if (file_exists($directory)) {
            return 0;
        } else {
            set_error_handler(null);
            @mkdir($directory);
            @chmod($directory, 0777);
            restore_error_handler();
        }
        if (file_exists($directory)) {
            return 1;
        }
        return -1;
    }
    /**
     * Given a page summary, extracts snippets which
     * are related to a set of search words. For each snippet, bold faces the
     * search terms, and then creates a new summary array.
     *
     * @param array $page a single search result summary
     * @param array $words keywords (typically what was searched on)
     * @param int $description_length length of the description
     * @return array $page which has been snippified and bold faced
     */
    public function formatSinglePageResult($page, $words = null,
        $description_length = self::DEFAULT_DESCRIPTION_LENGTH)
    {
        if (empty($page[self::TITLE])) {
            $page[self::TITLE] = "";
        }
        $page[self::TITLE] = strip_tags($page[self::TITLE]);
        $page[self::DESCRIPTION] = strip_tags(
            preg_replace("/\<\s+([a-zA-Z])/", '<$1',
            $page[self::DESCRIPTION]));
        if (strlen($page[self::TITLE]) == 0) {
            $offset = min(mb_strlen($page[self::DESCRIPTION]),
                self::SNIPPET_TITLE_LENGTH);
            $end_title = mb_strpos($page[self::DESCRIPTION], " ", $offset);
            $ellipsis = "";
            if ($end_title > self::SNIPPET_TITLE_LENGTH) {
                $ellipsis = "...";
                if ($end_title > self::MAX_SNIPPET_TITLE_LENGTH) {
                    $end_title = self::MAX_SNIPPET_TITLE_LENGTH;
                }
            }
            $page[self::TITLE] = mb_substr($page[self::DESCRIPTION], 0,
                $end_title) . $ellipsis;
            //still no text revert to url
            if (strlen($page[self::TITLE]) == 0 &&
                isset($page[self::URL])) {
                $page[self::TITLE] = $page[self::URL];
            }
        }
        // do a little cleaning on text
        if ($words != null) {
            $page[self::TITLE] =
                $this->boldKeywords($page[self::TITLE], $words);
            if (!isset($page[self::IS_FEED])) {
                $page[self::DESCRIPTION] =
                    $this->getSnippets($page[self::DESCRIPTION],
                    $words, $description_length);
            }
            $page[self::DESCRIPTION] =
                $this->boldKeywords($page[self::DESCRIPTION], $words);
        } else {
            $page[self::DESCRIPTION] = mb_substr($page[self::DESCRIPTION],
                0, $description_length);
            $test_snippet = preg_replace('/[^\s]+$/u', "",
                $page[self::DESCRIPTION]);
            if(!empty($test_snippet)) {
                $page[self::DESCRIPTION] = $test_snippet;
            }
        }
        $page[self::TITLE] = trim($page[self::TITLE], " .");
        $pre_description = preg_replace("/\p{C}+|^[^\p{L}]+/u", "",
            $page[self::DESCRIPTION]);
        $page[self::DESCRIPTION] = (str_starts_with($pre_description, "b>")) ?
            "<" . $pre_description : $pre_description;
        return $page;
    }
    /**
     * Given a string, extracts a snippets of text related to a given set of
     * key words. For a given word a snippet is a window of characters to its
     * left and right that is less than a maximum total number of characters.
     * There is also a rule that a snippet should avoid ending in the middle of
     * a word
     *
     * @param string $text haystack to extract snippet from
     * @param array $words keywords used to make look in haystack
     * @param string $description_length length of the description desired
     * @return string a concatenation of the extracted snippets of each word
     */
    public function getSnippets($text, $words, $description_length)
    {
        static $search_words = [];
        static $last_words = "";
        static $word_regex = "";
        if (mb_strlen($text) < $description_length) {
            return $text;
        }
        if (empty($words)) {
            $snippet_string = mb_substr($text, 0, $description_length);
            $rpos = strrpos($snippet_string, " ");
            if ($rpos) {
                $snippet_string = mb_substr($snippet_string, 0, $rpos);
            }
            return $snippet_string;
        }
        $word_string = implode(" ",  $words);
        $words_change = false;
        if ($word_string != $last_words) {
            $words_change = true;
            $last_words = $word_string;
        }
        $start_regex = "/";
        $left = self::SNIPPET_LENGTH_LEFT;
        $left3 = $left - 3;
        $right = self::SNIPPET_LENGTH_RIGHT;
        //note we don't want ' to count as the cause of a word boundary
        $start_regex2 = "/\b(\w{3}.{0,$left3})?(?:(?:";
        $end_regex = "/ui";
        $end_regex2 = ").{0,$right}\b)+/ui";
        $ellipsis = "";
        if ($words_change || empty($search_words)) {
            // original list of words might have had space separated phrases;
            $search_words = array_filter(array_unique(
                explode(" ", $word_string)));
            $word_regex = "";
            $delim = "";
            foreach ($search_words as $word) {
                //term must start on word boundary and end within 3 chars of one
                $word_regex .= $delim . "\b" . preg_quote($word, '/') .
                    ".{0,3}?\b";
                $delim = "|";
            }
        }
        $snippet_string = "";
        $text_sources = array_filter(array_unique(explode(".. ", $text)));
        foreach ($text_sources as $text_source) {
            $len = mb_strlen($text_source);
            $offset = 0;
            if ($len < self::MIN_SNIPPET_LENGTH) {
                if (preg_match($start_regex . $word_regex .
                    $end_regex, $text_source, $match)) {
                    if (stristr($snippet_string, $text_source) === false) {
                        $snippet_string .= $ellipsis . $text_source;
                        $ellipsis = " ... ";
                    }
                }
            } else {
                preg_match_all($start_regex2 . $word_regex . $end_regex2,
                    $text_source, $matches);
                if (isset($matches[0])) {
                    $seen_match = [];
                    foreach ($matches[0] as $match) {
                        $match = trim($match, ".");
                        if (stristr($snippet_string, $match) === false) {
                            $snippet_string .= $ellipsis. $match;
                            $ellipsis = " ... ";
                            if (mb_strlen($snippet_string) >=
                                $description_length) {
                                break;
                            }
                        }
                    }
                }
            }
            if (mb_strlen($snippet_string) >= $description_length) {
                $snippet_string = mb_substr($snippet_string, 0,
                    $description_length);
                $test_snippet = preg_replace('/[^\s]+$/', "",
                    $snippet_string);
                if(!empty($test_snippet)) {
                    $snippet_string = $test_snippet;
                }
                break;
            }
        }
        return $snippet_string;
    }
    /**
     * Given a string, wraps in bold html tags a set of key words it contains.
     *
     * @param string $text haystack string to look for the key words
     * @param array $words an array of words to bold face
     *
     * @return string  the resulting string after boldfacing has been applied
     */
    public function boldKeywords($text, $words)
    {
        $words = array_unique($words);
        foreach ($words as $word) {
            if ($word != "" && !stristr($word, "/")) {
                $pattern = '/(\b)(' . preg_quote($word, '/').'.{0,3}?)(\b)/i';
                $new_text = preg_replace($pattern, '$1<b>$2</b>$3', $text);
                $text = $new_text;
            }
        }
        return $text;
    }
    /**
     * Gets a list of all DBMS that work with the search engine
     *
     * @return array Names of available data sources
     */
    public function getDbmsList()
    {
        $list = [];
        $data_managers = glob(C\BASE_DIR.'/models/datasources/*Manager.php');

        foreach ($data_managers as $data_manager) {
            $dbms =
                substr($data_manager,
                    strlen(C\BASE_DIR.'/models/datasources/'), -
                    strlen("Manager.php"));
            if ($dbms != 'Datasource') {
                $list[] = $dbms;
            }
        }
        return $list;
    }
    /**
     * Returns whether the provided dbms needs a login and password or not
     * (sqlite or sqlite3)
     *
     * @param string $dbms the name of a database management system
     * @return bool true if needs a login and password; false otherwise
     */
    public function loginDbms($dbms)
    {
        return !in_array($dbms, ["Sqlite3"]);
    }
    /**
     * Used to determine if an action involves just one yioop instance on
     * the current local machine or not
     *
     * @param array $machine_urls urls of yioop instances to which the action
     *     applies
     * @param string $index_timestamp if timestamp exists checks if the index
     *     has declared itself to be a no network index.
     * @return bool whether it involves a single local yioop instance (true)
     *     or not (false)
     */
    public function isSingleLocalhost($machine_urls, $index_timestamp = -1)
    {
        if ($index_timestamp >= 0) {
            $index_archive_name = self::index_data_base_name . $index_timestamp;
            if (file_exists(C\CACHE_DIR .
                "/$index_archive_name/no_network.txt")) {
                return true;
            }
        }
        $name_path = UrlParser::getPath(C\p('NAME_SERVER'));
        $name_port = UrlParser::getPort(C\p('NAME_SERVER'));
        foreach ($machine_urls as $url) {
            if (!UrlParser::isLocalhostUrl($url) ||
                $name_path != UrlParser::getPath($url) ||
                $name_port != UrlParser::getPort($url)) {
                return false;
            }
        }
        if (is_array($machine_urls) && count($machine_urls) == 1 &&
            C\p('NAME_SERVER') == $machine_urls[0]) {
            $mirror_table_name = C\WORK_DIRECTORY . "/" .
                self::mirror_table_name;
            if (file_exists($mirror_table_name) &&
                time() - filemtime($mirror_table_name) <
                2 * C\MIRROR_NOTIFY_FREQUENCY) {
                return false;
            }
        }
        return true;
    }
    /**
     * Used to get the translation of a string_id stored in the database to
     * the given locale.
     *
     * @param string $string_id id to translate
     * @param string $locale_tag to translate to
     * @return mixed translation if found, $string_id, otherwise
     */
    public function translateDb($string_id, $locale_tag)
    {
        static $lookup = [];
        $db = $this->db;
        if (isset($lookup[$string_id])) {
            return $lookup[$string_id];
        }
        $sql = "
            SELECT TL.TRANSLATION AS TRANSLATION
            FROM TRANSLATION T, LOCALE L, TRANSLATION_LOCALE TL
            WHERE T.IDENTIFIER_STRING = :string_id AND
                L.LOCALE_TAG = :locale_tag AND
                L.LOCALE_ID = TL.LOCALE_ID AND
                T.TRANSLATION_ID = TL.TRANSLATION_ID " . $db->limitOffset(1);
        $result = $db->execute($sql,
            [":string_id" => $string_id, ":locale_tag" => $locale_tag]);
        $row = $db->fetchArray($result);
        if (isset($row['TRANSLATION'])) {
            return $row['TRANSLATION'];
        }
        return $string_id;
    }
    /**
     * Get the user_id associated with a given username
     * (In base class as used as an internal method in both signin and
     *  user models)
     *
     * @param string $username the username to look up
     * @return string the corresponding userid
     */
    public function getUserId($username)
    {
        $db = $this->db;
        $sql = "SELECT USER_ID FROM USERS WHERE
            LOWER(USER_NAME) = LOWER(?) ". $db->limitOffset(1);
        $result = $db->execute($sql, [$username]);
        if (!$result) {
            return false;
        }
        $row = $db->fetchArray($result);
        $user_id = $row['USER_ID'] ?? false;
        return $user_id;
    }
    /**
     * Creates the WHERE and ORDER BY clauses for a query of a Yioop
     * table such as USERS, ROLE, GROUP, which have associated search web
     * forms. Searches are case insensitive
     *
     * @param array $search_array each element of this is a quadruple
     *     name of a field, what comparison to perform, a value to check,
     *     and an order (ascending/descending) to sort by
     * @param array $any_fields these fields if present in search array
     *     but with value "-1" will be skipped as part of the where clause
     *     but will be used for order by clause
     * @return array string for where clause, string for order by clause
     */
    public function searchArrayToWhereOrderClauses($search_array,
        $any_fields = ['status'])
    {
        $db = $this->db;
        $where = "";
        $order_by = "";
        $order_by_comma = "";
        $where_and = "";
        $sort_types = ["ASC", "DESC"];
        foreach ($search_array as $row) {
            if (isset($this->search_table_column_map[$row[0]])) {
                $field_name = $this->search_table_column_map[$row[0]];
            } else {
                $field_name = $row[0];
            }
            if (in_array($row[1], ['BETWEEN', 'NOT_BETWEEN'])) {
                list(, $comparison, $value_low, $value_high, $sort_dir,) = $row;
                if ($value_low != "" || $value_high != "") {
                    if ($where == "") {
                        $where = " WHERE ";
                    }
                    $where .= $where_and;
                    if ($value_low != "" && $value_high == "") {
                        $where .= "$field_name >= '" .
                            $db->escapeString($value_low) . "'";
                    } else if ($value_low == "" && $value_high != "") {
                        $where .= "$field_name <= '" .
                            $db->escapeString($value_high) . "'";
                    } else if ($value_low != "" && $value_high != "") {
                        $where .= $field_name . " ". $comparison . " '" .
                            $db->escapeString($value_low) . "' AND '".
                            $db->escapeString($value_high) . "'";
                    }
                    $where_and = " AND ";
                }
            } else {
                list(, $comparison, $value, $sort_dir,) = $row;
                $trim_value = (is_string($value)) ? trim($value) : $value;
                if ((!empty($trim_value) || $value === "0") &&
                    (!in_array($row[0], $any_fields)
                    || $value != "-1")) {
                    if ($where == "") {
                        $where = " WHERE ";
                    }
                    $where .= $where_and;
                    switch ($comparison) {
                        case "=":
                        case "!=":
                        case "<":
                        case ">":
                        case "<=":
                        case ">=":
                             $where .= "$field_name$comparison'" .
                                $db->escapeString($value) . "'";
                            break;
                        case "IN":
                            if (!is_array($value)) {
                                break;
                            }
                            $where .= "$field_name IN (";
                            $comma = "";
                            foreach ($value as $possibility) {
                                $where .= $comma . "'" .
                                    $db->escapeString($possibility) . "'";
                                $comma = ",";
                            }
                            $where .= ")";
                            break;
                        case "CONTAINS":
                             $where .= "LOWER($field_name) LIKE LOWER('%".
                                $db->escapeString($value)."%')";
                            break;
                        case "NOT CONTAINS":
                             $where .= "NOT LOWER($field_name) LIKE LOWER('%".
                                $db->escapeString($value)."%')";
                            break;
                        case "BEGINS WITH":
                             $where .= "LOWER($field_name) LIKE LOWER('".
                                $db->escapeString($value)."%')";
                            break;
                        case "NOT BEGINS WITH":
                             $where .= "NOT LOWER($field_name) LIKE LOWER('".
                                $db->escapeString($value)."%')";
                            break;
                        case "ENDS WITH":
                             $where .= "LOWER($field_name) LIKE LOWER('%".
                                $db->escapeString($value)."')";
                            break;
                        case "NOT ENDS WITH":
                             $where .= "NOT LOWER($field_name) LIKE LOWER('%".
                                $db->escapeString($value)."')";
                            break;
                    }
                    $where_and = " AND ";
                }
            }
            if (in_array($sort_dir, $sort_types)) {
                if ($order_by == "") {
                    $order_by = " ORDER BY ";
                }
                $order_by .= $order_by_comma . $field_name . " " . $sort_dir;
                $order_by_comma = ", ";
            }
        }
        return [$where, $order_by];
    }
    /**
     * Gets a range of rows which match the provided search criteria from
     * $th provided table
     *
     * @param int $limit starting row from the potential results to return
     * @param int $num number of rows after start row to return
     * @param int &$total gets set with the total number of rows that
     *     can be returned by the given database query
     * @param array $search_array each element of this is a
     *     quadruple name of a field, what comparison to perform, a value to
     *     check, and an order (ascending/descending) to sort by
     * @param array $args additional values which may be used to get rows
     *      (what these are will typically depend on the subclass
     *      implementation)
     * @return array list of result rows from the underlying table
     *      after applying $search_array filters and $limit/$num
     *      paging; $total is mutated by reference with the unfiltered
     *      count
     */
    public function getRows($limit, $num, &$total,
        $search_array = [], $args = null)
    {
        $db = $this->db;
        $tables = $this->fromCallback($args);
        $limit = $db->limitOffset($limit, $num);
        list($where, $order_by) =
            $this->searchArrayToWhereOrderClauses($search_array,
            $this->any_fields);
        $more_conditions = $this->whereCallback($args);
        if ($more_conditions) {
            $add_where = " WHERE ";
            if ($where != "") {
                $add_where = " AND ";
            }
            $where .= $add_where. $more_conditions;
        }
        $count_column = "*";
        if (isset($this->search_table_column_map['key'])) {
            $count_column = "DISTINCT " . $this->search_table_column_map['key'];
        }
        $sql = "SELECT COUNT($count_column) AS NUM FROM $tables $where";
        $result = $db->execute($sql);
        $row = $db->fetchArray($result);
        $total = $row['NUM'] ?? 0;
        $select_columns = $this->selectCallback($args);
        $sql = "SELECT $select_columns FROM ".
            "$tables $where $order_by $limit";
        $result = $db->execute($sql);
        $i = 0;
        $rows = [];
        $row_callback = false;
        if ($result) {
            while ($rows[$i] = $db->fetchArray($result)) {
                $rows[$i] = $this->rowCallback($rows[$i], $args);
                $i++;
            }
            unset($rows[$i]); //last one will be null
        }
        $rows = $this->postQueryCallback($rows);
        return $rows;
    }
    /**
     * Controls which columns and the names of those columns from the tables
     * underlying the given model should be return from a getRows call.
     * This defaults to *, but in general will be overridden in subclasses of
     * Model
     *
     * @param mixed $args any additional arguments which should be used to
     *     determine the columns
     * @return string a comma separated list of columns suitable for a SQL
     *     query
     */
    public function selectCallback($args  = null)
    {
        return "*";
    }
    /**
     * Controls which tables and the names of tables
     * underlie the given model and should be used in a getRows call
     * This defaults to the single table whose name is whatever is before
     * Model in the name of the model. For example, by default on FooModel
     * this method would return "FOO". If a different behavior, this can be
     * overridden in subclasses of Model
     *
     * @param mixed $args any additional arguments which should be used to
     *     determine these tables
     * @return string a comma separated list of tables suitable for a SQL
     *     query
     */
    public function fromCallback($args  = null)
    {
        $name = strtoupper(get_class($this));
        $name = substr($name, strlen(C\NS_MODELS), -strlen("Model"));
        return $name;
    }
    /**
     * Controls the WHERE clause of the SQL query that
     * underlies the given model and should be used in a getRows call.
     * This defaults to an empty WHERE clause.
     *
     * @param mixed $args additional arguments that might be used to construct
     *     the WHERE clause.
     * @return string a SQL WHERE clause
     */
    public function whereCallback($args = null)
    {
        return "";
    }
    /**
     * Called after as row is retrieved by getRows from the database to
     * perform some manipulation that would be useful for this model.
     * For example, in CrawlModel, after a row representing a crawl mix
     * has been gotten, this is used to perform an additional query to marshal
     * its components. By default this method just returns this row unchanged.
     *
     * @param array $row row as retrieved from database query
     * @param mixed $args additional arguments that might be used by this
     *     callback
     * @return array $row after callback manipulation
     */
    public function rowCallback($row, $args)
    {
        return $row;
    }
    /**
     * Called after getRows has retrieved all the rows that it would retrieve
     * but before they are returned to give one last place where they could
     * be further manipulated. For example, in MachineModel this callback
     * is used to make parallel network calls to get the status of each machine
     * returned by getRows. The default for this method is to leave the
     * rows that would be returned unchanged
     *
     * @param array $rows that have been calculated so far by getRows
     * @return array $rows after this final manipulation
     *
     */
    public function postQueryCallback($rows)
    {
        return $rows;
    }
}
