diff --git a/lib/Database.php b/lib/Database.php index 78821a2..e3e0145 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -2,6 +2,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse; use PasswordGenerator\Generator as PassGen; +use JKingWeb\Arsse\Database\Query; class Database { @@ -356,42 +357,36 @@ class Database { public function subscriptionList(string $user, int $folder = null, int $id = null): Db\Result { if(!Data::$user->authorize($user, __FUNCTION__)) throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); - // lay out the base query parts - $queryCTE = ["topmost(f_id,top) as (select id,id from arsse_folders where owner is ? and parent is null union select id,top from arsse_folders join topmost on parent=f_id)"]; - $queryWhere = ["owner is ?"]; - $queryTypes = ["str", "str", "str", "str"]; - $queryValues = [$user, $this->dateFormatDefault, $user, $user]; - if(!is_null($folder)) { - // if a folder is specified, make sure it exists - $this->folderValidateId($user, $folder); - // if it does exist, add a common table expression to list it and its children so that we select from the entire subtree - array_unshift($queryCTE, "folders(folder) as (SELECT ? union select id from arsse_folders join folders on parent is folder)"); - // add a suitable WHERE condition and bindings - $queryWhere[] = "folder in (select folder from folders)"; - array_unshift($queryTypes, "int"); - array_unshift($queryValues, $folder); - } - if(!is_null($id)) { - // this condition facilitates the implementation of subscriptionPropertiesGet, which would otherwise have to duplicate the complex query - // if an ID is specified, add a suitable WHERE condition and bindings - $queryWhere[] = "arsse_subscriptions.id is ?"; - $queryTypes[] = "int"; - $queryValues[] = $id; - } - // stitch the query together - $queryCTE = "WITH RECURSIVE ".implode(", ", $queryCTE)." "; - $queryWhere = implode(" AND ", $queryWhere); - $query = - $queryCTE."SELECT + // create a complex query + $q = new Query( + "SELECT arsse_subscriptions.id, url,favicon,source,folder,pinned,err_count,err_msg,order_type, DATEFORMAT(?, added) as added, topmost.top as top_folder, CASE WHEN arsse_subscriptions.title is not null THEN arsse_subscriptions.title ELSE arsse_feeds.title END as title, - (SELECT count(*) from arsse_articles where feed is arsse_subscriptions.feed) - (SELECT count(*) from arsse_marks join arsse_articles on article = arsse_articles.id where owner is ? and feed is arsse_feeds.id and read is 1) as unread - from arsse_subscriptions join arsse_feeds on feed = arsse_feeds.id left join topmost on folder=f_id where $queryWhere order by pinned desc, title"; - // execute the query - return $this->db->prepare($query, $queryTypes)->run($queryValues); + (SELECT count(*) from arsse_articles where feed is arsse_subscriptions.feed) - (SELECT count(*) from arsse_marks join user on user is owner join arsse_articles on article = arsse_articles.id where feed is arsse_feeds.id and read is 1) as unread + from arsse_subscriptions join user on user is owner join arsse_feeds on feed = arsse_feeds.id left join topmost on folder=f_id", + "", // where terms + "pinned desc, title" // order by terms + ); + // define common table expressions + $q->setCTE("user(user) as (SELECT ?)", "str", $user); // the subject user; this way we only have to pass it to prepare() once + // topmost folders belonging to the user + $q->setCTE("topmost(f_id,top) as (select id,id from arsse_folders join user on owner is user where parent is null union select id,top from arsse_folders join topmost on parent=f_id)"); + if(!is_null($id)) { + // this condition facilitates the implementation of subscriptionPropertiesGet, which would otherwise have to duplicate the complex query; it takes precedence over a specified folder + // if an ID is specified, add a suitable WHERE condition and bindings + $q->setWhere("arsse_subscriptions.id is ?", "int", $id); + } else if(!is_null($folder)) { + // if a folder is specified, make sure it exists + $this->folderValidateId($user, $folder); + // if it does exist, add a common table expression to list it and its children so that we select from the entire subtree + $q->setCTE("folders(folder) as (SELECT ? union select id from arsse_folders join folders on parent is folder)", "int", $folder); + // add a suitable WHERE condition + $q->setWhere("folder in (select folder from folders)"); + } + return $this->db->prepare($q, "str")->run($this->dateFormatDefault); } public function subscriptionRemove(string $user, int $id): bool { @@ -608,4 +603,32 @@ class Database { } return (int) $this->db->prepare("SELECT max(id) from arsse_editions")->run()->getValue(); } -} \ No newline at end of file + + public function articleList(string $user): Db\Result { + if(!Data::$user->authorize($user, __FUNCTION__)) throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + return $this->db->prepare( + "WITH + user(user) as (SELECT ?), + subscribed_feeds(id) as (SELECT feed from arsse_subscriptions join user on user is owner) + ". + "SELECT + arsse_articles.id, + arsse_articles.url, + title,author,content,feed,guid, + DATEFORMAT(?, edited) as edited, + DATEFORMAT(?, modified) as modified, + CASE (SELECT count(*) from arsse_marks join user on user is owner where article is arsse_articles.id and read is 1) when 1 then 0 else 1 end as unread, + (SELECT count(*) from arsse_marks join user on user is owner where article is arsse_articles.id and starred is 1) as starred, + (SELECT max(id) from arsse_editions where article is arsse_articles.id) as latestEdition, + url_title_hash||':'||url_content_hash||':'||title_content_hash as fingerprint, + arsse_enclosures.url as media_url, + arsse_enclosures.type as media_type + FROM arsse_articles + join subscribed_feeds on arsse_articles.feed is subscribed_feeds.id + left join arsse_enclosures on arsse_enclosures.article is arsse_articles.id + ", + "str","str","str" + )-run($user, $this->dateFormatDefault, $this->dateFormatDefault); + } +} + diff --git a/lib/Database/Query.php b/lib/Database/Query.php new file mode 100644 index 0000000..260bcb4 --- /dev/null +++ b/lib/Database/Query.php @@ -0,0 +1,110 @@ +body = $body; + if(strlen($where)) $this->where[] = $where; + if(strlen($order)) $this->order[] = $order; + $this->limit = $limit; + $this->offset = $offset; + } + + function setCTE(string $body, $types = null, $values = null, string $join = ''): bool { + if(!strlen($body)) return false; + $this->qCTE[] = $body; + if(!is_null($types)) { + $this->tCTE[] = $types; + $this->vCTE[] = $values; + } + if(strlen($join)) $this->jCTE[] = $join; // the CTE may only participate in subqueries rather than a join on the main query + return true; + } + + function setWhere(string $where, $types = null, $values = null): bool { + if(!strlen($where)) return false; + $this->qWhere[] = $where; + if(!is_null($types)) { + $this->tWhere[] = $types; + $this->vWhere[] = $values; + } + return true; + } + + function setOrder(string $oder, bool $prepend = false): bool { + if(!strlen($order)) return false; + if($prepend) { + array_unshift($this->order, $order); + } else { + $this->order[] = $order; + } + return true; + } + + function prepare(Driver $drv, ...$userTypes): Statement { + return new QueryStatement($drv->prepare($this->getQuery(), $this->getCTETypes(), $userTypes, $this->getWhereTypes()), $this->getCTEValues(), $this->getWhereValues()); + } + + function getQuery(bool $pretty = false): string { + $cte = sizeof($this->qCTE); + $out = ""; + if($cte) { + // start with common table expressions + $out .= "WITH RECURSIVE ".implode(", ", $this->qCTE)." "; + } + // add the body + $out .= $this->body; + if($cte) { + // add any joins against CTEs + $out .= " ".implode(" ", $this->jCTE); + } + // add any WHERE terms + if(sizeof($this->qWhere)) { + $out .= " WHERE ".implode(" AND ", $this->qWhere); + } + // add any ORDER BY terms + if(sizeof($this->order)) { + $out .= " ORDER BY ".implode(", ", $this->order); + } + // add LIMIT and OFFSET if the former is specified + if($this->limit > 0) { + $out .= " LIMIT ".$this->limit; + if($this->offset > 0) { + $out .= " OFFSET ".$this->offset; + } + } + return $out; + } + + function getWhereTypes(): array { + return $this->tWhere; + } + + function getWhereValues(): array { + return $this->vWhere; + } + + function getCTETypes(): array { + return $this->tCTE; + } + + function getCTEValues(): array { + return $this->vCTE; + } +} \ No newline at end of file diff --git a/lib/Db/AbstractDriver.php b/lib/Db/AbstractDriver.php index 770c38d..d92556f 100644 --- a/lib/Db/AbstractDriver.php +++ b/lib/Db/AbstractDriver.php @@ -7,6 +7,8 @@ abstract class AbstractDriver implements Driver { protected $transDepth = 0; protected $transStatus = []; + public abstract function prepareArray($query, array $paramTypes): Statement; + public function schemaVersion(): int { try { return (int) $this->query("SELECT value from arsse_settings where key is schema_version")->getValue(); @@ -128,7 +130,7 @@ abstract class AbstractDriver implements Driver { return ($this->query("SELECT count(*) from arsse_settings where key is 'lock'")->getValue() > 0); } - public function prepare(string $query, ...$paramType): Statement { + public function prepare($query, ...$paramType): Statement { return $this->prepareArray($query, $paramType); } } \ No newline at end of file diff --git a/lib/Db/AbstractStatement.php b/lib/Db/AbstractStatement.php index 74a849d..029ef14 100644 --- a/lib/Db/AbstractStatement.php +++ b/lib/Db/AbstractStatement.php @@ -5,8 +5,9 @@ namespace JKingWeb\Arsse\Db; abstract class AbstractStatement implements Statement { protected $types = []; protected $isNullable = []; + protected $values = ['pre' => [], 'post' => []]; - abstract function runArray(array $values): Result; + abstract function runArray(array $values = []): Result; abstract static function dateFormat(int $part = self::TS_BOTH): string; public function run(...$values): Result { diff --git a/lib/Db/Driver.php b/lib/Db/Driver.php index 739aafe..51c50de 100644 --- a/lib/Db/Driver.php +++ b/lib/Db/Driver.php @@ -33,5 +33,6 @@ interface Driver { // perform a single unsanitized query and return a result set function query(string $query): Result; // ready a prepared statement for later execution - function prepare(string $query, ...$paramType): Statement; + function prepare($query, ...$paramType): Statement; + function prepareArray($query, array $paramTypes): Statement; } \ No newline at end of file diff --git a/lib/Db/SQLite3/Driver.php b/lib/Db/SQLite3/Driver.php index a97e473..7125531 100644 --- a/lib/Db/SQLite3/Driver.php +++ b/lib/Db/SQLite3/Driver.php @@ -125,13 +125,22 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { return new Result($r, [$changes, $lastId]); } - public function prepareArray(string $query, array $paramTypes): \JKingWeb\Arsse\Db\Statement { + public function prepareArray($query, array $paramTypes): \JKingWeb\Arsse\Db\Statement { + if($query instanceof \JKingWeb\Arsse\Database\Query) { + $preValues = $query->getCTEValues(); + $postValues = $query->getWhereValues(); + $paramTypes = [$query->getCTETypes(), $paramTypes, $query->getWhereTypes()]; + $query = $query->getQuery(); + } else { + $preValues = []; + $postValues = []; + } try { $s = $this->db->prepare($query); } catch(\Exception $e) { list($excClass, $excMsg, $excData) = $this->exceptionBuild(); throw new $excClass($excMsg, $excData); } - return new Statement($this->db, $s, $paramTypes); + return new Statement($this->db, $s, $paramTypes, $preValues, $postValues); } } \ No newline at end of file diff --git a/lib/Db/SQLite3/Statement.php b/lib/Db/SQLite3/Statement.php index 4424278..14748bc 100644 --- a/lib/Db/SQLite3/Statement.php +++ b/lib/Db/SQLite3/Statement.php @@ -26,10 +26,12 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement { protected $db; protected $st; - public function __construct(\SQLite3 $db, \SQLite3Stmt $st, array $bindings = []) { + public function __construct(\SQLite3 $db, \SQLite3Stmt $st, array $bindings = [], array $preValues, array $postValues) { $this->db = $db; $this->st = $st; $this->rebindArray($bindings); + $this->values['pre'] = $preValues; + $this->values['post'] = $postValues; } public function __destruct() { @@ -45,9 +47,10 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement { ])[$part]; } - public function runArray(array $values = null): \JKingWeb\Arsse\Db\Result { + public function runArray(array $values = []): \JKingWeb\Arsse\Db\Result { $this->st->clear(); - if(!is_null($values)) $this->bindValues($values); + $values = [$this->values['pre'], $values, $this->values['post']]; + $this->bindValues($values); try { $r = $this->st->execute(); } catch(\Exception $e) { diff --git a/lib/Db/Statement.php b/lib/Db/Statement.php index 3a74d54..e5a631f 100644 --- a/lib/Db/Statement.php +++ b/lib/Db/Statement.php @@ -33,7 +33,7 @@ interface Statement { static function dateFormat(int $part = self::TS_BOTH): string; function run(...$values): Result; - function runArray(array $values): Result; + function runArray(array $values = []): Result; function rebind(...$bindings): bool; function rebindArray(array $bindings): bool; } \ No newline at end of file diff --git a/tests/test.php b/tests/test.php deleted file mode 100644 index 3fffe21..0000000 --- a/tests/test.php +++ /dev/null @@ -1,28 +0,0 @@ -dbSQLite3File = ":memory:"; -Data::load($conf); -Data::$db->schemaUpdate(); - -Data::$user->add($user, $pass); -Data::$user->auth(); -Data::$user->authorizationEnabled(false); -Data::$user->rightsSet($user, User\Driver::RIGHTS_GLOBAL_ADMIN); -Data::$user->authorizationEnabled(true); -Data::$db->folderAdd($user, ['name' => 'ook']); -/*Data::$db->subscriptionAdd($user, "http://linuxfr.org/news.atom"); -Data::$db->subscriptionPropertiesSet($user, 1, [ - 'title' => "OOOOOOOOK!", -]);*/ -(new REST())->dispatch(new REST\Request( - "POST", "/index.php/apps/news/api/v1-2/feeds/", json_encode(['url'=> "http://linuxfr.org/news.atom"]) -)); \ No newline at end of file