First cut of item-with-feed and list impl

- Add strict types to all files
- Convert many queries to document commands
This commit is contained in:
2024-06-02 22:14:19 -04:00
parent b88ad1f268
commit 93dd8e880f
35 changed files with 309 additions and 319 deletions

View File

@@ -1,7 +1,9 @@
<?php
<?php declare(strict_types=1);
namespace FeedReaderCentral;
use BitBadger\Documents\DocumentException;
use BitBadger\Documents\Field;
use BitBadger\Documents\SQLite\Configuration;
use BitBadger\Documents\SQLite\Custom;
use BitBadger\Documents\SQLite\Definition;
@@ -77,6 +79,59 @@ class Data
$db->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
*

View File

@@ -1,4 +1,5 @@
<?php
<?php declare(strict_types=1);
namespace FeedReaderCentral;
use BitBadger\Documents\DocumentException;
@@ -10,6 +11,7 @@ use BitBadger\Documents\SQLite\Custom;
use BitBadger\Documents\SQLite\Document;
use BitBadger\Documents\SQLite\Exists;
use BitBadger\Documents\SQLite\Find;
use BitBadger\Documents\SQLite\Parameters;
use BitBadger\Documents\SQLite\Patch;
use DateTimeInterface;
use SQLite3;
@@ -137,7 +139,7 @@ class Feed
SQL;
}
try {
Custom::nonQuery($sql, array_merge(array_map($it -> $it->asParameter(), $fields)), $db);
Custom::nonQuery($sql, Parameters::addFields($fields, []), $db);
return ['ok' => true];
} catch (DocumentException $ex) {
return ['error' => "$ex"];

View File

@@ -1,13 +1,6 @@
<?php
namespace FeedReaderCentral;
<?php declare(strict_types=1);
use BitBadger\Documents\DocumentException;
use BitBadger\Documents\Field;
use BitBadger\Documents\JsonMapper;
use BitBadger\Documents\Query;
use BitBadger\Documents\SQLite\Configuration;
use BitBadger\Documents\SQLite\Custom;
use BitBadger\Documents\SQLite\Parameters;
namespace FeedReaderCentral;
/**
* An item from a feed
@@ -71,27 +64,4 @@ class Item
updated_on: $item->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));
}
}

View File

@@ -1,51 +0,0 @@
<?php
namespace FeedReaderCentral;
use BitBadger\Documents\JsonMapper;
use BitBadger\Documents\Mapper;
use FeedReaderCentral\Domain\Feed;
/**
* A combined item and feed (used for lists)
*/
class ItemAndFeed
{
/** @var Item The item to be manipulated */
public Item $item;
/** @var Feed The feed to which the item belongs */
public Feed $feed;
/**
* Create a mapper for this item
* @return Mapper<ItemAndFeed> 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 <<<SQL
SELECT $item.data AS item_data, $feed.data AS feed_data
FROM $item INNER JOIN $feed ON $item.data->>'feed_id' = $feed.data->>'id'
SQL;
}
}

View File

@@ -1,19 +1,25 @@
<?php
<?php declare(strict_types=1);
namespace FeedReaderCentral;
use SQLite3;
use SQLite3Result;
use SQLite3Stmt;
use BitBadger\Documents\Configuration;
use BitBadger\Documents\DocumentException;
use BitBadger\Documents\DocumentList;
use BitBadger\Documents\Field;
use BitBadger\Documents\JsonMapper;
use BitBadger\Documents\Query;
use BitBadger\Documents\SQLite\Custom;
use BitBadger\Documents\SQLite\Parameters;
/**
* A list of items to be displayed
*
* This is a wrapper for retrieval and display of arbitrary lists of items based on a SQLite result.
*/
class ItemList {
/** @var SQLite3Result The list of items to be displayed */
private SQLite3Result $items;
class ItemList
{
/** @var DocumentList<ItemWithFeed> 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 = <<<SQL
SELECT item.id, item.feed_id, item.title AS item_title, coalesce(item.updated_on, item.published_on) AS as_of,
item.is_read, item.is_bookmarked, feed.title AS feed_title
FROM item INNER JOIN feed ON feed.id = item.feed_id
WHERE feed.user_id = :userId $where
ORDER BY coalesce(item.updated_on, item.published_on) DESC
SQL;
$query = $db->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()) { ?>
<p>Error retrieving list:<br><?=$this->error?><?php
if ($this->isError()) {
echo "<p>Error retrieving list:<br>$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 '<article>';
if ($item) {
while ($item) { ?>
<p><?=hx_get("/item?id={$item['id']}$return", strip_tags($item['item_title']))?><br>
<small><?php
if ($this->showIndicators) {
if (!$item['is_read']) echo '<strong>Unread</strong> &nbsp; ';
if ($item['is_bookmarked']) echo '<strong>Bookmarked</strong> &nbsp; ';
}
echo '<em>' . date_time($item['as_of']) . '</em>';
if ($this->linkFeed) {
echo ' &bull; ' .
hx_get("/feed/items?id={$item['feed_id']}&" . strtolower($this->itemType),
htmlentities($item['feed_title']));
} elseif ($this->displayFeed) {
echo ' &bull; ' . htmlentities($item['feed_title']);
} ?>
</small><?php
$item = $this->items->fetchArray(SQLITE3_ASSOC);
iterator_apply($this->dbList->items(), function (ItemWithFeed $it) use (&$hasItems, $return)
{
$hasItems = true;
echo '<p>' . hx_get("/item?id=$it->id$return", strip_tags($it->title)) . '<br><small>';
if ($this->showIndicators) {
if (!$it->isRead()) echo '<strong>Unread</strong> &nbsp; ';
if ($it->isBookmarked()) echo '<strong>Bookmarked</strong> &nbsp; ';
}
} else { ?>
<p><em>There are no <?=strtolower($this->itemType)?> items</em><?php
echo '<em>' . date_time($it->updated_on ?? $it->published_on) . '</em>';
if ($this->linkFeed) {
echo ' &bull; ' .
hx_get("/feed/items?id={$it->feed->id}&" . strtolower($this->itemType),
htmlentities($it->feed->title));
} elseif ($this->displayFeed) {
echo ' &bull; ' . htmlentities($it->feed->title);
}
echo '</small>';
});
if (!$hasItems) {
echo '<p><em>There are no ' . strtolower($this->itemType) . ' items</em>';
}
echo '</article>';
}

73
src/lib/ItemWithFeed.php Normal file
View File

@@ -0,0 +1,73 @@
<?php declare(strict_types=1);
namespace FeedReaderCentral;
use BitBadger\Documents\DocumentException;
use BitBadger\Documents\Field;
use BitBadger\Documents\JsonMapper;
use BitBadger\Documents\Query;
use BitBadger\Documents\SQLite\Configuration;
use BitBadger\Documents\SQLite\Custom;
use BitBadger\Documents\SQLite\Parameters;
use BitBadger\Documents\SQLite\Results;
/**
* A combined item and feed (used for lists)
*/
class ItemWithFeed extends Item
{
/** @var string The body of the `FROM` clause to join item and feed */
public const string FROM_WITH_JOIN = Table::ITEM . ' INNER JOIN ' . Table::FEED
. ' ON ' . Table::ITEM . ".data->>'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));
}
}

View File

@@ -1,4 +1,5 @@
<?php
<?php declare(strict_types=1);
namespace FeedReaderCentral;
/**

View File

@@ -1,4 +1,4 @@
<?php
<?php declare(strict_types=1);
namespace FeedReaderCentral;

View File

@@ -1,4 +1,5 @@
<?php
<?php declare(strict_types=1);
namespace FeedReaderCentral;
use DOMNode;

View File

@@ -1,4 +1,5 @@
<?php
<?php declare(strict_types=1);
namespace FeedReaderCentral;
use BitBadger\Documents\DocumentException;
@@ -67,6 +68,7 @@ class Security
$dbEmail = $email;
}
$user = User::findByEmail($dbEmail);
var_dump($user);
if ($user) self::verifyPassword($user, $password, $returnTo);
add_error('Invalid credentials; log on unsuccessful');
}

View File

@@ -1,4 +1,5 @@
<?php
<?php declare(strict_types=1);
namespace FeedReaderCentral;
/**

View File

@@ -1,4 +1,5 @@
<?php
<?php declare(strict_types=1);
namespace FeedReaderCentral;
use BitBadger\Documents\DocumentException;
@@ -9,7 +10,6 @@ use BitBadger\Documents\SQLite\Document;
use BitBadger\Documents\SQLite\Find;
use BitBadger\Documents\SQLite\Parameters;
use BitBadger\Documents\SQLite\Results;
use SQLite3;
/**
* A user of Feed Reader Central
@@ -51,25 +51,14 @@ class User
/**
* Does this user have any bookmarked items?
*
* @return bool True if the user has any bookmarked items, false if not
* @throws DocumentException If any is encountered
*/
public static function hasBookmarks(): bool
{
$userField = Field::EQ('user_id', $_SESSION[Key::USER_ID], '@user');
$userField->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(<<<SQL
SELECT EXISTS (
SELECT 1
FROM $item INNER JOIN $feed ON $item.data->>'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(...));
}
}