diff --git a/core/imageboard/search.php b/core/imageboard/search.php index b89b70a68..48df0c5f7 100644 --- a/core/imageboard/search.php +++ b/core/imageboard/search.php @@ -65,6 +65,25 @@ 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 { /** @@ -99,9 +118,10 @@ private static function find_images_internal(int $start = 0, ?int $limit = null, } } - [$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); + $queryBuilder = Search::build_search_query_from_terms($tags, $limit, $start); + $query = $queryBuilder->render(); + return $database->get_all_iterable($query->sql, $query->parameters); + } /** @@ -113,6 +133,7 @@ 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 = []; @@ -204,9 +225,12 @@ 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)) { - [$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); + $queryBuilder = Search::build_search_query_from_terms($tags); + + $query = $queryBuilder->renderForCount(); + + $total = (int)$database->get_one($query->sql, $query->parameters); + if (SPEED_HAX && $total > 5000) { // when we have a ton of images, the count // won't change dramatically very often @@ -232,21 +256,18 @@ private static function tag_or_wildcard_to_ids(string $tag): array } /** - * Turn a human input string into a an abstract search query + * Turn a human input string into 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): array + public static function terms_to_conditions(array $terms): TermConditions { global $config; - $tag_conditions = []; - $img_conditions = []; - $order = null; + $output = new TermConditions(); /* * Turn a bunch of strings into a bunch of TagCondition @@ -255,88 +276,172 @@ public static function terms_to_conditions(array $terms): array $stpen = 0; // search term parse event number foreach (array_merge([null], $terms) as $term) { $stpe = send_event(new SearchTermParseEvent($stpen++, $term, $terms)); - $order ??= $stpe->order; - $img_conditions = array_merge($img_conditions, $stpe->img_conditions); - $tag_conditions = array_merge($tag_conditions, $stpe->tag_conditions); + 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 = ($order ?: "images.".$config->get_string(IndexConfig::ORDER)); + 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; + } - return [$tag_conditions, $img_conditions, $order]; + return self::build_search_query( + $t->tag_conditions, + $t->img_conditions, + $t->order, + $limitToUse, + $offset + ); } /** - * Turn an abstract search query into an SQL Querylet + * Turn an abstract search query into an SQL QueryBuilder * * (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_querylet( + public static function build_search_query( array $tag_conditions, array $img_conditions, - ?string $order = null, + string|array $orders, ?int $limit = null, - ?int $offset = null - ): Querylet { + ?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.*"); + // no tags, do a simple search if (count($tag_conditions) === 0) { static::$_search_path[] = "no_tags"; - $query = new Querylet("SELECT images.* FROM images WHERE 1=1"); + } + + $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++; + } + } + + // no tags, do a simple search + if ($positive_tag_count === 0 && $negative_tag_count === 0) { + // Do nothing, use base QueryBuilder by itself + } // 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 ( - 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 + ( + ($positive_tag_count === 1 && $negative_tag_count === 0) + || ($positive_tag_count === 0 && $negative_tag_count === 1) + ) && empty($img_conditions) - // 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) + && $isIdOrdered && !is_null($offset) + && !is_null($limit) ) { static::$_search_path[] = "fast"; - $tc = $tag_conditions[0]; + + $in = $positive_tag_count === 1 ? "IN" : "NOT IN"; // 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($tc->tag); + $tag_array = self::tag_or_wildcard_to_ids($tag_conditions[0]->tag); if (count($tag_array) == 0) { - // if wildcard expanded to nothing, take a shortcut - static::$_search_path[] = "invalid_tag"; - $query = new Querylet("SELECT images.* FROM images WHERE 1=0"); + if ($positive_tag_count == 1) { + static::$_search_path[] = "invalid_tag"; + + // An impossible query, short it here + $query->addManualCriterion("1=0"); + return $query; + } } else { $set = implode(', ', $tag_array); - $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; + + $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); } } - - // more than one tag, or more than zero other conditions, or a non-default sort order + // more than one positive tag, or more than zero negative tags else { static::$_search_path[] = "general"; $positive_tag_id_array = []; @@ -351,10 +456,12 @@ public static function build_search_querylet( if ($tq->positive) { $all_nonexistent_negatives = false; if ($tag_count == 0) { - # one of the positive tags had zero results, therefor there + # one of the positive tags had zero results, therefore there # can be no results; "where 1=0" should shortcut things static::$_search_path[] = "invalid_tag"; - return new Querylet("SELECT images.* FROM images WHERE 1=0"); + $query->addManualCriterion("1=0"); + + return $query; } 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]; @@ -364,21 +471,24 @@ public static function build_search_querylet( $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_query()); + assert($positive_tag_id_array || $positive_wildcard_id_array || $negative_tag_id_array || $all_nonexistent_negatives, @$_GET['q']); if ($all_nonexistent_negatives) { + // Not necessary to add a 1=1 with QueryBuilder 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)) { + } elseif (!empty($positive_tag_id_array) || !empty($positive_wildcard_id_array) || !empty($negative_tag_id_array)) { static::$_search_path[] = "some_positives"; $inner_joins = []; if (!empty($positive_tag_id_array)) { @@ -392,74 +502,80 @@ public static function build_search_querylet( $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; - 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_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++; + } } - 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) "; + + 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++; + } } - $sub_query .= "WHERE it.tag_id $first "; + if (!empty($negative_tag_id_array)) { - $sub_query .= " AND negative.image_id IS NULL"; + + $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 .= " 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 .= " AND"; + $img_sql .= "\r AND"; } if (!$iq->positive) { - $img_sql .= " NOT"; + $img_sql .= "\r NOT"; } $img_sql .= " (" . $iq->qlet->sql . ")"; $img_vars = array_merge($img_vars, $iq->qlet->variables); } - $query->append(new Querylet(" AND ")); - $query->append(new Querylet($img_sql, $img_vars)); - } - - if(!is_null($order)) { - $query->append(new Querylet(" ORDER BY ".$order)); + $query->addManualCriterion($img_sql, $img_vars); } - - 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 new file mode 100644 index 000000000..adb39f510 --- /dev/null +++ b/core/query_builder.php @@ -0,0 +1,754 @@ +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 9a346d617..808b5c2f9 100644 --- a/core/tests/SearchTest.php +++ b/core/tests/SearchTest.php @@ -72,31 +72,36 @@ public function testUpload(): array * @param string $tags * @param TagCondition[] $expected_tag_conditions * @param ImgCondition[] $expected_img_conditions - * @param string $expected_order + * @param mixed[] $expected_order + * @param ?int $expected_limit */ private function assert_TTC( string $tags, array $expected_tag_conditions, array $expected_img_conditions, - string $expected_order, + array $expected_order, + ?int $expected_limit ): 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(); - [$tag_conditions, $img_conditions, $order] = $terms_to_conditions->invokeArgs($obj, [Tag::explode($tags, false)]); + /** @var TermConditions $conditions */ + $conditions = $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" => $tag_conditions, - "imgs" => $img_conditions, - "order" => $order, + "tags" => $conditions->tag_conditions, + "imgs" => $conditions->img_conditions, + "order" => $conditions->order, + "limit" => $conditions->limit, ]) ); } @@ -114,7 +119,8 @@ public function testTTC_Empty(): void "true" => true])), new ImgCondition(new Querylet("rating IN ('?', 's', 'q', 'e')", [])), ], - "images.id DESC" + ["images.id DESC"], + null ); } @@ -132,7 +138,8 @@ 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" + ["images.id DESC"], + null ); } @@ -151,10 +158,28 @@ public function testTTC_Ratio(): void new ImgCondition(new Querylet("width / :width1 = height / :height1", ['width1' => 42, 'height1' => 12345])), ], - "images.id DESC" + ["images.id DESC"], + null ); } + 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( @@ -168,7 +193,8 @@ public function testTTC_Order(): void "true" => true])), new ImgCondition(new Querylet("rating IN ('?', 's', 'q', 'e')", [])), ], - "images.numeric_score DESC" + ["images.numeric_score DESC"], + null ); } @@ -210,13 +236,15 @@ private function assert_BSQ( Search::$_search_path = []; $class = new \ReflectionClass(Search::class); - $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 + $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 $obj = new Search(); - $querylet = $build_search_querylet->invokeArgs($obj, [$tcs, $ics, $order, $limit, $start]); + /** @var QueryBuilder $query_builder */ + $query_builder = $build_search_query->invokeArgs($obj, [$tcs, $ics, $order, $limit, $start]); + $query = $query_builder->render(); - $results = $database->get_all($querylet->sql, $querylet->variables); + $results = $database->get_all($query->sql, $query->parameters); static::assertThat( [ diff --git a/core/util.php b/core/util.php index aa4efb598..b5557607a 100644 --- a/core/util.php +++ b/core/util.php @@ -812,3 +812,30 @@ 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 "
".html_escape(return_var_dump($input))."