aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorwinter2024-12-08 23:18:35 +0000
committerwinter2024-12-08 23:18:35 +0000
commitbda28640d6e066ae338c6f31407df274ed09f026 (patch)
tree2a887dd4f98f79fc11efefc9585c58e144f66a00
parentb00185ddbac9ac3de975a3954f2ede2f24458f6a (diff)
implement remote object fetching!
this is most of the tricky parts of inbound federation :3
-rw-r--r--WpfTest/Model.php130
-rw-r--r--WpfTest/Model/Actor.php16
-rw-r--r--WpfTest/Model/ActorEndpoints.php12
-rw-r--r--WpfTest/Model/FetchableModel.php78
-rw-r--r--WpfTest/Model/Note.php22
-rw-r--r--WpfTest/Model/NoteAttachment.php12
-rw-r--r--WpfTest/Model/NotePrivacy.php31
-rw-r--r--WpfTest/Model/NotePrivacyInteractors.php10
-rw-r--r--WpfTest/Model/NotePrivacyScope.php10
-rw-r--r--WpfTest/RemoteFetchable.php21
-rw-r--r--routes/note.php2
11 files changed, 307 insertions, 37 deletions
diff --git a/WpfTest/Model.php b/WpfTest/Model.php
index c006ffd..53b2d83 100644
--- a/WpfTest/Model.php
+++ b/WpfTest/Model.php
@@ -1,7 +1,28 @@
<?php
namespace WpfTest;
+use WpfTest\Model\FetchableModel;
+
abstract class Model {
+ private bool $saved = false;
+
+ protected function setOwnerId(int $id) {
+ }
+
+ /**
+ * @return ?string a condition for a WHERE clause if this object already
+ * exists in the database, or null otherwise
+ */
+ protected function getUpdateWhereClause(\PDO $db): ?string {
+ if (isset($this->id) && static::find($this->{'id'}) != null) {
+ return 'id = ' . $this->{'id'};
+ }
+ return null;
+ }
+
+ /**
+ * Converts a field or type name into the version used in the database
+ */
private static function mangleName(string $name): string {
$chars = str_split($name);
$chars[0] = strtolower($chars[0]);
@@ -14,6 +35,9 @@ abstract class Model {
return implode($chars);
}
+ /**
+ * Converts a database column name into the corresponding field name
+ */
private static function unmangleName(string $name): string {
$chars = str_split($name);
for ($i = 0; $i < count($chars); $i++) {
@@ -29,7 +53,27 @@ abstract class Model {
$props = $refl->getProperties();
$result = [];
foreach ($props as $p) {
- $result[self::mangleName($p->getName())] = $p->getValue();
+ $pName = $p->getName();
+ if (!isset($this->{$pName}))
+ continue;
+ // we need to determine the plain value to insert ...
+ $value = $this->{$pName};
+ $discarded = false;
+ if ($value instanceof \DateTimeInterface) {
+ // datetime objects should be turned into iso 8601 timestamps
+ $value = $value->format('c');
+ } elseif ($value instanceof Model) {
+ // models should generally be turned into their id if there is one or removed if not
+ if (property_exists($value, 'id') && isset($value->id))
+ $value = $value->{'id'}; // doing it like this to shut up the warning (lol)
+ else
+ $discarded = true;
+ } elseif (is_array($value)) {
+ // arrays ought to be handled separately when saving (different db table)
+ $discarded = true;
+ }
+ if (!$discarded)
+ $result[self::mangleName($pName)] = $value;
}
return $result;
}
@@ -55,8 +99,8 @@ abstract class Model {
throw $e;
$typeClass = $type->getName();
if (enum_exists($typeClass)) {
- // if it's an enum we can find the enum case with the same name
- $obj->{$name} = (new \ReflectionEnum($typeClass))->getCase($value)->getValue();
+ // if it's a backed enum we can find the enum case with the same value
+ $obj->{$name} = $typeClass::from($value);
} elseif (is_subclass_of($typeClass, Model::class) && is_int($value)) {
// if it's another model we can try to look it up by id automatically
$obj->{$name} = $typeClass::find($value);
@@ -66,23 +110,25 @@ abstract class Model {
}
}
}
- // set up any extra-complicated fields if needed
- $obj->hydrate($columns);
+ // set up any referenced fields if needed
+ $obj->hydrate();
+ $obj->saved = true;
return $obj;
}
/**
- * Initialises fields of the model that contain more complex objects
- * @param array $row the row of the database
+ * Initialises fields of the model that contain referenced / foreign keyed objects
+ * which couldn't be initialised automatically
* @return void
*/
- protected function hydrate(array $row) {
+ protected function hydrate() {
}
public static function findWhere(string $whereClause, array $parameters): ?static {
$classNameParts = explode('\\', static::class);
$className = $classNameParts[count($classNameParts) - 1];
$tableName = self::mangleName($className);
+
$stmt = Db::getInstance()->getPdo()->prepare("SELECT * FROM $tableName WHERE $whereClause LIMIT 1");
$stmt->execute($parameters);
return static::fromDbRow($stmt->fetch(\PDO::FETCH_ASSOC));
@@ -92,6 +138,7 @@ abstract class Model {
$classNameParts = explode('\\', static::class);
$className = $classNameParts[count($classNameParts) - 1];
$tableName = self::mangleName($className);
+
$stmt = Db::getInstance()->getPdo()->prepare("SELECT * FROM $tableName WHERE $whereClause");
$stmt->execute($parameters);
return array_map(function ($row) {
@@ -102,4 +149,71 @@ abstract class Model {
public static function find(int $id): ?static {
return static::findWhere('id = ?', [$id]);
}
+
+ /**
+ * Inserts or updates the model's corresponding database record.
+ * Also sets the model's id field if it has one.
+ */
+ public function save() {
+ // figure out all the parameters and such for the query
+ $paramData = $this->toDbRow();
+ $columnNames = array_keys($paramData);
+ $parameterNames = array_map(function (string $column) {
+ return ":$column";
+ }, $columnNames);
+ $columns = implode(', ', $columnNames);
+ $parameters = implode(', ', $parameterNames);
+
+ $classNameParts = explode('\\', static::class);
+ $className = $classNameParts[count($classNameParts) - 1];
+ $tableName = self::mangleName($className);
+
+ $db = Db::getInstance()->getPdo();
+
+ $whereClause = $this->getUpdateWhereClause($db);
+ $shouldUpdate = $whereClause != null;
+ $stmt = $db->prepare(
+ $shouldUpdate
+ ? "UPDATE $tableName SET ($columns) = ($parameters) WHERE $whereClause"
+ : "INSERT INTO $tableName ($columns) VALUES ($parameters)"
+ );
+
+ // pdo is really stupid and casts `false`/0 to an empty string, which breaks the query
+ // so i have to cast `false`s and `0`s manually. hooray
+ // i also have to convert backed enums to their value because they dont implement Stringable
+ $paramData = array_map(function ($item) {
+ if ($item === false || $item === 0)
+ return '0';
+ if (is_a($item, \BackedEnum::class))
+ return $item->value;
+ return $item;
+ }, $paramData);
+ // actually save it !!!! yay
+ $stmt->execute($paramData);
+
+ if (property_exists($this, 'id')) {
+ $this->id = $shouldUpdate
+ ? static::findWhere($whereClause, [])->id
+ : (int) $db->lastInsertId();
+ }
+
+ // recursively save inner models
+ // but not fetchable ones because they should really be saved separately
+ $reflProps = (new \ReflectionObject($this))->getProperties();
+ foreach ($reflProps as $reflProp) {
+ assert($reflProp instanceof \ReflectionProperty);
+ $reflType = $reflProp->getType();
+ $propName = $reflProp->getName();
+ if (!($reflType instanceof \ReflectionNamedType))
+ continue;
+ $type = $reflType->getName();
+ if (is_subclass_of($type, Model::class) && !is_subclass_of($type, FetchableModel::class)) {
+ if (property_exists($this, 'id'))
+ $this->{$propName}->setOwnerId($this->id);
+ $this->{$propName}->save();
+ }
+ }
+
+ $this->saved = true;
+ }
} \ No newline at end of file
diff --git a/WpfTest/Model/Actor.php b/WpfTest/Model/Actor.php
index eaef99c..0b0e729 100644
--- a/WpfTest/Model/Actor.php
+++ b/WpfTest/Model/Actor.php
@@ -1,9 +1,9 @@
<?php
namespace WpfTest\Model;
-use WpfTest\Model;
+use WpfTest\RemoteFetchable;
-class Actor extends Model implements \JsonSerializable {
+class Actor extends FetchableModel implements \JsonSerializable {
public ?int $id;
public string $uri;
public bool $isLocal = false;
@@ -20,6 +20,14 @@ class Actor extends Model implements \JsonSerializable {
public ActorEndpoints $endpoints;
public array $extensions = [];
+ protected function getUpdateWhereClause(\PDO $db): ?string {
+ if (self::findWhere('uri = ?', [$this->uri]) != null)
+ return 'uri = ' . $db->quote($this->uri);
+ if (self::findWhere('id = ?', [$this->id]) != null)
+ return "id = $this->id";
+ return null;
+ }
+
public static function findLocalByHandle(string $handle): ?self {
return self::findWhere('is_local = true AND handle = ?', [$handle]);
}
@@ -42,4 +50,8 @@ class Actor extends Model implements \JsonSerializable {
]
];
}
+
+ public function hydrate() {
+ $this->endpoints = ActorEndpoints::findWhere('actor_id = ?', [$this->id]);
+ }
} \ No newline at end of file
diff --git a/WpfTest/Model/ActorEndpoints.php b/WpfTest/Model/ActorEndpoints.php
index 03dab9e..f6120d3 100644
--- a/WpfTest/Model/ActorEndpoints.php
+++ b/WpfTest/Model/ActorEndpoints.php
@@ -4,6 +4,7 @@ namespace WpfTest\Model;
use WpfTest\Model;
class ActorEndpoints extends Model {
+ public ?int $actorId;
public string $basicFeed;
public ?string $fullFeed;
public ?string $follow;
@@ -12,4 +13,15 @@ class ActorEndpoints extends Model {
public ?string $unblock;
public ?string $subscribe;
public ?string $unsubscribe;
+
+ protected function setOwnerId(int $id) {
+ $this->actorId = $id;
+ }
+
+ protected function getUpdateWhereClause($db): ?string {
+ if (self::findWhere('actor_id = ?', [$this->actorId]) != null) {
+ return "actor_id = $this->actorId";
+ }
+ return null;
+ }
} \ No newline at end of file
diff --git a/WpfTest/Model/FetchableModel.php b/WpfTest/Model/FetchableModel.php
new file mode 100644
index 0000000..44106d4
--- /dev/null
+++ b/WpfTest/Model/FetchableModel.php
@@ -0,0 +1,78 @@
+<?php
+namespace WpfTest\Model;
+
+use WpfTest\Model;
+use WpfTest\RemoteFetchable;
+
+abstract class FetchableModel extends Model implements RemoteFetchable {
+ private static \JsonMapper $mapper;
+
+ public static function __initStatic() {
+ self::$mapper = new \JsonMapper();
+ // this is unfortunately necessary to parse DateTime(Immutable)s properly
+ self::$mapper->bStrictObjectTypeChecking = false;
+ }
+
+ private static function fetchFromRemote(string $uri, bool $autoSave): ?static {
+ // fetch the object
+ $data = @file_get_contents($uri, context: stream_context_create(['http' => [
+ 'header' => 'Accept: application/json'
+ ]]));
+ if ($data === false)
+ return null;
+ $data = json_decode($data);
+ if ($data === false)
+ return null;
+ // default everything nullable to null
+ $obj = new static();
+ $reflProps = (new \ReflectionClass(static::class))->getProperties();
+ foreach ($reflProps as $reflProp) {
+ assert($reflProp instanceof \ReflectionProperty); // for intellisense lmao
+ if ($reflProp->getType()->allowsNull()) {
+ $obj->{$reflProp->getName()} = null;
+ }
+ }
+ // rename 'self' to 'uri' because of reasons
+ if (property_exists($data, 'self')) {
+ $data->uri = $data->self;
+ unset($data->self);
+ }
+ // map all present fields
+ self::$mapper->map($data, $obj);
+ // (hopefully) recursively fetch any other fetchable models contained in this one
+ foreach ($reflProps as $reflProp) {
+ assert($reflProp instanceof \ReflectionProperty);
+ $reflType = $reflProp->getType();
+ $propName = $reflProp->getName();
+ if (!($reflType instanceof \ReflectionNamedType))
+ continue;
+ $type = $reflType->getName();
+ if (is_subclass_of($type, FetchableModel::class) && is_string($data->{$propName})) {
+ // try to avoid recursion
+ // TODO: make a better effort at avoiding recursion (e.g. when it's more than one step nested)
+ if ($data->{$propName} == $uri) {
+ $obj->{$propName} = $obj;
+ } else {
+ $obj->{$propName} = $type::findByUri($data->{$propName}, $autoSave);
+ }
+ }
+ }
+
+ if ($autoSave) {
+ $obj->save();
+ }
+
+ return $obj;
+ }
+
+ public static function findByUri(string $uri, bool $autoSave = true, bool $forceRefetch = false): ?static {
+ if (!$forceRefetch) {
+ $obj = static::findWhere('uri = ?', [$uri]);
+ if ($obj != null)
+ return $obj;
+ }
+ return static::fetchFromRemote($uri, $autoSave);
+ }
+}
+
+FetchableModel::__initStatic(); \ No newline at end of file
diff --git a/WpfTest/Model/Note.php b/WpfTest/Model/Note.php
index 85b3567..af2d2aa 100644
--- a/WpfTest/Model/Note.php
+++ b/WpfTest/Model/Note.php
@@ -2,9 +2,8 @@
namespace WpfTest\Model;
use WpfTest\Db;
-use WpfTest\Model;
-class Note extends Model implements \JsonSerializable {
+class Note extends FetchableModel implements \JsonSerializable {
public ?int $id;
public string $uri;
public \DateTimeImmutable $created;
@@ -30,7 +29,15 @@ class Note extends Model implements \JsonSerializable {
public array $attachments = [];
public array $extensions = [];
- protected function hydrate(array $row) {
+ protected function getUpdateWhereClause(\PDO $db): ?string {
+ if (self::findWhere('uri = ?', [$this->uri]) != null)
+ return 'uri = ' . $db->quote($this->uri);
+ if (self::findWhere('id = ?', [$this->id]) != null)
+ return "id = $this->id";
+ return null;
+ }
+
+ protected function hydrate() {
$this->privacy = NotePrivacy::findWhere('note_id = ?', [$this->id]);
$this->formattedContent = $this->findFormattedContents();
$this->mentions = $this->findMentions();
@@ -74,14 +81,7 @@ class Note extends Model implements \JsonSerializable {
}, $this->mentions),
'inReplyTo' => $this->inReplyTo?->uri,
'threadApex' => $this->threadApex?->uri,
- 'privacy' => [
- 'scope' => $this->privacy->scope->name,
- 'alsoVisibleTo' => $this->privacy->alsoVisibleTo,
- 'indexable' => $this->privacy->indexable,
- 'canReshare' => $this->privacy->canReshare->name,
- 'canReply' => $this->privacy->canReply->name,
- 'canOtherInteract' => $this->privacy->canOtherInteract->name
- ],
+ 'privacy' => $this->privacy,
'attachments' => array_map(function (NoteAttachment $attachment) {
return [
'type' => $attachment->type,
diff --git a/WpfTest/Model/NoteAttachment.php b/WpfTest/Model/NoteAttachment.php
index b07d595..16947c5 100644
--- a/WpfTest/Model/NoteAttachment.php
+++ b/WpfTest/Model/NoteAttachment.php
@@ -4,8 +4,20 @@ namespace WpfTest\Model;
use WpfTest\Model;
class NoteAttachment extends Model {
+ public ?int $noteId;
public int $index;
public string $type;
public string $href;
public ?string $description;
+
+ protected function setOwnerId(int $id) {
+ $this->noteId = $id;
+ }
+
+ protected function getUpdateWhereClause($db): ?string {
+ if (self::findWhere('note_id = ?', [$this->noteId]) != null) {
+ return "note_id = $this->noteId";
+ }
+ return null;
+ }
} \ No newline at end of file
diff --git a/WpfTest/Model/NotePrivacy.php b/WpfTest/Model/NotePrivacy.php
index b7ed58a..b955447 100644
--- a/WpfTest/Model/NotePrivacy.php
+++ b/WpfTest/Model/NotePrivacy.php
@@ -3,14 +3,37 @@ namespace WpfTest\Model;
use WpfTest\Model;
-class NotePrivacy extends Model {
+class NotePrivacy extends Model implements \JsonSerializable {
+ public ?int $noteId;
public NotePrivacyScope $scope;
/**
* @var Actor[]
*/
public array $alsoVisibleTo = [];
public bool $indexable;
- public NotePrivacyInteractors $canReshare = NotePrivacyInteractors::all;
- public NotePrivacyInteractors $canReply = NotePrivacyInteractors::all;
- public NotePrivacyInteractors $canOtherInteract = NotePrivacyInteractors::all;
+ public NotePrivacyInteractors $canReshare = NotePrivacyInteractors::ALL;
+ public NotePrivacyInteractors $canReply = NotePrivacyInteractors::ALL;
+ public NotePrivacyInteractors $canOtherInteract = NotePrivacyInteractors::ALL;
+
+ protected function setOwnerId(int $id) {
+ $this->noteId = $id;
+ }
+
+ protected function getUpdateWhereClause($db): ?string {
+ if (self::findWhere('note_id = ?', [$this->noteId]) != null) {
+ return "note_id = $this->noteId";
+ }
+ return null;
+ }
+
+ public function jsonSerialize(): array {
+ return [
+ 'scope' => $this->scope->value,
+ 'alsoVisibleTo' => $this->alsoVisibleTo,
+ 'indexable' => $this->indexable,
+ 'canReshare' => $this->canReshare->value,
+ 'canReply' => $this->canReply->value,
+ 'canOtherInteract' => $this->canOtherInteract->value
+ ];
+ }
} \ No newline at end of file
diff --git a/WpfTest/Model/NotePrivacyInteractors.php b/WpfTest/Model/NotePrivacyInteractors.php
index ff4d7fa..917d90a 100644
--- a/WpfTest/Model/NotePrivacyInteractors.php
+++ b/WpfTest/Model/NotePrivacyInteractors.php
@@ -1,9 +1,9 @@
<?php
namespace WpfTest\Model;
-enum NotePrivacyInteractors {
- case all;
- case followers;
- case mutuals;
- case none;
+enum NotePrivacyInteractors: string {
+ case ALL = 'all';
+ case FOLLOWERS = 'followers';
+ case MUTUALS = 'mutuals';
+ case NONE = 'none';
} \ No newline at end of file
diff --git a/WpfTest/Model/NotePrivacyScope.php b/WpfTest/Model/NotePrivacyScope.php
index 3134c26..44c9344 100644
--- a/WpfTest/Model/NotePrivacyScope.php
+++ b/WpfTest/Model/NotePrivacyScope.php
@@ -1,9 +1,9 @@
<?php
namespace WpfTest\Model;
-enum NotePrivacyScope {
- case public;
- case followers;
- case mutuals;
- case none;
+enum NotePrivacyScope: string {
+ case PUBLIC = 'public';
+ case FOLLOWERS = 'followers';
+ case MUTUALS = 'mutuals';
+ case NONE = 'none';
} \ No newline at end of file
diff --git a/WpfTest/RemoteFetchable.php b/WpfTest/RemoteFetchable.php
new file mode 100644
index 0000000..0782e7e
--- /dev/null
+++ b/WpfTest/RemoteFetchable.php
@@ -0,0 +1,21 @@
+<?php
+namespace WpfTest;
+
+interface RemoteFetchable {
+ /**
+ * Fetches the object by its URI from the remote server.
+ * @param string $uri the URI of the remote object
+ * @param bool $autoSave whether to automatically save the fetched object to the database
+ * @return static
+ */
+ //public static function fetchFromRemote(string $uri, bool $autoSave);
+
+ /**
+ * Finds the object locally by its URI or fetches it remotely if not found.
+ * @param string $uri the URI identifier of the object
+ * @param bool $autoSave whether to automatically save the object (if fetched) to the database
+ * @param bool $forceRefetch whether to forcibly refetch the object even if it's in the database
+ * @return static
+ */
+ public static function findByUri(string $uri, bool $autoSave = true, bool $forceRefetch = false);
+} \ No newline at end of file
diff --git a/routes/note.php b/routes/note.php
index e43c2a1..65b9313 100644
--- a/routes/note.php
+++ b/routes/note.php
@@ -1,9 +1,7 @@
<?php
use WpfTest\HttpResponseStatus\NotFound;
-use WpfTest\Model\Actor;
use WpfTest\Model\Note;
-use WpfTest\Model\NoteAttachment;
use WpfTest\Router;
Router::getInstance()->mount('/post/:id', function (array $args) {