diff --git a/core/imageboard/search.php b/core/imageboard/search.php index a5891f125..b89b70a68 100644 --- a/core/imageboard/search.php +++ b/core/imageboard/search.php @@ -65,25 +65,6 @@ public function __construct( } } -class TermConditions -{ - /** @var TagCondition[] */ - public array $tag_conditions = []; - /** @var ImgCondition[] */ - public array $img_conditions = []; - /** @var mixed[] */ - public array $order = []; - public ?int $limit; - - public function addOrder(null|string|QueryBuilderOrder $order): void - { - if(is_null($order) || (is_string($order) && empty($order))) { - return; - } - $this->order[] = $order; - } -} - class Search { /** @@ -118,10 +99,9 @@ private static function find_images_internal(int $start = 0, ?int $limit = null, } } - $queryBuilder = Search::build_search_query_from_terms($tags, $limit, $start); - $query = $queryBuilder->render(); - return $database->get_all_iterable($query->sql, $query->parameters); - + [$tag_conditions, $img_conditions, $order] = self::terms_to_conditions($tags); + $querylet = self::build_search_querylet($tag_conditions, $img_conditions, $order, $limit, $start); + return $database->get_all_iterable($querylet->sql, $querylet->variables); } /** @@ -133,7 +113,6 @@ private static function find_images_internal(int $start = 0, ?int $limit = null, #[Query(name: "posts", type: "[Post!]!", args: ["tags" => "[string!]"])] public static function find_images(int $offset = 0, ?int $limit = null, array $tags = []): array { - $result = self::find_images_internal($offset, $limit, $tags); $images = []; @@ -225,12 +204,9 @@ public static function count_images(array $tags = []): int $cache_key = "image-count:" . md5(Tag::implode($tags)); $total = $cache->get($cache_key); if (is_null($total)) { - $queryBuilder = Search::build_search_query_from_terms($tags); - - $query = $queryBuilder->renderForCount(); - - $total = (int)$database->get_one($query->sql, $query->parameters); - + [$tag_conditions, $img_conditions, $order] = self::terms_to_conditions($tags); + $querylet = self::build_search_querylet($tag_conditions, $img_conditions, null); + $total = (int)$database->get_one("SELECT COUNT(*) AS cnt FROM ($querylet->sql) AS tbl", $querylet->variables); if (SPEED_HAX && $total > 5000) { // when we have a ton of images, the count // won't change dramatically very often @@ -245,13 +221,10 @@ public static function count_images(array $tags = []): int /** * @return list */ - private static function tag_or_wildcard_to_ids(string $tag, bool $include_empty_tags = true): array + private static function tag_or_wildcard_to_ids(string $tag): array { global $database; $sq = "SELECT id FROM tags WHERE LOWER(tag) LIKE LOWER(:tag)"; - if (!$include_empty_tags) { - $sq .= " AND count > 0 "; - } if ($database->get_driver_id() === DatabaseDriverID::SQLITE) { $sq .= "ESCAPE '\\'"; } @@ -259,18 +232,21 @@ private static function tag_or_wildcard_to_ids(string $tag, bool $include_empty_ } /** - * Turn a human input string into an abstract search query + * Turn a human input string into a an abstract search query * * (This is only public for testing purposes, nobody should be calling this * directly from outside this class) * * @param string[] $terms + * @return array{0: TagCondition[], 1: ImgCondition[], 2: string} */ - public static function terms_to_conditions(array $terms): TermConditions + public static function terms_to_conditions(array $terms): array { global $config; - $output = new TermConditions(); + $tag_conditions = []; + $img_conditions = []; + $order = null; /* * Turn a bunch of strings into a bunch of TagCondition @@ -279,184 +255,106 @@ public static function terms_to_conditions(array $terms): TermConditions $stpen = 0; // search term parse event number foreach (array_merge([null], $terms) as $term) { $stpe = send_event(new SearchTermParseEvent($stpen++, $term, $terms)); - if(!is_null($stpe->order)) { - $output->addOrder($stpe->order); - } - $output->limit ??= $stpe->limit; - $output->img_conditions = array_merge($output->img_conditions, $stpe->img_conditions); - $output->tag_conditions = array_merge($output->tag_conditions, $stpe->tag_conditions); - } - - if(empty($output->order)) { - $output->addOrder("images.".$config->get_string(IndexConfig::ORDER)); + $order ??= $stpe->order; + $img_conditions = array_merge($img_conditions, $stpe->img_conditions); + $tag_conditions = array_merge($tag_conditions, $stpe->tag_conditions); } - return $output; - } - - /** - * @param string[] $terms - */ - public static function build_search_query_from_terms(array $terms, ?int $limit = null, ?int $offset = null): QueryBuilder - { - $terms = self::terms_to_conditions($terms); - return self::build_search_query_from_term_conditions($terms, $limit, $offset); - } - - public static function build_search_query_from_term_conditions(TermConditions $t, ?int $limit = null, ?int $offset = null): QueryBuilder - { - $limitToUse = null; - if(!is_null($t->limit)) { - if(!is_null($limit)) { - $limitToUse = min($t->limit, $limit); - } - else { - $limitToUse = $t->limit; - } - } - else { - $limitToUse = $limit; - } + $order = ($order ?: "images.".$config->get_string(IndexConfig::ORDER)); - return self::build_search_query( - $t->tag_conditions, - $t->img_conditions, - $t->order, - $limitToUse, - $offset - ); + return [$tag_conditions, $img_conditions, $order]; } /** - * Turn an abstract search query into an SQL QueryBuilder + * Turn an abstract search query into an SQL Querylet * * (This is only public for testing purposes, nobody should be calling this * directly from outside this class) * * @param TagCondition[] $tag_conditions * @param ImgCondition[] $img_conditions - * @param string|(string|QueryBuilderOrder)[] $orders */ - public static function build_search_query( + public static function build_search_querylet( array $tag_conditions, array $img_conditions, - string|array $orders, + ?string $order = null, ?int $limit = null, - ?int $offset = null, - ): QueryBuilder { - $parsed_orders = []; - if (is_string($orders)) { - $parsed_orders = QueryBuilderOrder::parse($orders); - } else { - foreach ($orders as $o) { - if(is_string($o)) { - $parsed_orders = array_merge($parsed_orders, QueryBuilderOrder::parse($o)); - } else { - $parsed_orders[] = $o; - } - } - } - $orders = $parsed_orders; - - $query = new QueryBuilder("images"); - $query->addSelectField("images.*"); - - $isIdOrdered = false; - $idOrder = null; - foreach ($orders as $order) { - $sourceString = $order->getSourceString(); - if($order->isSourceString() && !empty($sourceString) && !str_contains($sourceString, ".")) { - // This is checking if the source is just a field name, and if it specifies a table - // If it doesn't specify a table, it explicitly declares it being for the images table - $order = new QueryBuilderOrder("images.$sourceString", $order->getAscending()); - } - if($sourceString == "id" || $sourceString == "images.id") { - $isIdOrdered = true; - $idOrder = $order; - } - $query->addQueryBuilderOrder($order); - } - - if (!is_null($limit)) { - $query->limit = $limit; - $query->offset = $offset; - } - - $positive_tag_count = 0; - $negative_tag_count = 0; - foreach ($tag_conditions as $tq) { - if ($tq->positive) { - $positive_tag_count++; - } else { - $negative_tag_count++; - } - } - + ?int $offset = null + ): Querylet { // no tags, do a simple search - if ($positive_tag_count === 0 && $negative_tag_count === 0) { - // Do nothing, use base QueryBuilder by itself - + if (count($tag_conditions) === 0) { + static::$_search_path[] = "no_tags"; + $query = new Querylet("SELECT images.* FROM images WHERE 1=1"); } // one tag sorted by ID - we can fetch this from the image_tags table, // and do the offset / limit there, which is 10x faster than fetching // all the image_tags and doing the offset / limit on the result. elseif ( - ( - ($positive_tag_count === 1 && $negative_tag_count === 0) - || ($positive_tag_count === 0 && $negative_tag_count === 1) - ) + count($tag_conditions) === 1 + && $tag_conditions[0]->positive + // We can only do this if img_conditions is empty, because + // we're going to apply the offset / limit to the image_tags + // subquery, and applying extra conditions to the top-level + // query might reduce the total results below the target limit && empty($img_conditions) - && $isIdOrdered - && !is_null($offset) + // We can only do this if we're sorting by ID, because + // we're going to be using the image_tags table, which + // only has image_id and tag_id, not any other columns + && ($order == "id DESC" || $order == "images.id DESC") + // This is only an optimisation if we are applying limit + // and offset && !is_null($limit) + && !is_null($offset) ) { - - $in = $positive_tag_count === 1 ? "IN" : "NOT IN"; + static::$_search_path[] = "fast"; + $tc = $tag_conditions[0]; // IN (SELECT id FROM tags) is 100x slower than doing a separate // query and then a second query for IN(first_query_results)?? - $tag_array = self::tag_or_wildcard_to_ids($tag_conditions[0]->tag, false); + $tag_array = self::tag_or_wildcard_to_ids($tc->tag); if (count($tag_array) == 0) { - if ($positive_tag_count == 1) { - // An impossible query, short it here - $query->addManualCriterion("1=0"); - return $query; - } + // if wildcard expanded to nothing, take a shortcut + static::$_search_path[] = "invalid_tag"; + $query = new Querylet("SELECT images.* FROM images WHERE 1=0"); } else { $set = implode(', ', $tag_array); - - $tagQuery = new QueryBuilder("image_tags", "it"); - $tagQuery->addSelectField("it.image_id"); - $tagQuery->addManualCriterion("it.tag_id $in ($set)"); - $tagQuery->addOrder("it.image_id", $idOrder->getAscending()); - $tagQuery->limit = $limit; - $tagQuery->offset = $offset; - - $tagJoin = $query->addJoin("INNER", $tagQuery, "a"); - $tagJoin->addManualCriterion("a.image_id = images.id"); - - $query->addOrder("images.id", false); + $query = new Querylet(" + SELECT images.* + FROM images INNER JOIN ( + SELECT DISTINCT it.image_id + FROM image_tags it + WHERE it.tag_id IN ($set) + ORDER BY it.image_id DESC + LIMIT :limit OFFSET :offset + ) a on a.image_id = images.id + WHERE 1=1 + ", ["limit" => $limit, "offset" => $offset]); + // don't offset at the image level because + // we already offset at the image_tags level + $limit = null; + $offset = null; } } - // more than one positive tag, or more than zero negative tags + + // more than one tag, or more than zero other conditions, or a non-default sort order else { + static::$_search_path[] = "general"; $positive_tag_id_array = []; $positive_wildcard_id_array = []; $negative_tag_id_array = []; $all_nonexistent_negatives = true; foreach ($tag_conditions as $tq) { - $tag_ids = self::tag_or_wildcard_to_ids($tq->tag, false); + $tag_ids = self::tag_or_wildcard_to_ids($tq->tag); $tag_count = count($tag_ids); if ($tq->positive) { $all_nonexistent_negatives = false; if ($tag_count == 0) { - # one of the positive tags had zero results, therefore there + # one of the positive tags had zero results, therefor there # can be no results; "where 1=0" should shortcut things - $query->addManualCriterion("1=0"); - - return $query; + static::$_search_path[] = "invalid_tag"; + return new Querylet("SELECT images.* FROM images WHERE 1=0"); } elseif ($tag_count == 1) { // All wildcard terms that qualify for a single tag can be treated the same as non-wildcards $positive_tag_id_array[] = $tag_ids[0]; @@ -466,23 +364,22 @@ public static function build_search_query( $positive_wildcard_id_array[] = $tag_ids; } } else { - if ($tag_count > 0) { - $all_nonexistent_negatives = false; // Unlike positive criteria, negative criteria are all handled in an OR fashion, // so we can just compile them all into a single sub-query. $negative_tag_id_array = array_merge($negative_tag_id_array, $tag_ids); } } - } - assert($positive_tag_id_array || $positive_wildcard_id_array || $negative_tag_id_array || $all_nonexistent_negatives, @$_GET['q']); + assert($positive_tag_id_array || $positive_wildcard_id_array || $negative_tag_id_array || $all_nonexistent_negatives, _get_query()); if ($all_nonexistent_negatives) { - // Not necessary to add a 1=1 with QueryBuilder - } elseif (!empty($positive_tag_id_array) || !empty($positive_wildcard_id_array) || !empty($negative_tag_id_array)) { + static::$_search_path[] = "all_nonexistent_negatives"; + $query = new Querylet("SELECT images.* FROM images WHERE 1=1"); + } elseif (!empty($positive_tag_id_array) || !empty($positive_wildcard_id_array)) { + static::$_search_path[] = "some_positives"; $inner_joins = []; if (!empty($positive_tag_id_array)) { foreach ($positive_tag_id_array as $tag) { @@ -495,80 +392,74 @@ public static function build_search_query( $inner_joins[] = "IN ($positive_tag_id_list)"; } } + + $first = array_shift($inner_joins); + $sub_query = "SELECT DISTINCT it.image_id FROM image_tags it "; $i = 0; - if (!empty($positive_tag_id_array)) { - foreach ($positive_tag_id_array as $tag) { - $join = $query->addJoin("INNER", "image_tags", "it$i"); - $join->addManualCriterion("it$i.tag_id = $tag"); - $join->addManualCriterion("it$i.image_id = images.id"); - $i++; - } + foreach ($inner_joins as $inner_join) { + $i++; + $sub_query .= " INNER JOIN image_tags it$i ON it$i.image_id = it.image_id AND it$i.tag_id $inner_join "; } - - if (!empty($positive_wildcard_id_array)) { - foreach ($positive_wildcard_id_array as $tags) { - $source = new QueryBuilder("image_tags"); - $source->addSelectField("image_id"); - $source->addInCriterion("tag_id", $tags); - $source->addGroup("image_id"); - - $join = $query->addJoin("INNER", $source, "it$i"); - $join->addManualCriterion("it$i.image_id = images.id"); - $i++; - } + if (!empty($negative_tag_id_array)) { + $negative_tag_id_list = join(', ', $negative_tag_id_array); + $sub_query .= " LEFT JOIN image_tags negative ON negative.image_id = it.image_id AND negative.tag_id IN ($negative_tag_id_list) "; } - + $sub_query .= "WHERE it.tag_id $first "; if (!empty($negative_tag_id_array)) { - - $join = $query->addJoin(QueryBuilder::LEFT_JOIN, "image_tags", "negative"); - $join->addManualCriterion("negative.image_id = images.id"); - $join->addInCriterion("negative.tag_id", $negative_tag_id_array); - $query->addManualCriterion("negative.image_id IS NULL"); + $sub_query .= " AND negative.image_id IS NULL"; } + $sub_query .= " GROUP BY it.image_id "; + + $query = new Querylet(" + SELECT images.* + FROM images + INNER JOIN ($sub_query) a on a.image_id = images.id + "); + } elseif (!empty($negative_tag_id_array)) { + static::$_search_path[] = "only_negative_tags"; + $negative_tag_id_list = join(', ', $negative_tag_id_array); + $query = new Querylet(" + SELECT images.* + FROM images + LEFT JOIN image_tags negative ON negative.image_id = images.id AND negative.tag_id in ($negative_tag_id_list) + WHERE negative.image_id IS NULL + "); + } else { + throw new InvalidInput("No criteria specified"); } } /* * Merge all the image metadata searches into one generic querylet * and append to the base querylet with "AND blah" - * Also adds special joins */ - $aliasCount = 0; if (!empty($img_conditions)) { $n = 0; $img_sql = ""; $img_vars = []; foreach ($img_conditions as $iq) { - if(strpos($iq->qlet->sql, "JOIN") === 0) { - // This is a join criteria - $alias = "joinAlias".$aliasCount; - - $sql = str_replace("{alias}", $alias, substr($iq->qlet->sql, 4)); - $source = explode(" ON ", $sql)[0]; - $criteria = explode(" ON ", $sql)[1]; - $join = $query->addJoin(($iq->positive ? "INNER" : QueryBuilder::LEFT_JOIN), $source); - $join->addManualCriterion($criteria); - - if(!$iq->positive) { - $query->addManualCriterion($alias.".image_id IS NULL", $img_vars); - } - - $aliasCount++; - continue; - } - if ($n++ > 0) { - $img_sql .= "\r AND"; + $img_sql .= " AND"; } if (!$iq->positive) { - $img_sql .= "\r NOT"; + $img_sql .= " NOT"; } $img_sql .= " (" . $iq->qlet->sql . ")"; $img_vars = array_merge($img_vars, $iq->qlet->variables); } - $query->addManualCriterion($img_sql, $img_vars); + $query->append(new Querylet(" AND ")); + $query->append(new Querylet($img_sql, $img_vars)); + } + + if(!is_null($order)) { + $query->append(new Querylet(" ORDER BY ".$order)); } + + if (!is_null($limit)) { + $query->append(new Querylet(" LIMIT :limit ", ["limit" => $limit])); + $query->append(new Querylet(" OFFSET :offset ", ["offset" => $offset])); + } + return $query; } - } diff --git a/core/query_builder.php b/core/query_builder.php deleted file mode 100644 index a50b70c37..000000000 --- a/core/query_builder.php +++ /dev/null @@ -1,755 +0,0 @@ -id = "QB" . uniqid(); - } - - public static function IsA(mixed $value): bool - { - return is_object($value) && is_a($value, QueryBuilderBase::CLASS_NAME); - } - - protected function generateParameterId(string $suffix): string - { - return $this->id.$suffix."_"; - } - - abstract public function toSql(bool $omitOrders, bool $humanReadable): string; - /** - * @return mixed[] - */ - abstract public function compileParameters(): array; -} - -class QueryBuilder extends QueryBuilderBase -{ - /** @var string[] */ - private array $selectFields = []; - private string|QueryBuilderBase $source; - private string $sourceAlias; - /** @var QueryBuilderJoin[] */ - public array $joins = []; - public QueryBuilderCriteria $criteria; - /** @var QueryBuilderOrder[] */ - public array $orders = []; - /** @var QueryBuilderGroup[] */ - public array $groups = []; - public int $limit = 0; - public ?int $offset = 0; - - public const CLASS_NAME = "Shimmie2\QueryBuilder"; - public const LEFT_JOIN = "LEFT"; - public const RIGHT_JOIN = "RIGHT"; - public const INNER_JOIN = "INNER"; - public function __construct(string|QueryBuilder $source, string $sourceAlias = "") - { - parent::__construct(); - $this->criteria = new QueryBuilderCriteria(); - $this->source = $source; - $this->sourceAlias = $sourceAlias; - } - - public function crashIt(): void - { - $this->render(false, true)->crashIt(); - } - - public static function IsA(mixed $value): bool - { - return is_object($value) && is_a($value, QueryBuilder::CLASS_NAME); - } - - public function addSelectField(string $name, string $alias = ""): void - { - $result = $name; - if (!empty($alias)) { - $result .= " $alias"; - } - - $this->selectFields[] = $result; - } - - public function clearSelectFields(): void - { - $this->selectFields = []; - } - - public function addJoin(string $type, string|QueryBuilder $source, string $sourceAlias = ""): QueryBuilderJoin - { - $join = new QueryBuilderJoin($type, $source, $sourceAlias); - $this->joins[] = $join; - return $join; - } - - public function addOrder(string|QueryBuilderBase $source, bool $ascending = true): void - { - $order = new QueryBuilderOrder($source, $ascending); - $this->orders[] = $order; - } - public function addQueryBuilderOrder(QueryBuilderOrder $order): void - { - $this->orders[] = $order; - } - public function clearOrder(): void - { - $this->orders = []; - } - - - public function addGroup(string $field): void - { - $order = new QueryBuilderGroup($field); - $this->groups[] = $order; - } - - public function addOrCriteria(): QueryBuilderCriteria - { - $output = new QueryBuilderCriteria("OR"); - $this->criteria->addQueryBuilderCriteria($output); - return $output; - } - - /** - * @param mixed[] $parameters - */ - public function addCriterion(string|QueryBuilderBase $left, string $comparison, string|QueryBuilderBase $right, array $parameters): void - { - $this->criteria->addCriterion($left, $comparison, $right, $parameters); - } - /** - * @param mixed[] $options - */ - public function addInCriterion(string|QueryBuilderBase $left, array $options): void - { - $this->criteria->addInCriterion($left, $options); - } - /** - * @param mixed[] $parameters - */ - public function addManualCriterion(string $statement, array $parameters = []): void - { - $this->criteria->addManualCriterion($statement, $parameters); - } - - public function toSql(bool $omitOrders = false, bool $humanReadable = true): string - { - $output = "SELECT "; - $output .= join(", ", $this->selectFields); - if ($humanReadable) { - $output .= "\r\n"; - } - $output .= " FROM "; - - if (is_object($this->source) && is_a($this->source, self::CLASS_NAME)) { - $output .= "(".$this->source->toSql($omitOrders, $humanReadable).")"; - } elseif (is_object($this->source) && is_a($this->source, QueryBuilderBase::CLASS_NAME)) { - $output .= $this->source->toSql($omitOrders, $humanReadable); - } else { - $output .= " ".$this->source." "; - } - - if (!empty($this->sourceAlias)) { - $output .= " AS ".$this->sourceAlias." "; - } - - if ($humanReadable) { - $output .= "\r\n"; - } - - if (!empty($this->joins)) { - foreach ($this->joins as $join) { - $output .= " ".$join->toSql($omitOrders, $humanReadable)." "; - if ($humanReadable) { - $output .= "\r\n"; - } - } - } - - if (!$this->criteria->isEmpty()) { - $output .= " WHERE "; - $output .= $this->criteria->toSql($omitOrders, $humanReadable); - } - if ($humanReadable) { - $output .= "\r\n"; - } - - if (!empty($this->groups)) { - $output .= " GROUP BY "; - foreach ($this->groups as $group) { - $output .= $group->toSql($omitOrders, $humanReadable); - $output .= ", "; - } - $output = substr($output, 0, strlen($output) - 2); - } - - if (!$omitOrders && !empty($this->orders)) { - $output .= " ORDER BY "; - foreach ($this->orders as $order) { - $output .= $order->toSql($omitOrders, $humanReadable); - $output .= ", "; - } - $output = substr($output, 0, strlen($output) - 2); - } - - if ($this->limit > 0) { - $output .= " LIMIT ". $this->limit; - } - if ($this->offset > 0) { - $output .= " OFFSET ". $this->offset; - } - - - return $output; - } - - /** - * @return mixed[] - */ - public function compileParameters(): array - { - $output = []; - - if(!empty($this->joins)) { - foreach($this->joins as $join) { - $output = array_merge($output, $join->compileParameters()); - } - } - - if (!$this->criteria->isEmpty()) { - $output = array_merge($output, $this->criteria->compileParameters()); - } - - if (is_object($this->source) && is_a($this->source, QueryBuilderBase::CLASS_NAME)) { - $output = array_merge($output, $this->source->compileParameters()); - } - - return $output; - } - - public function render(bool $omitOrders = false, bool $humanReadable = true): RenderedQuery - { - $output = new RenderedQuery(); - $output->sql = $this->toSql($omitOrders, $humanReadable); - $output->parameters = $this->compileParameters(); - return $output; - } - - public function renderForCount(bool $humanReadable = true): RenderedQuery - { - $fieldsTemp = $this->selectFields; - - $this->selectFields = ["1"]; - - $selectQuery = new QueryBuilder($this, "countSubquery"); - $selectQuery->addSelectField("COUNT(*)"); - - $output = $selectQuery->render(true, $humanReadable); - - $this->selectFields = $fieldsTemp; - return $output; - } -} - -class RenderedQuery -{ - public string $sql; - /** @var mixed[] */ - public array $parameters = []; - - public function crashIt(): void - { - var_dump_format($this->parameters, "Parameters"); - var_dump_format($this->sql, "SQL"); - throw new SCoreException("SQL Query dump"); - } -} - -class QueryBuilderCriteria extends QueryBuilderBase -{ - /** @var QueryBuilderBase[] */ - private array $criteria = []; - private string $operator = "AND"; - - public function __construct(string $operator = "AND") - { - parent::__construct(); - - if ($operator !== "AND" && $operator !== "OR") { - throw new SCoreException("operator must be \"AND\" or \"OR\""); - } - $this->operator = $operator; - } - - public function isEmpty(): bool - { - return empty($this->criteria); - } - - /** - * @param mixed[] $parameters - */ - public function addManualCriterion(string $statement, array $parameters): void - { - $this->criteria[] = new ManualQueryBuilderCriterion($statement, $parameters); - } - - public function addQueryBuilderCriteria(QueryBuilderCriteria $criteria): void - { - $this->criteria[] = $criteria; - } - - /** - * @param mixed[] $parameters - */ - public function addCriterion(string|QueryBuilderBase $left, string $comparison, string|QueryBuilderBase $right, array $parameters = []): void - { - $this->criteria[] = new QueryBuilderCriterion($left, $comparison, $right, $parameters); - } - /** - * @param mixed[] $options - */ - public function addInCriterion(string|QueryBuilderBase $left, array $options): void - { - $this->criteria[] = new QueryBuilderInCriterion($left, $options); - } - - - public function toSql(bool $omitOrders, bool $humanReadable): string - { - $output = ""; - if (empty($this->criteria)) { - throw new SCoreException("No criterion set"); - } - - foreach ($this->criteria as $criterion) { - $output .= $criterion->toSql($omitOrders, $humanReadable); - $output .= " ".$this->operator." "; - } - $output = substr($output, 0, strlen($output) - strlen($this->operator) - 2); - - if (sizeof($this->criteria) > 1) { - $output = " ($output) "; - } else { - $output = " $output "; - } - return $output; - } - - /** - * @return mixed[] - */ - public function compileParameters(): array - { - $output = []; - - if (empty($this->criteria)) { - throw new SCoreException("No criterion set"); - } - - foreach ($this->criteria as $criteria) { - $output = array_merge($output, $criteria->compileParameters()); - } - - return $output; - } -} - -class QueryBuilderCriterion extends QueryBuilderBase -{ - private string|QueryBuilderBase $left; - private string|QueryBuilderBase $right; - private string $comparison; - /** @var mixed[] */ - private array $parameters = []; - - /** - * @param mixed[] $parameters - */ - public function __construct(string|QueryBuilderBase $left, string $comparison, string|QueryBuilderBase $right, array $parameters) - { - parent::__construct(); - - $this->left = $left; - $this->comparison = $comparison; - $this->right = $right; - $this->parameters = $parameters; - } - - public function toSql(bool $omitOrders, bool $humanReadable): string - { - if (is_object($this->left) && is_a($this->left, QueryBuilderBase::CLASS_NAME)) { - $output = "(".$this->left->toSql($omitOrders, $humanReadable).")"; - } else { - $output = $this->left; - } - - $output .= " ".$this->comparison." "; - - if (is_object($this->right) && is_a($this->right, QueryBuilderBase::CLASS_NAME)) { - $output .= "(" . $this->right->toSql($omitOrders, $humanReadable) . ")"; - } else { - $output .= $this->right; - } - return $output; - } - - /** - * @return mixed[] - */ - public function compileParameters(): array - { - $output = $this->parameters; - if (is_object($this->left) && is_a($this->left, QueryBuilderBase::CLASS_NAME)) { - $output = array_merge($output, $this->left->compileParameters()); - } - if (is_object($this->right) && is_a($this->right, QueryBuilderBase::CLASS_NAME)) { - $output = array_merge($output, $this->right->compileParameters()); - } - - return $output; - } -} - - -class QueryBuilderInCriterion extends QueryBuilderBase -{ - private string|QueryBuilderBase $left; - /** @var mixed[] */ - private array $options = []; - - /** - * @param mixed[] $options - */ - public function __construct(string|QueryBuilderBase $left, array $options) - { - parent::__construct(); - - $this->id = "QB".uniqid(); - $this->left = $left; - $this->options = $options; - if (empty($options)) { - throw new SCoreException("Options cannot be empty"); - } - } - - private function isSafe(mixed $value): bool - { - if (is_string($value)) { - return false; - } - - if (is_int($value)) { - return true; - } - - // TODO: Other data types - - return false; - } - - public function toSql(bool $omitOrders, bool $humanReadable): string - { - if (is_object($this->left) && is_a($this->left, QueryBuilderBase::CLASS_NAME)) { - $output = "(".$this->left->toSql($omitOrders, $humanReadable).")"; - } else { - $output = $this->left; - } - - $output .= " IN ("; - - for ($i = 0;$i < sizeof($this->options);$i++) { - $value = $this->options[$i]; - - if ($this->isSafe($value)) { - $output .= " ".$value." "; - } else { - $id = $this->generateParameterId(strval($i)); - $output .= " :".$id." "; - } - $output .= ", "; - } - $output = substr($output, 0, strlen($output) - 2); - $output .= ") "; - - return $output; - } - - /** - * @return mixed[] - */ - public function compileParameters(): array - { - $output = []; - - for ($i = 0;$i < sizeof($this->options);$i++) { - $value = $this->options[$i]; - - if (!$this->isSafe($value)) { - $id = $this->generateParameterId(strval($i)); - $output[$id] = $this->options[$i]; - } - } - - return $output; - } -} - - -class ManualQueryBuilderCriterion extends QueryBuilderBase -{ - private string $statement; - /** @var mixed[] */ - private array $parameters = []; - - /** - * @param mixed[] $parameters - */ - public function __construct(string $statement, array $parameters) - { - parent::__construct(); - - $this->statement = $statement; - $this->parameters = $parameters; - } - - public function toSql(bool $omitOrders, bool $humanReadable): string - { - return $this->statement; - } - - /** - * @return mixed[] - */ - public function compileParameters(): array - { - return $this->parameters; - } -} - -class QueryBuilderOrder extends QueryBuilderBase -{ - public ?QueryBuilderBase $sourceBuilder = null; - public string $sourceString; - private ?bool $ascending; - /** @var mixed[] */ - private array $parameters = []; - public const CLASS_NAME = "Shimmie2\QueryBuilderOrder"; - public function __construct(string|QueryBuilderBase $source, ?bool $ascending) - { - parent::__construct(); - if (is_string($source)) { - if(empty($source)) { - throw new SCoreException("Source parameter cannot be empty"); - } - $this->sourceString = $source; - } else { - $this->sourceBuilder = $source; - } - $this->ascending = $ascending; - } - public static function IsA(mixed $value): bool - { - return is_object($value) && is_a($value, QueryBuilderOrder::CLASS_NAME); - } - - public function isSourceString(): bool - { - return is_null($this->sourceBuilder); - } - public function getSourceString(): string - { - if(!$this->isSourceString()) { - return ""; - } - return $this->sourceString; - } - public function getAscending(): bool - { - return $this->ascending; - } - - public function toSql(bool $omitOrders, bool $humanReadable): string - { - if($this->isSourceString()) { - $output = $this->sourceString; - } else { - $output = "(".$this->sourceBuilder->toSql($omitOrders, $humanReadable).")"; - } - - if($this->ascending === true) { - $output .= " ASC "; - } elseif($this->ascending === false) { - $output .= " DESC "; - } - return $output; - } - - /** - * @return QueryBuilderOrder[] - */ - public static function parse(string $input): array - { - $output = []; - if(str_contains($input, "(")|| - str_contains($input, ")")) { - // This means some complex function is going on, just use it as-is - $slices = [$input]; - } - else { - $slices = explode(",", trim($input)); - } - foreach($slices as $slice) { - $slice = trim($slice); - $ascending = true; - if(str_ends_with(strtolower($slice), " desc")) { - $ascending = false; - $slice = substr($slice, 0, strlen($slice) - 5); - } elseif(str_ends_with(strtolower($slice), " asc")) { - $slice = substr($slice, 0, strlen($slice) - 4); - } - $output[] = new QueryBuilderOrder($slice, $ascending); - } - return $output; - } - - /** - * @return mixed[] - */ - public function compileParameters(): array - { - $output = $this->parameters; - if (!$this->isSourceString()) { - $output = array_merge($output, $this->sourceBuilder->compileParameters()); - } - return $output; - } -} - -class QueryBuilderGroup extends QueryBuilderBase -{ - private string|QueryBuilderBase $field; - /** @var mixed[] */ - private array $parameters = []; - - public function __construct(string|QueryBuilderBase $field) - { - parent::__construct(); - $this->field = $field; - } - - public function toSql(bool $omitOrders, bool $humanReadable): string - { - if (is_object($this->field) && is_a($this->field, QueryBuilderBase::CLASS_NAME)) { - $output = "(".$this->field->toSql($omitOrders, $humanReadable).")"; - } else { - $output = $this->field; - } - - return $output; - } - - /** - * @return mixed[] - */ - public function compileParameters(): array - { - $output = $this->parameters; - if (is_object($this->field) && is_a($this->field, QueryBuilderBase::CLASS_NAME)) { - $output = array_merge($output, $this->field->compileParameters()); - } - return $output; - } -} - - -class QueryBuilderJoin extends QueryBuilderBase -{ - private string|QueryBuilder $source; - private string $sourceAlias; - private QueryBuilderCriteria $criteria; - private string $type; - - public function __construct(string $type, string|QueryBuilder $source, string $sourceAlias = "") - { - parent::__construct(); - - $this->criteria = new QueryBuilderCriteria(); - - $this->source = $source; - $this->sourceAlias = $sourceAlias; - - if ($type !== QueryBuilder::INNER_JOIN && $type !== QueryBuilder::LEFT_JOIN && $type !== "RIGHT" && $type !== "LEFT OUTER" && $type !== "RIGHT OUTER") { - throw new SCoreException("Join type \"$type\" not recognized"); - } - $this->type = $type; - } - - public function addOrCriteria(): QueryBuilderCriteria - { - $output = new QueryBuilderCriteria("OR"); - $this->criteria->addQueryBuilderCriteria($output); - return $output; - } - - /** - * @param mixed[] $parameters - */ - public function addCriterion(string|QueryBuilderBase $left, string $comparison, string|QueryBuilderBase $right, array $parameters = []): void - { - $this->criteria->addCriterion($left, $comparison, $right, $parameters); - } - /** - * @param mixed[] $parameters - */ - public function addManualCriterion(string $statement, array $parameters = []): void - { - $this->criteria->addManualCriterion($statement, $parameters); - } - /** - * @param mixed[] $options - */ - public function addInCriterion(string|QueryBuilderBase $left, array $options): void - { - $this->criteria->addInCriterion($left, $options); - } - - public function toSql(bool $omitOrders, bool $humanReadable): string - { - $output = " ".$this->type." JOIN "; - - if (is_object($this->source) && is_a($this->source, QueryBuilderBase::CLASS_NAME)) { - $output .= "(".$this->source->toSql($omitOrders, $humanReadable).")"; - } else { - $output .= $this->source; - } - if (!empty($this->sourceAlias)) { - $output .= " ".$this->sourceAlias." "; - } - - $output .= " ON ". $this->criteria->toSql($omitOrders, $humanReadable); - - return $output; - } - - /** - * @return mixed[] - */ - public function compileParameters(): array - { - $output = $this->criteria->compileParameters(); - if (is_object($this->source) && is_a($this->source, QueryBuilderBase::CLASS_NAME)) { - $output = array_merge($output, $this->source->compileParameters()); - } - return $output; - } -} diff --git a/core/tests/SearchTest.php b/core/tests/SearchTest.php index 808b5c2f9..9a346d617 100644 --- a/core/tests/SearchTest.php +++ b/core/tests/SearchTest.php @@ -72,36 +72,31 @@ public function testUpload(): array * @param string $tags * @param TagCondition[] $expected_tag_conditions * @param ImgCondition[] $expected_img_conditions - * @param mixed[] $expected_order - * @param ?int $expected_limit + * @param string $expected_order */ private function assert_TTC( string $tags, array $expected_tag_conditions, array $expected_img_conditions, - array $expected_order, - ?int $expected_limit + string $expected_order, ): void { $class = new \ReflectionClass(Search::class); $terms_to_conditions = $class->getMethod("terms_to_conditions"); $terms_to_conditions->setAccessible(true); // Use this if you are running PHP older than 8.1.0 $obj = new Search(); - /** @var TermConditions $conditions */ - $conditions = $terms_to_conditions->invokeArgs($obj, [Tag::explode($tags, false)]); + [$tag_conditions, $img_conditions, $order] = $terms_to_conditions->invokeArgs($obj, [Tag::explode($tags, false)]); static::assertThat( [ "tags" => $expected_tag_conditions, "imgs" => $expected_img_conditions, "order" => $expected_order, - "limit" => $expected_limit, ], new IsEqual([ - "tags" => $conditions->tag_conditions, - "imgs" => $conditions->img_conditions, - "order" => $conditions->order, - "limit" => $conditions->limit, + "tags" => $tag_conditions, + "imgs" => $img_conditions, + "order" => $order, ]) ); } @@ -119,8 +114,7 @@ public function testTTC_Empty(): void "true" => true])), new ImgCondition(new Querylet("rating IN ('?', 's', 'q', 'e')", [])), ], - ["images.id DESC"], - null + "images.id DESC" ); } @@ -138,8 +132,7 @@ public function testTTC_Hash(): void new ImgCondition(new Querylet("rating IN ('?', 's', 'q', 'e')", [])), new ImgCondition(new Querylet("images.hash = :hash", ["hash" => "1234567890"])), ], - ["images.id DESC"], - null + "images.id DESC" ); } @@ -158,28 +151,10 @@ public function testTTC_Ratio(): void new ImgCondition(new Querylet("width / :width1 = height / :height1", ['width1' => 42, 'height1' => 12345])), ], - ["images.id DESC"], - null + "images.id DESC" ); } - public function testTTC_Limit(): void - { - $this->assert_TTC( - "limit=12", - [ - ], - [ - new ImgCondition(new Querylet("trash != :true", ["true" => true])), - new ImgCondition(new Querylet("private != :true OR owner_id = :private_owner_id", [ - "private_owner_id" => 1, - "true" => true])), - new ImgCondition(new Querylet("rating IN ('?', 's', 'q', 'e')", [])), - ], - ["images.id DESC"], - 12 - ); - } public function testTTC_Order(): void { $this->assert_TTC( @@ -193,8 +168,7 @@ public function testTTC_Order(): void "true" => true])), new ImgCondition(new Querylet("rating IN ('?', 's', 'q', 'e')", [])), ], - ["images.numeric_score DESC"], - null + "images.numeric_score DESC" ); } @@ -236,15 +210,13 @@ private function assert_BSQ( Search::$_search_path = []; $class = new \ReflectionClass(Search::class); - $build_search_query = $class->getMethod("build_search_query"); - $build_search_query->setAccessible(true); // Use this if you are running PHP older than 8.1.0 + $build_search_querylet = $class->getMethod("build_search_querylet"); + $build_search_querylet->setAccessible(true); // Use this if you are running PHP older than 8.1.0 $obj = new Search(); - /** @var QueryBuilder $query_builder */ - $query_builder = $build_search_query->invokeArgs($obj, [$tcs, $ics, $order, $limit, $start]); - $query = $query_builder->render(); + $querylet = $build_search_querylet->invokeArgs($obj, [$tcs, $ics, $order, $limit, $start]); - $results = $database->get_all($query->sql, $query->parameters); + $results = $database->get_all($querylet->sql, $querylet->variables); static::assertThat( [ diff --git a/core/util.php b/core/util.php index b5557607a..aa4efb598 100644 --- a/core/util.php +++ b/core/util.php @@ -812,30 +812,3 @@ function shm_tempnam(string $prefix = ""): string $temp = \Safe\realpath("data/temp"); return \Safe\tempnam($temp, $prefix); } - -// Acquired from https://stackoverflow.com/questions/139474/how-can-i-capture-the-result-of-var-dump-to-a-string -function return_var_dump(mixed...$args): string -{ - ob_start(); - try { - var_dump(...$args); - $output = ob_get_clean(); - if($output === false) { - return ""; - } - return $output; - } catch (\Throwable $ex) { - // PHP8 ArgumentCountError for 0 arguments, probably.. - // in php<8 this was just a warning - ob_end_clean(); - throw $ex; - } -} - -function var_dump_format(mixed $input, ?string $title = null): void -{ - if(!empty($title)) { - echo "

$title

"; - } - echo "
".html_escape(return_var_dump($input))."

"; -} diff --git a/ext/index/events.php b/ext/index/events.php index 0f7faab9d..02e9ec836 100644 --- a/ext/index/events.php +++ b/ext/index/events.php @@ -19,8 +19,7 @@ class SearchTermParseEvent extends Event public array $img_conditions = []; /** @var TagCondition[] */ public array $tag_conditions = []; - public null|string|QueryBuilderOrder $order = null; - public ?int $limit = null; + public ?string $order = null; /** * @param string[] $context diff --git a/ext/index/main.php b/ext/index/main.php index 9a0f6a547..d1bbfdf39 100644 --- a/ext/index/main.php +++ b/ext/index/main.php @@ -4,7 +4,6 @@ namespace Shimmie2; -use _PHPStan_39fe102d2\Nette\Neon\Exception; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\{InputInterface,InputArgument}; use Symfony\Component\Console\Input\InputOption; @@ -88,7 +87,7 @@ public function onPageRequest(PageRequestEvent $event): void } } if (is_null($images)) { - $images = Search::find_images(offset: ($page_number - 1) * $page_size, limit: $page_size, tags: $search_terms); + $images = Search::find_images(($page_number - 1) * $page_size, $page_size, $search_terms); } $count_images = count($images); @@ -165,22 +164,24 @@ public function onCliGen(CliGenEvent $event): void $limit = $input->getOption('limit'); $count = $input->getOption('count'); + [$tag_conditions, $img_conditions, $order] = Search::terms_to_conditions($search); + if($count) { + $order = null; + $page = null; + $limit = null; + } - $queryBuilder = Search::build_search_query_from_terms( - $search, + $q = Search::build_search_querylet( + $tag_conditions, + $img_conditions, + $order, $limit, - (int)(($page - 1) * $limit) + (int)(($page - 1) * $limit), ); - if($count) { - $q = $queryBuilder->renderForCount(); - } else { - $q = $queryBuilder->render(); - } - $sql_str = $q->sql; $sql_str = preg_replace("/\s+/", " ", $sql_str); - foreach($q->parameters as $key => $val) { + foreach($q->variables as $key => $val) { if(is_string($val)) { $sql_str = str_replace(":$key", "'$val'", $sql_str); } else { @@ -244,13 +245,10 @@ public function onSearchTermParse(SearchTermParseEvent $event): void // recommended to change homepage to "post/list/order:dailyshuffle/1" $seed = (int)date("Ymd"); $event->order = $database->seeded_random($seed, "images.id"); - } elseif (preg_match("/^limit[=:](\d+)$/i", $event->term, $matches)) { - $limit = intval($matches[1]); - $event->limit = $limit; } // If we've reached this far, and nobody else has done anything with this term, then treat it as a tag - if ($event->order === null && $event->limit === null && $event->img_conditions == [] && $event->tag_conditions == []) { + if ($event->order === null && $event->img_conditions == [] && $event->tag_conditions == []) { $event->add_tag_condition(new TagCondition($event->term, $event->positive)); } } diff --git a/ext/index/theme.php b/ext/index/theme.php index 03105e373..4e10960b7 100644 --- a/ext/index/theme.php +++ b/ext/index/theme.php @@ -277,12 +277,6 @@ public function get_help_html(): HTMLElement P("Direction can be either asc or desc, indicating ascending (123) or descending (321) order."), SHM_COMMAND_EXAMPLE("order:id_asc", "Returns posts sorted by ID, smallest first."), SHM_COMMAND_EXAMPLE("order:width_desc", "Returns posts sorted by width, largest first."), - // - // - // - HR(), - H3("Limit the number of search results"), - SHM_COMMAND_EXAMPLE("limit=100", "Only returns the first 100 results"), ); } }