From 93dd8e880f812bb792ae8390c79ba91d4bbbd506 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sun, 2 Jun 2024 22:14:19 -0400 Subject: [PATCH] First cut of item-with-feed and list impl - Add strict types to all files - Convert many queries to document commands --- src/app-config.php | 2 +- src/cli-start.php | 3 +- src/composer.lock | 11 +- src/lib/Data.php | 57 +++++++++- src/lib/Feed.php | 6 +- src/lib/Item.php | 34 +----- src/lib/ItemAndFeed.php | 51 --------- src/lib/ItemList.php | 162 +++++++++++++---------------- src/lib/ItemWithFeed.php | 73 +++++++++++++ src/lib/Key.php | 3 +- src/lib/ParsedFeed.php | 2 +- src/lib/ParsedItem.php | 3 +- src/lib/Security.php | 4 +- src/lib/Table.php | 3 +- src/lib/User.php | 23 ++-- src/public/bookmark.php | 26 ++--- src/public/docs/feeds.php | 2 +- src/public/docs/index.php | 2 +- src/public/docs/items.php | 2 +- src/public/docs/security-modes.php | 2 +- src/public/docs/the-cli.php | 2 +- src/public/feed/index.php | 4 +- src/public/feed/items.php | 15 ++- src/public/feeds.php | 6 +- src/public/index.php | 14 +-- src/public/item.php | 52 +++------ src/public/search.php | 12 +-- src/public/user/log-off.php | 3 +- src/public/user/log-on.php | 10 +- src/start.php | 9 +- src/user-config.dist.php | 4 +- src/util/db-update.php | 2 +- src/util/refresh.php | 5 +- src/util/search.php | 2 +- src/util/user.php | 17 +-- 35 files changed, 309 insertions(+), 319 deletions(-) delete mode 100644 src/lib/ItemAndFeed.php create mode 100644 src/lib/ItemWithFeed.php diff --git a/src/app-config.php b/src/app-config.php index 01720a5..92d7df8 100644 --- a/src/app-config.php +++ b/src/app-config.php @@ -1,4 +1,4 @@ -close(); } + /** + * Create a JSON field comparison to find bookmarked items + * + * @param string $qualifier The table qualifier to include (optional; defaults to no qualifier) + * @return Field A field that will find bookmarked items + */ + public static function bookmarkField(string $qualifier = ''): Field + { + $bookField = Field::EQ('is_bookmarked', 1, '@book'); + $bookField->qualifier = $qualifier; + return $bookField; + } + + /** + * Create a JSON field comparison to find items for a given feed + * + * @param int $feedId The ID of the feed for which items should be retrieved + * @param string $qualifier The table qualifier to include (optional; defaults to no qualifier) + * @return Field A field to find items for the give feed + */ + public static function feedField(int $feedId, string $qualifier = ''): Field + { + $feedField = Field::EQ(Configuration::idField(), $feedId, '@feed'); + $feedField->qualifier = $qualifier; + return $feedField; + } + + /** + * Create a JSON field comparison to find unread items + * + * @param string $qualifier The table qualifier to include (optional; defaults to no qualifier) + * @return Field A field to find unread items + */ + public static function unreadField(string $qualifier = ''): Field + { + $readField = Field::EQ('is_read', 0, '@read'); + $readField->qualifier = $qualifier; + return $readField; + } + + /** + * Create a JSON field comparison to find items belonging to feeds to which the given user is subscribed + * + * @param string $qualifier The table qualifier to include (optional; defaults to no qualifier) + * @return Field A field to find feeds belonging to the given user + */ + public static function userIdField(string $qualifier = ''): Field + { + $userField = Field::EQ('user_id', $_SESSION[Key::USER_ID], '@user'); + $userField->qualifier = $qualifier; + return $userField; + } + /** * Parse/format a date/time from a string * diff --git a/src/lib/Feed.php b/src/lib/Feed.php index e294980..66d6adf 100644 --- a/src/lib/Feed.php +++ b/src/lib/Feed.php @@ -1,4 +1,5 @@ - $it->asParameter(), $fields)), $db); + Custom::nonQuery($sql, Parameters::addFields($fields, []), $db); return ['ok' => true]; } catch (DocumentException $ex) { return ['error' => "$ex"]; diff --git a/src/lib/Item.php b/src/lib/Item.php index 5e62ea1..79f8062 100644 --- a/src/lib/Item.php +++ b/src/lib/Item.php @@ -1,13 +1,6 @@ -updatedOn, content: $item->content); } - - /** - * Retrieve an item by its ID, ensuring that its owner matches the current user - * - * @param int $id The ID of the item to retrieve - * @return Item|false The item if it exists and is owned by the current user, false if not - * @throws DocumentException If any is encountered - */ - public static function retrieveByIdForUser(int $id): Item|false - { - $idField = Field::EQ(Configuration::idField(), $id, '@id'); - $idField->qualifier = Table::ITEM; - $userField = Field::EQ('user_id', $_SESSION[Key::USER_ID], '@user'); - $userField->qualifier = Table::FEED; - $fields = [$idField, $userField]; - - $where = Query::whereByFields($fields); - $item = Table::ITEM; - $feed = Table::FEED; - return Custom::single( - "SELECT $item.data FROM $item INNER JOIN $feed ON $item.data->>'feed_id' = $feed.data->>'id' WHERE $where", - Parameters::addFields($fields, []), new JsonMapper(Item::class)); - } } diff --git a/src/lib/ItemAndFeed.php b/src/lib/ItemAndFeed.php deleted file mode 100644 index 38dbb6a..0000000 --- a/src/lib/ItemAndFeed.php +++ /dev/null @@ -1,51 +0,0 @@ - A mapper to deserialize this from the query - */ - public static function mapper(): Mapper - { - return new class implements Mapper { - public function map(array $result): ItemAndFeed - { - $it = new ItemAndFeed(); - $it->item = (new JsonMapper(Item::class, 'item_data'))->map($result); - $it->feed = (new JsonMapper(Feed::class, 'feed_data'))->map($result); - return $it; - } - }; - } - - /** - * Generate the `SELECT` and `FROM` clauses for the query to retrieve this item - * - * @return string The `SELECT` and `FROM` clauses to retrieve these items - */ - public static function selectFrom(): string - { - $item = Table::ITEM; - $feed = Table::FEED; - return <<>'feed_id' = $feed.data->>'id' - SQL; - } -} diff --git a/src/lib/ItemList.php b/src/lib/ItemList.php index e177866..405e1fb 100644 --- a/src/lib/ItemList.php +++ b/src/lib/ItemList.php @@ -1,19 +1,25 @@ - The items matching the criteria, lazily iterable */ + private DocumentList $dbList; /** @var string The error message generated by executing a query */ public string $error = ''; @@ -23,7 +29,8 @@ class ItemList { * * @return bool True if there is an error condition associated with this list, false if not */ - public function isError(): bool { + public function isError(): bool + { return $this->error != ''; } @@ -39,54 +46,34 @@ class ItemList { /** * Constructor * - * @param SQLite3 $db The database connection (used to retrieve error information if the query fails) - * @param SQLite3Stmt $query The query to retrieve the items for this list * @param string $itemType The type of item being displayed (unread, bookmark, etc.) * @param string $returnURL The URL to which the item page should return once the item has been viewed + * @param array|Field[] $fields The fields to use to restrict the results + * @param string $searchWhere Additional WHERE clause to use for searching */ - private function __construct(SQLite3 $db, SQLite3Stmt $query, public string $itemType, public string $returnURL = '') + private function __construct(public string $itemType, public string $returnURL = '', array $fields = [], + string $searchWhere = '') { - $result = $query->execute(); - if (!$result) { - $this->error = 'SQLite error: ' . $db->lastErrorMsg(); - } else { - $this->items = $result; + $allFields = [Data::userIdField(Table::FEED), ...$fields]; + try { + $this->dbList = Custom::list( + ItemWithFeed::SELECT_WITH_FEED . ' WHERE ' + . Query::whereByFields(array_filter($allFields, fn($it) => $it->paramName <> '@search')) + . $searchWhere, + Parameters::addFields($allFields, []), new JsonMapper(ItemWithFeed::class)); + } catch (DocumentException $ex) { + $this->error = "$ex"; } } - /** - * Create an item list query - * - * @param SQLite3 $db The database connection to use to obtain items - * @param array $criteria One or more SQL WHERE conditions (will be combined with AND) - * @param array $parameters Parameters to be added to the query (key index 0, value index 1; optional) - * @return SQLite3Stmt The query, ready to be executed - */ - private static function makeQuery(SQLite3 $db, array $criteria, array $parameters = []): SQLite3Stmt - { - $where = empty($criteria) ? '' : 'AND ' . implode(' AND ', $criteria); - $sql = <<prepare($sql); - $query->bindValue(':userId', $_SESSION[Key::USER_ID]); - foreach ($parameters as $param) $query->bindValue($param[0], $param[1]); - return $query; - } - /** * Create an item list with all the current user's bookmarked items * - * @param SQLite3 $db The database connection to use to obtain items * @return static An item list with all bookmarked items */ - public static function allBookmarked(SQLite3 $db): static + public static function allBookmarked(): static { - $list = new static($db, self::makeQuery($db, ['item.is_bookmarked = 1']), 'Bookmarked', '/?bookmarked'); + $list = new static('Bookmarked', '/?bookmarked', [Data::bookmarkField(Table::ITEM)]); $list->linkFeed = true; return $list; } @@ -94,12 +81,11 @@ class ItemList { /** * Create an item list with all the current user's unread items * - * @param SQLite3 $db The database connection to use to obtain items * @return static An item list with all unread items */ - public static function allUnread(SQLite3 $db): static + public static function allUnread(): static { - $list = new static($db, self::makeQuery($db, ['item.is_read = 0']), 'Unread'); + $list = new static('Unread', fields: [Data::unreadField(Table::ITEM)]); $list->linkFeed = true; return $list; } @@ -108,13 +94,11 @@ class ItemList { * Create an item list with all items for the given feed * * @param int $feedId The ID of the feed for which items should be retrieved - * @param SQLite3 $db The database connection to use to obtain items * @return static An item list with all items for the given feed */ - public static function allForFeed(int $feedId, SQLite3 $db): static + public static function allForFeed(int $feedId): static { - $list = new static($db, self::makeQuery($db, ['feed.id = :feed'], [[':feed', $feedId]]), '', - "/feed/items?id=$feedId"); + $list = new static('', "/feed/items?id=$feedId", [Data::feedField($feedId, Table::FEED)]); $list->showIndicators = true; return $list; } @@ -123,27 +107,24 @@ class ItemList { * Create an item list with unread items for the given feed * * @param int $feedId The ID of the feed for which items should be retrieved - * @param SQLite3 $db The database connection to use to obtain items * @return static An item list with unread items for the given feed */ - public static function unreadForFeed(int $feedId, SQLite3 $db): static + public static function unreadForFeed(int $feedId): static { - return new static($db, self::makeQuery($db, ['feed.id = :feed', 'item.is_read = 0'], [[':feed', $feedId]]), - 'Unread', "/feed/items?id=$feedId&unread"); + return new static('Unread', "/feed/items?id=$feedId&unread", + [Data::feedField($feedId, Table::FEED), Data::unreadField(Table::ITEM)]); } /** * Create an item list with bookmarked items for the given feed * * @param int $feedId The ID of the feed for which items should be retrieved - * @param SQLite3 $db The database connection to use to obtain items * @return static An item list with bookmarked items for the given feed */ - public static function bookmarkedForFeed(int $feedId, SQLite3 $db): static + public static function bookmarkedForFeed(int $feedId): static { - return new static($db, - self::makeQuery($db, ['feed.id = :feed', 'item.is_bookmarked = 1'], [[':feed', $feedId]]), 'Bookmarked', - "/feed/items?id=$feedId&bookmarked"); + return new static('Bookmarked', "/feed/items?id=$feedId&bookmarked", + [Data::feedField($feedId, Table::FEED), Data::bookmarkField(Table::ITEM)]); } /** @@ -151,16 +132,16 @@ class ItemList { * * @param string $search The item search terms / query * @param bool $isBookmarked Whether to restrict the search to bookmarked items - * @param SQLite3 $db The database connection to use to obtain items * @return static An item list match the given search terms */ - public static function matchingSearch(string $search, bool $isBookmarked, SQLite3 $db): static + public static function matchingSearch(string $search, bool $isBookmarked): static { - $where = $isBookmarked ? ['item.is_bookmarked = 1'] : []; - $where[] = 'item.id IN (SELECT ROWID FROM item_search WHERE content MATCH :search)'; - $list = new static($db, self::makeQuery($db, $where, [[':search', $search]]), - 'Matching' . ($isBookmarked ? ' Bookmarked' : ''), - "/search?search=$search&items=" . ($isBookmarked ? 'bookmarked' : 'all')); + $fields = [Field::EQ('content', $search, '@search')]; + if ($isBookmarked) $fields[] = Data::bookmarkField(Table::ITEM); + $list = new static('Matching' . ($isBookmarked ? ' Bookmarked' : ''), + "/search?search=$search&items=" . ($isBookmarked ? 'bookmarked' : 'all'), $fields, + ' ' . Table::ITEM . ".data->>'" . Configuration::idField() . "' IN " + . '(SELECT ROWID FROM item_search WHERE content MATCH @search)'); $list->showIndicators = true; $list->displayFeed = true; return $list; @@ -171,34 +152,33 @@ class ItemList { */ public function render(): void { - if ($this->isError()) { ?> -

Error retrieving list:
error?>isError()) { + echo "

Error retrieving list:
$this->error"; return; } - $item = $this->items->fetchArray(SQLITE3_ASSOC); - $return = $this->returnURL == '' ? '' : '&from=' . urlencode($this->returnURL); + $return = $this->returnURL == '' ? '' : '&from=' . urlencode($this->returnURL); + $hasItems = false; echo '

'; - if ($item) { - while ($item) { ?> -


- showIndicators) { - if (!$item['is_read']) echo 'Unread   '; - if ($item['is_bookmarked']) echo 'Bookmarked   '; - } - echo '' . date_time($item['as_of']) . ''; - if ($this->linkFeed) { - echo ' • ' . - hx_get("/feed/items?id={$item['feed_id']}&" . strtolower($this->itemType), - htmlentities($item['feed_title'])); - } elseif ($this->displayFeed) { - echo ' • ' . htmlentities($item['feed_title']); - } ?> - items->fetchArray(SQLITE3_ASSOC); + iterator_apply($this->dbList->items(), function (ItemWithFeed $it) use (&$hasItems, $return) + { + $hasItems = true; + echo '

' . hx_get("/item?id=$it->id$return", strip_tags($it->title)) . '
'; + if ($this->showIndicators) { + if (!$it->isRead()) echo 'Unread   '; + if ($it->isBookmarked()) echo 'Bookmarked   '; } - } else { ?> -

There are no itemType)?> items' . date_time($it->updated_on ?? $it->published_on) . ''; + if ($this->linkFeed) { + echo ' • ' . + hx_get("/feed/items?id={$it->feed->id}&" . strtolower($this->itemType), + htmlentities($it->feed->title)); + } elseif ($this->displayFeed) { + echo ' • ' . htmlentities($it->feed->title); + } + echo ''; + }); + if (!$hasItems) { + echo '

There are no ' . strtolower($this->itemType) . ' items'; } echo '

'; } diff --git a/src/lib/ItemWithFeed.php b/src/lib/ItemWithFeed.php new file mode 100644 index 0000000..46ea813 --- /dev/null +++ b/src/lib/ItemWithFeed.php @@ -0,0 +1,73 @@ +>'feed_id' = " . Table::FEED . ".data->>'id'"; + + /** @var string The `SELECT` clause to add the feed as a property to the item's document */ + public const string SELECT_WITH_FEED = + 'SELECT json_set(' . Table::ITEM . ".data, '$.feed', " . Table::FEED . '.data) AS data FROM ' + . self::FROM_WITH_JOIN; + + /** @var Feed The feed to which this item belongs */ + public Feed $feed; + + /** + * Create JSON comparison fields to retrieve items while also checking the owning user + * + * @param int $id The ID of the item being retrieved + * @return array|Field[] The fields for item ID and user ID + */ + private static function idAndUserFields(int $id): array + { + $idField = Field::EQ(Configuration::idField(), $id, '@id'); + $idField->qualifier = Table::ITEM; + $userField = Field::EQ('user_id', $_SESSION[Key::USER_ID], '@user'); + $userField->qualifier = Table::FEED; + return [$idField, $userField]; + } + + /** + * Check an item's existence via its ID + * + * @param int $id The ID of the item whose existence should be checked + * @return bool True if the item exists for the current user, false if not + * @throws DocumentException If any is encountered + */ + public static function existsById(int $id): bool + { + $fields = self::idAndUserFields($id); + return Custom::scalar(Query\Exists::query(self::FROM_WITH_JOIN, Query::whereByFields($fields)), + Parameters::addFields($fields, []), Results::toExists(...)); + } + + /** + * Retrieve an item via its ID + * + * @param int $id The ID of the item to be retrieved + * @return ItemWithFeed|false The item if it is found, false if not + * @throws DocumentException If any is encountered + */ + public static function retrieveById(int $id): ItemWithFeed|false + { + $fields = self::idAndUserFields($id); + return Custom::single(self::SELECT_WITH_FEED . ' WHERE ' . Query::whereByFields($fields), + Parameters::addFields($fields, []), new JsonMapper(self::class)); + } +} diff --git a/src/lib/Key.php b/src/lib/Key.php index 1546baf..137e848 100644 --- a/src/lib/Key.php +++ b/src/lib/Key.php @@ -1,4 +1,5 @@ -qualifier = Table::FEED; - $bookField = Field::EQ('is_bookmarked', 1, '@book'); - $bookField->qualifier = Table::ITEM; - $fields = [$userField, $bookField]; - - $item = Table::ITEM; - $feed = Table::FEED; - $where = Query::whereByFields($fields); - return Custom::scalar(<<>'feed_id' = $feed.data->>'id' - WHERE $where) - SQL, Parameters::addFields($fields, []), Results::toExists(...)); + $fields = [Data::userIdField(Table::FEED), Data::bookmarkField(Table::ITEM)]; + return Custom::scalar(Query\Exists::query(ItemWithFeed::FROM_WITH_JOIN, Query::whereByFields($fields)), + Parameters::addFields($fields, []), Results::toExists(...)); } } diff --git a/src/public/bookmark.php b/src/public/bookmark.php index 2e8ffd1..d03039e 100644 --- a/src/public/bookmark.php +++ b/src/public/bookmark.php @@ -1,4 +1,4 @@ -prepare( - 'SELECT item.id FROM item INNER JOIN feed ON feed.id = item.feed_id WHERE item.id = :id AND feed.user_id = :user'); -$existsQuery->bindValue(':id', $id); -$existsQuery->bindValue(':user', $_SESSION[Key::USER_ID]); -$existsResult = $existsQuery->execute(); -$exists = $existsResult ? $existsResult->fetchArray(SQLITE3_ASSOC) : false; - -if (!$exists) not_found(); +if (!$item = ItemWithFeed::retrieveById($id)) not_found(); if (key_exists('action', $_GET)) { $flag = match ($_GET['action']) { @@ -40,15 +27,14 @@ if (key_exists('action', $_GET)) { }; if (isset($flag)) { try { - Patch::byId(Table::ITEM, $id, ['is_bookmarked' => $flag], $db); + Patch::byId(Table::ITEM, $id, ['is_bookmarked' => $flag]); + $item->is_bookmarked = $flag; } catch (DocumentException $ex) { add_error("$ex"); } } } -if (!$item = Find::byId(Table::ITEM, $id, Item::class)) not_found(); - $action = $item->isBookmarked() ? 'remove' : 'add'; $icon = $item->isBookmarked() ? 'added' : 'add'; ?>