From 21fdd66d3796f0df42941921441c212a55eec00e Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 1 Mar 2019 22:36:25 -0500 Subject: [PATCH] Work around limit to SQL parameter placeholders for IN() clauses Improves #150 LIKE-based matches also need to be similarly conservative --- lib/Database.php | 300 +++++++++++-------------- lib/Db/Driver.php | 6 + lib/Db/MySQL/Driver.php | 4 + lib/Db/PDODriver.php | 4 + lib/Db/PostgreSQL/Driver.php | 4 + lib/Db/SQLite3/Driver.php | 4 + tests/cases/Database/SeriesArticle.php | 7 +- tests/cases/Db/BaseDriver.php | 4 + 8 files changed, 155 insertions(+), 178 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 61eefa58..f0987c33 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -38,8 +38,10 @@ use JKingWeb\Arsse\Misc\ValueInfo; class Database { /** The version number of the latest schema the interface is aware of */ const SCHEMA_VERSION = 4; - /** The maximum number of articles to mark in one query without chunking */ - const LIMIT_ARTICLES = 50; + /** The size of a set of values beyond which the set will be embedded into the query text */ + const LIMIT_SET_SIZE = 25; + /** The length of a string in an embedded set beyond which a parameter placeholder will be used for the string */ + const LIMIT_SET_STRING_LENGTH = 200; /** A map database driver short-names and their associated class names */ const DRIVER_NAMES = [ 'sqlite3' => \JKingWeb\Arsse\Db\SQLite3\Driver::class, @@ -126,29 +128,50 @@ class Database { return $out; } - /** Conputes the contents of an SQL "IN()" clause, producing one parameter placeholder for each input value + /** Computes the contents of an SQL "IN()" clause, for each input value either embedding the value or producing a parameter placeholder * - * Returns an indexed array containing the clause text, an array of types, and the array of values + * Returns an indexed array containing the clause text, an array of types, and an array of values. Note that the array of output values may not match the array of input values * * @param array $values Arbitrary values * @param string $type A single data type applied to each value */ protected function generateIn(array $values, string $type): array { - $out = [ - "", // query clause - [], // binding types - $values, // binding values - ]; - if (sizeof($values)) { - // the query clause is just a series of question marks separated by commas - $out[0] = implode(",", array_fill(0, sizeof($values), "?")); - // the binding types are just a repetition of the supplied type - $out[1] = array_fill(0, sizeof($values), $type); - } else { + if (!sizeof($values)) { // if the set is empty, some databases require an explicit null - $out[0] = "null"; + return ["null", [], []]; + } + $t = (Statement::TYPES[$type] ?? 0) % Statement::T_NOT_NULL; + if (sizeof($values) > self::LIMIT_SET_SIZE && ($t == Statement::T_INTEGER || $t == Statement::T_STRING)) { + $clause = []; + $params = []; + $count = 0; + $convType = Db\AbstractStatement::TYPE_NORM_MAP[Statement::TYPES[$type]]; + foreach($values as $v) { + $v = ValueInfo::normalize($v, $convType, null, "sql"); + if (is_null($v)) { + // nulls are pointless to have + continue; + } elseif (is_string($v)) { + if (strlen($v) > self::LIMIT_SET_STRING_LENGTH) { + $clause[] = "?"; + $params[] = $v; + } else { + $clause[] = $this->db->literalString($v); + } + } else { + $clause[] = ValueInfo::normalize($v, ValueInfo::T_STRING, null, "sql"); + } + $count++; + } + if (!$count) { + // the set is actually empty + return ["null", [], []]; + } else { + return [implode(",", $clause), array_fill(0, sizeof($params), $type), $params]; + } + } else { + return [implode(",", array_fill(0, sizeof($values), "?")), array_fill(0, sizeof($values), $type), $values]; } - return $out; } /** Computes basic LIKE-based text search constraints for use in a WHERE clause @@ -1074,10 +1097,10 @@ class Database { */ public function feedMatchIds(int $feedID, array $ids = [], array $hashesUT = [], array $hashesUC = [], array $hashesTC = []): Db\Result { // compile SQL IN() clauses and necessary type bindings for the four identifier lists - list($cId, $tId) = $this->generateIn($ids, "str"); - list($cHashUT, $tHashUT) = $this->generateIn($hashesUT, "str"); - list($cHashUC, $tHashUC) = $this->generateIn($hashesUC, "str"); - list($cHashTC, $tHashTC) = $this->generateIn($hashesTC, "str"); + list($cId, $tId, $vId) = $this->generateIn($ids, "str"); + list($cHashUT, $tHashUT, $vHashUT) = $this->generateIn($hashesUT, "str"); + list($cHashUC, $tHashUC, $vHashUC) = $this->generateIn($hashesUC, "str"); + list($cHashTC, $tHashTC, $vHashTC) = $this->generateIn($hashesTC, "str"); // perform the query return $articles = $this->db->prepare( "SELECT id, edited, guid, url_title_hash, url_content_hash, title_content_hash FROM arsse_articles WHERE feed = ? and (guid in($cId) or url_title_hash in($cHashUT) or url_content_hash in($cHashUC) or title_content_hash in($cHashTC))", @@ -1086,7 +1109,7 @@ class Database { $tHashUT, $tHashUC, $tHashTC - )->run($feedID, $ids, $hashesUT, $hashesUC, $hashesTC); + )->run($feedID, $vId, $vHashUT, $vHashUC, $vHashTC); } /** Computes an SQL query to find and retrieve data about articles in the database @@ -1180,25 +1203,25 @@ class Database { $q->setLimit($context->limit, $context->offset); // handle the simple context options $options = [ - // each context array consists of a column identifier (see $colDefs above), a comparison operator, a data type, an option to pair with for BETWEEN evaluation, and an upper bound if the value is an array - "edition" => ["edition", "=", "int", "", 1], - "editions" => ["edition", "in", "int", "", self::LIMIT_ARTICLES], - "article" => ["id", "=", "int", "", 1], - "articles" => ["id", "in", "int", "", self::LIMIT_ARTICLES], - "oldestArticle" => ["id", ">=", "int", "latestArticle", 1], - "latestArticle" => ["id", "<=", "int", "oldestArticle", 1], - "oldestEdition" => ["edition", ">=", "int", "latestEdition", 1], - "latestEdition" => ["edition", "<=", "int", "oldestEdition", 1], - "modifiedSince" => ["modified_date", ">=", "datetime", "notModifiedSince", 1], - "notModifiedSince" => ["modified_date", "<=", "datetime", "modifiedSince", 1], - "markedSince" => ["marked_date", ">=", "datetime", "notMarkedSince", 1], - "notMarkedSince" => ["marked_date", "<=", "datetime", "markedSince", 1], - "folderShallow" => ["folder", "=", "int", "", 1], - "subscription" => ["subscription", "=", "int", "", 1], - "unread" => ["unread", "=", "bool", "", 1], - "starred" => ["starred", "=", "bool", "", 1], + // each context array consists of a column identifier (see $colDefs above), a comparison operator, a data type, and an option to pair with for BETWEEN evaluation + "edition" => ["edition", "=", "int", ""], + "editions" => ["edition", "in", "int", ""], + "article" => ["id", "=", "int", ""], + "articles" => ["id", "in", "int", ""], + "oldestArticle" => ["id", ">=", "int", "latestArticle"], + "latestArticle" => ["id", "<=", "int", "oldestArticle"], + "oldestEdition" => ["edition", ">=", "int", "latestEdition"], + "latestEdition" => ["edition", "<=", "int", "oldestEdition"], + "modifiedSince" => ["modified_date", ">=", "datetime", "notModifiedSince"], + "notModifiedSince" => ["modified_date", "<=", "datetime", "modifiedSince"], + "markedSince" => ["marked_date", ">=", "datetime", "notMarkedSince"], + "notMarkedSince" => ["marked_date", "<=", "datetime", "markedSince"], + "folderShallow" => ["folder", "=", "int", ""], + "subscription" => ["subscription", "=", "int", ""], + "unread" => ["unread", "=", "bool", ""], + "starred" => ["starred", "=", "bool", ""], ]; - foreach ($options as $m => list($col, $op, $type, $pair, $max)) { + foreach ($options as $m => list($col, $op, $type, $pair)) { if (!$context->$m()) { // context is not being used continue; @@ -1206,8 +1229,6 @@ class Database { // context option is an array of values if (!$context->$m) { throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element - } elseif (sizeof($context->$m) > $max) { - throw new Db\ExceptionInput("tooLong", ['field' => $m, 'action' => $this->caller(), 'max' => $max]); // @codeCoverageIgnore } list($clause, $types, $values) = $this->generateIn($context->$m, $type); $q->setWhere("{$colDefs[$col]} $op ($clause)", $types, $values); @@ -1224,7 +1245,7 @@ class Database { } } // further handle exclusionary options if specified - foreach ($options as $m => list($col, $op, $type, $pair, $max)) { + foreach ($options as $m => list($col, $op, $type, $pair)) { if (!method_exists($context->not, $m) || !$context->not->$m()) { // context option is not being used continue; @@ -1232,8 +1253,6 @@ class Database { if (!$context->not->$m) { // for exclusions we don't care if the array is empty continue; - } elseif (sizeof($context->not->$m) > $max) { - throw new Db\ExceptionInput("tooLong", ['field' => "$m (not)", 'action' => $this->caller(), 'max' => $max]); } list($clause, $types, $values) = $this->generateIn($context->not->$m, $type); $q->setWhereNot("{$colDefs[$col]} $op ($clause)", $types, $values); @@ -1315,35 +1334,10 @@ class Database { $q->setWhereNot(...$this->generateSearch($context->not->$m, $cols, true)); } // return the query + //var_export((string) $q); return $q; } - /** Chunk a context with more than the maximum number of articles or editions into an array of contexts */ - protected function contextChunk(Context $context): array { - $exception = ""; - if ($context->editions()) { - // editions take precedence over articles - if (sizeof($context->editions) > self::LIMIT_ARTICLES) { - $exception = "editions"; - } - } elseif ($context->articles()) { - if (sizeof($context->articles) > self::LIMIT_ARTICLES) { - $exception = "articles"; - } - } - if ($exception) { - $out = []; - $list = array_chunk($context->$exception, self::LIMIT_ARTICLES); - foreach ($list as $chunk) { - $out[] = (clone $context)->$exception($chunk); - } - return $out; - } else { - return []; - } - } - - /** Lists articles in the database which match a given query context * * If an empty column list is supplied, a count of articles is returned instead @@ -1357,22 +1351,11 @@ class Database { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } $context = $context ?? new Context; - // if the context has more articles or editions than we can process in one query, perform a series of queries and return an aggregate result - if ($contexts = $this->contextChunk($context)) { - $out = []; - $tr = $this->begin(); - foreach ($contexts as $context) { - $out[] = $this->articleList($user, $context, $fields); - } - $tr->commit(); - return new Db\ResultAggregate(...$out); - } else { - $q = $this->articleQuery($user, $context, $fields); - $q->setOrder("arsse_articles.edited".($context->reverse ? " desc" : "")); - $q->setOrder("latest_editions.edition".($context->reverse ? " desc" : "")); - // perform the query and return results - return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues()); - } + $q = $this->articleQuery($user, $context, $fields); + $q->setOrder("arsse_articles.edited".($context->reverse ? " desc" : "")); + $q->setOrder("latest_editions.edition".($context->reverse ? " desc" : "")); + // perform the query and return results + return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues()); } /** Returns a count of articles which match the given query context @@ -1385,19 +1368,8 @@ class Database { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } $context = $context ?? new Context; - // if the context has more articles or editions than we can process in one query, perform a series of queries and return an aggregate result - if ($contexts = $this->contextChunk($context)) { - $out = 0; - $tr = $this->begin(); - foreach ($contexts as $context) { - $out += $this->articleCount($user, $context); - } - $tr->commit(); - return $out; - } else { - $q = $this->articleQuery($user, $context, []); - return (int) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue(); - } + $q = $this->articleQuery($user, $context, []); + return (int) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue(); } /** Applies one or multiple modifications to all articles matching the given query context @@ -1425,80 +1397,69 @@ class Database { return 0; } $context = $context ?? new Context; - // if the context has more articles or editions than we can process in one query, perform a series of queries and return an aggregate result - if ($contexts = $this->contextChunk($context)) { - $out = 0; - $tr = $this->begin(); - foreach ($contexts as $context) { - $out += $this->articleMark($user, $data, $context); + $tr = $this->begin(); + $out = 0; + if ($data['read'] || $data['starred'] || strlen($data['note'] ?? "")) { + // first prepare a query to insert any missing marks rows for the articles we want to mark + // but only insert new mark records if we're setting at least one "positive" mark + $q = $this->articleQuery($user, $context, ["id", "subscription", "note"]); + $q->setWhere("arsse_marks.starred is null"); // null means there is no marks row for the article + $this->db->prepare("INSERT INTO arsse_marks(article,subscription,note) ".$q->getQuery(), $q->getTypes())->run($q->getValues()); + } + if (isset($data['read']) && (isset($data['starred']) || isset($data['note'])) && ($context->edition() || $context->editions())) { + // if marking by edition both read and something else, do separate marks for starred and note than for read + // marking as read is ignored if the edition is not the latest, but the same is not true of the other two marks + $this->db->query("UPDATE arsse_marks set touched = 0 where touched <> 0"); + // set read marks + $q = $this->articleQuery($user, $context, ["id", "subscription"]); + $q->setWhere("arsse_marks.read <> coalesce(?,arsse_marks.read)", "bool", $data['read']); + $q->pushCTE("target_articles(article,subscription)"); + $q->setBody("UPDATE arsse_marks set \"read\" = ?, touched = 1 where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles)", "bool", $data['read']); + $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues()); + // get the articles associated with the requested editions + if ($context->edition()) { + $context->article($this->articleValidateEdition($user, $context->edition)['article'])->edition(null); + } else { + $context->articles($this->editionArticle(...$context->editions))->editions(null); } - $tr->commit(); - return $out; - } else { - $tr = $this->begin(); - $out = 0; - if ($data['read'] || $data['starred'] || strlen($data['note'] ?? "")) { - // first prepare a query to insert any missing marks rows for the articles we want to mark - // but only insert new mark records if we're setting at least one "positive" mark - $q = $this->articleQuery($user, $context, ["id", "subscription", "note"]); - $q->setWhere("arsse_marks.starred is null"); // null means there is no marks row for the article - $this->db->prepare("INSERT INTO arsse_marks(article,subscription,note) ".$q->getQuery(), $q->getTypes())->run($q->getValues()); - } - if (isset($data['read']) && (isset($data['starred']) || isset($data['note'])) && ($context->edition() || $context->editions())) { - // if marking by edition both read and something else, do separate marks for starred and note than for read - // marking as read is ignored if the edition is not the latest, but the same is not true of the other two marks - $this->db->query("UPDATE arsse_marks set touched = 0 where touched <> 0"); - // set read marks + // set starred and/or note marks (unless all requested editions actually do not exist) + if ($context->article || $context->articles) { $q = $this->articleQuery($user, $context, ["id", "subscription"]); - $q->setWhere("arsse_marks.read <> coalesce(?,arsse_marks.read)", "bool", $data['read']); + $q->setWhere("(arsse_marks.note <> coalesce(?,arsse_marks.note) or arsse_marks.starred <> coalesce(?,arsse_marks.starred))", ["str", "bool"], [$data['note'], $data['starred']]); $q->pushCTE("target_articles(article,subscription)"); - $q->setBody("UPDATE arsse_marks set \"read\" = ?, touched = 1 where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles)", "bool", $data['read']); + $data = array_filter($data, function($v) { + return isset($v); + }); + list($set, $setTypes, $setValues) = $this->generateSet($data, ['starred' => "bool", 'note' => "str"]); + $q->setBody("UPDATE arsse_marks set touched = 1, $set where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles)", $setTypes, $setValues); $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues()); + } + // finally set the modification date for all touched marks and return the number of affected marks + $out = $this->db->query("UPDATE arsse_marks set modified = CURRENT_TIMESTAMP, touched = 0 where touched = 1")->changes(); + } else { + if (!isset($data['read']) && ($context->edition() || $context->editions())) { // get the articles associated with the requested editions if ($context->edition()) { $context->article($this->articleValidateEdition($user, $context->edition)['article'])->edition(null); } else { $context->articles($this->editionArticle(...$context->editions))->editions(null); } - // set starred and/or note marks (unless all requested editions actually do not exist) - if ($context->article || $context->articles) { - $q = $this->articleQuery($user, $context, ["id", "subscription"]); - $q->setWhere("(arsse_marks.note <> coalesce(?,arsse_marks.note) or arsse_marks.starred <> coalesce(?,arsse_marks.starred))", ["str", "bool"], [$data['note'], $data['starred']]); - $q->pushCTE("target_articles(article,subscription)"); - $data = array_filter($data, function($v) { - return isset($v); - }); - list($set, $setTypes, $setValues) = $this->generateSet($data, ['starred' => "bool", 'note' => "str"]); - $q->setBody("UPDATE arsse_marks set touched = 1, $set where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles)", $setTypes, $setValues); - $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues()); + if (!$context->article && !$context->articles) { + return 0; } - // finally set the modification date for all touched marks and return the number of affected marks - $out = $this->db->query("UPDATE arsse_marks set modified = CURRENT_TIMESTAMP, touched = 0 where touched = 1")->changes(); - } else { - if (!isset($data['read']) && ($context->edition() || $context->editions())) { - // get the articles associated with the requested editions - if ($context->edition()) { - $context->article($this->articleValidateEdition($user, $context->edition)['article'])->edition(null); - } else { - $context->articles($this->editionArticle(...$context->editions))->editions(null); - } - if (!$context->article && !$context->articles) { - return 0; - } - } - $q = $this->articleQuery($user, $context, ["id", "subscription"]); - $q->setWhere("(arsse_marks.note <> coalesce(?,arsse_marks.note) or arsse_marks.starred <> coalesce(?,arsse_marks.starred) or arsse_marks.read <> coalesce(?,arsse_marks.read))", ["str", "bool", "bool"], [$data['note'], $data['starred'], $data['read']]); - $q->pushCTE("target_articles(article,subscription)"); - $data = array_filter($data, function($v) { - return isset($v); - }); - list($set, $setTypes, $setValues) = $this->generateSet($data, ['read' => "bool", 'starred' => "bool", 'note' => "str"]); - $q->setBody("UPDATE arsse_marks set $set, modified = CURRENT_TIMESTAMP where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles)", $setTypes, $setValues); - $out = $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->changes(); } - $tr->commit(); - return $out; + $q = $this->articleQuery($user, $context, ["id", "subscription"]); + $q->setWhere("(arsse_marks.note <> coalesce(?,arsse_marks.note) or arsse_marks.starred <> coalesce(?,arsse_marks.starred) or arsse_marks.read <> coalesce(?,arsse_marks.read))", ["str", "bool", "bool"], [$data['note'], $data['starred'], $data['read']]); + $q->pushCTE("target_articles(article,subscription)"); + $data = array_filter($data, function($v) { + return isset($v); + }); + list($set, $setTypes, $setValues) = $this->generateSet($data, ['read' => "bool", 'starred' => "bool", 'note' => "str"]); + $q->setBody("UPDATE arsse_marks set $set, modified = CURRENT_TIMESTAMP where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles)", $setTypes, $setValues); + $out = $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->changes(); } + $tr->commit(); + return $out; } /** Returns statistics about the articles starred by the given user @@ -1685,20 +1646,9 @@ class Database { public function editionArticle(int ...$edition): array { $out = []; $context = (new Context)->editions($edition); - // if the context has more articles or editions than we can process in one query, perform a series of queries and return an aggregate result - if ($contexts = $this->contextChunk($context)) { - $articles = $editions = []; - foreach ($contexts as $context) { - $out = $this->editionArticle(...$context->editions); - $editions = array_merge($editions, array_map("intval", array_keys($out))); - $articles = array_merge($articles, array_map("intval", array_values($out))); - } - return array_combine($editions, $articles); - } else { - list($in, $inTypes) = $this->generateIn($context->editions, "int"); - $out = $this->db->prepare("SELECT id as edition, article from arsse_editions where id in($in)", $inTypes)->run($context->editions)->getAll(); - return $out ? array_combine(array_column($out, "edition"), array_column($out, "article")) : []; - } + list($in, $inTypes, $inValues) = $this->generateIn($context->editions, "int"); + $out = $this->db->prepare("SELECT id as edition, article from arsse_editions where id in($in)", $inTypes)->run($inValues)->getAll(); + return $out ? array_combine(array_column($out, "edition"), array_column($out, "article")) : []; } /** Creates a label, and returns its numeric identifier diff --git a/lib/Db/Driver.php b/lib/Db/Driver.php index 959a5500..b0f572c8 100644 --- a/lib/Db/Driver.php +++ b/lib/Db/Driver.php @@ -76,4 +76,10 @@ interface Driver { * - "like": the case-insensitive LIKE operator */ public function sqlToken(string $token): string; + + /** Returns a string literal which is properly escaped to guard against SQL injections. Delimiters are included in the output string + * + * This functionality should be avoided in favour of using statement parameters whenever possible + */ + public function literalString(string $str): string; } diff --git a/lib/Db/MySQL/Driver.php b/lib/Db/MySQL/Driver.php index 8a4fe445..edd5f771 100644 --- a/lib/Db/MySQL/Driver.php +++ b/lib/Db/MySQL/Driver.php @@ -212,4 +212,8 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { public function prepareArray(string $query, array $paramTypes): \JKingWeb\Arsse\Db\Statement { return new Statement($this->db, $query, $paramTypes, $this->packetSize); } + + public function literalString(string $str): string { + return "'".$this->db->real_escape_string($str)."'"; + } } diff --git a/lib/Db/PDODriver.php b/lib/Db/PDODriver.php index c5b4f4db..df5dcc3b 100644 --- a/lib/Db/PDODriver.php +++ b/lib/Db/PDODriver.php @@ -28,4 +28,8 @@ trait PDODriver { } return new PDOResult($this->db, $r); } + + public function literalString(string $str): string { + return $this->db->quote($str); + } } diff --git a/lib/Db/PostgreSQL/Driver.php b/lib/Db/PostgreSQL/Driver.php index 08c439d3..12ad8fcd 100644 --- a/lib/Db/PostgreSQL/Driver.php +++ b/lib/Db/PostgreSQL/Driver.php @@ -221,4 +221,8 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { public function prepareArray(string $query, array $paramTypes): \JKingWeb\Arsse\Db\Statement { return new Statement($this->db, $query, $paramTypes); } + + public function literalString(string $str): string { + return pg_escape_literal($this->db, $str); + } } diff --git a/lib/Db/SQLite3/Driver.php b/lib/Db/SQLite3/Driver.php index f7e47fb9..3682d03b 100644 --- a/lib/Db/SQLite3/Driver.php +++ b/lib/Db/SQLite3/Driver.php @@ -179,4 +179,8 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { $this->exec((!$rollback) ? "COMMIT" : "ROLLBACK"); return true; } + + public function literalString(string $str): string { + return "'".\SQLite3::escapeString($str)."'"; + } } diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index 3887d785..e2aa5988 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -432,7 +432,7 @@ trait SeriesArticle { "Multiple unstarred articles" => [(new Context)->articles([1,2,3])->starred(false), [2,3]], "Multiple articles" => [(new Context)->articles([1,20,50]), [1,20]], "Multiple editions" => [(new Context)->editions([1,1001,50]), [1,20]], - "150 articles" => [(new Context)->articles(range(1, Database::LIMIT_ARTICLES * 3)), [1,2,3,4,5,6,7,8,19,20]], + "150 articles" => [(new Context)->articles(range(1, Database::LIMIT_SET_SIZE * 3)), [1,2,3,4,5,6,7,8,19,20]], "Search title or content 1" => [(new Context)->searchTerms(["Article"]), [1,2,3]], "Search title or content 2" => [(new Context)->searchTerms(["one", "first"]), [1]], "Search title or content 3" => [(new Context)->searchTerms(["one first"]), []], @@ -455,6 +455,7 @@ trait SeriesArticle { "Search with exclusion" => [(new Context)->searchTerms(["Article"])->not->searchTerms(["one", "two"]), [3]], "Excluded folder tree" => [(new Context)->not->folder(1), [1,2,3,4,19,20]], "Excluding label ID 2" => [(new Context)->not->label(2), [2,3,4,6,7,8,19]], + "Excluding label 'Fascinating'" => [(new Context)->not->labelName("Fascinating"), [2,3,4,6,7,8,19]], ]; } @@ -744,7 +745,7 @@ trait SeriesArticle { } public function testMarkTooManyMultipleArticles() { - $this->assertSame(7, Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->articles(range(1, Database::LIMIT_ARTICLES * 3)))); + $this->assertSame(7, Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->articles(range(1, Database::LIMIT_SET_SIZE * 3)))); } public function testMarkAMissingArticle() { @@ -907,7 +908,7 @@ trait SeriesArticle { $this->assertSame(2, Arsse::$db->articleCount("john.doe@example.com", (new Context)->starred(true))); $this->assertSame(4, Arsse::$db->articleCount("john.doe@example.com", (new Context)->folder(1))); $this->assertSame(0, Arsse::$db->articleCount("jane.doe@example.com", (new Context)->starred(true))); - $this->assertSame(10, Arsse::$db->articleCount("john.doe@example.com", (new Context)->articles(range(1, Database::LIMIT_ARTICLES *3)))); + $this->assertSame(10, Arsse::$db->articleCount("john.doe@example.com", (new Context)->articles(range(1, Database::LIMIT_SET_SIZE * 3)))); } public function testCountArticlesWithoutAuthority() { diff --git a/tests/cases/Db/BaseDriver.php b/tests/cases/Db/BaseDriver.php index 4967e84c..74ef7c97 100644 --- a/tests/cases/Db/BaseDriver.php +++ b/tests/cases/Db/BaseDriver.php @@ -378,4 +378,8 @@ abstract class BaseDriver extends \JKingWeb\Arsse\Test\AbstractTest { $this->drv->savepointUndo(); $this->assertTrue($this->exec(str_replace("#", "3", $this->setVersion))); } + + public function testProduceAStringLiteral() { + $this->assertSame("'It''s a string!'", $this->drv->literalString("It's a string!")); + } }