Skip to content

Instantly share code, notes, and snippets.

@jmikola
Created November 3, 2022 08:25
Show Gist options
  • Save jmikola/b44353a2d1879bff2e801281a72baf59 to your computer and use it in GitHub Desktop.
Save jmikola/b44353a2d1879bff2e801281a72baf59 to your computer and use it in GitHub Desktop.
Change tracking using ext-mongodb's BSON API
$ php tracked_persistable.php
object(MongoDB\Examples\User)#29 (3) {
["name"]=>
string(7) "alcaeus"
["emails"]=>
object(MongoDB\Examples\TrackedBSONArray)#24 (0) {
}
["_id"]=>
object(MongoDB\BSON\ObjectId)#15 (1) {
["oid"]=>
string(24) "63637a94909ef8efcf01fc92"
}
}
array(1) {
["$set"]=>
array(2) {
["name"]=>
string(7) "andreas"
["emails.2"]=>
array(2) {
["type"]=>
string(7) "private"
["address"]=>
string(19) "[email protected]"
}
}
}
object(MongoDB\Examples\User)#32 (3) {
["name"]=>
string(7) "andreas"
["emails"]=>
object(MongoDB\Examples\TrackedBSONArray)#35 (0) {
}
["_id"]=>
object(MongoDB\BSON\ObjectId)#31 (1) {
["oid"]=>
string(24) "63637a94909ef8efcf01fc92"
}
}
<?php
declare(strict_types=1);
namespace MongoDB\Examples;
use MongoDB\BSON\ObjectId;
use MongoDB\BSON\Persistable;
use MongoDB\BSON\Serializable;
use MongoDB\BSON\Unserializable;
use MongoDB\Client;
use MongoDB\Model\BSONArray;
use ReflectionObject;
use ReflectionProperty;
use function array_filter;
use function array_key_exists;
use function count;
use function get_object_vars;
use function getenv;
use function var_dump;
require __DIR__ . '/../vendor/autoload.php';
#[Attribute(Attribute::TARGET_PROPERTY)]
class TrackChanges
{
}
interface Tracked extends Serializable, Unserializable
{
public function getInitialData(): ?array;
}
trait TrackedTrait
{
private readonly array $__initialData;
public function getInitialData(): ?array
{
return $this->__initialData ?? null;
}
private function setInitialData(array $initialData): void
{
$this->__initialData = $initialData;
}
public function __debugInfo(): array
{
if ($this instanceof ArrayObject) {
return $this->getArrayCopy();
}
$data = get_object_vars($this);
unset($data['__initialData']);
return $data;
}
}
interface TrackedPersistable extends Tracked, Persistable
{
}
trait TrackedPersistableTrait
{
use TrackedTrait;
public function bsonSerialize(): array
{
$data = [];
foreach (getTrackedProperties($this) as $rp) {
if ($rp->getValue($this) === null) {
continue;
}
$data[$rp->getName()] = $rp->getValue($this);
}
return $data;
}
public function bsonUnserialize(array $data): void
{
$initialData = [];
foreach (getTrackedProperties($this) as $rp) {
$key = $rp->getName();
if (array_key_exists($key, $data)) {
$rp->setValue($this, $data[$key]);
$initialData[$key] = $data[$key];
}
}
$this->setInitialData($initialData);
}
}
class TrackedBSONArray extends BSONArray implements Tracked
{
use TrackedTrait;
public function bsonUnserialize(array $data): void
{
$this->setInitialData($data);
parent::bsonUnserialize($data);
}
}
/** @return array<ReflectionProperty> */
function getTrackedProperties(Tracked $ct): array
{
$filter = ReflectionProperty::IS_PUBLIC | ReflectionProperty::IS_PROTECTED | ReflectionProperty::IS_PRIVATE;
return array_filter(
(new ReflectionObject($ct))->getProperties($filter),
fn (ReflectionProperty $rp) => count($rp->getAttributes(TrackChanges::class)) > 0
);
}
function getUpdate(TrackedPersistable $ctp): array
{
return addChangesToUpdate($ctp);
}
function addChangesToUpdate(Tracked $ct, array &$update = [], string $prefix = ''): array
{
$oldData = $ct->getInitialData();
$newData = $ct->bsonSerialize();
foreach ($newData as $key => $value) {
if ($value instanceof Tracked && $value->getInitialData() !== null) {
addChangesToUpdate($value, $update, "{$prefix}{$key}.");
continue;
}
if ($value instanceof Tracked && $value->getInitialData() === null) {
$value = $value->bsonSerialize();
}
// Set fields that are only present in $newData
if (! isset($oldData[$key])) {
$update['$set']["{$prefix}{$key}"] = $value;
continue;
}
// TODO: Handle changed fields recursively
if ($oldData[$key] != $newData[$key]) {
$update['$set']["{$prefix}{$key}"] = $value;
}
}
// Unset fields that are only present in $oldData
foreach ($oldData as $key => $_) {
if (! isset($newData[$key])) {
$update['$unset']["{$prefix}{$key}"] = 1;
}
}
return $update;
}
class Email implements TrackedPersistable
{
use TrackedPersistableTrait;
public function __construct(
#[TrackChanges]
public string $type,
#[TrackChanges]
public string $address
) {
}
}
class User implements TrackedPersistable
{
use TrackedPersistableTrait;
public function __construct(
#[TrackChanges]
public string $name,
#[TrackChanges]
public TrackedBSONArray $emails = new TrackedBSONArray(),
#[TrackChanges]
public readonly ObjectId $_id = new ObjectId(),
) {
}
}
$client = new Client(
getenv('MONGODB_URI') ?: 'mongodb://127.0.0.1/',
[],
['typeMap' => ['array' => TrackedBSONArray::class]]
);
$collection = $client->test->users;
$collection->drop();
$user = new User(name: 'alcaeus');
$user->emails[] = new Email('personal', '[email protected]');
$user->emails[] = new Email('work', '[email protected]');
$collection->insertOne($user);
$trackedUser = $collection->findOne();
var_dump($trackedUser);
$trackedUser->name = 'andreas';
/* Note: this absolutely does not work for removing array elements. That will
* likely require more active change tracking at the BSONArray level. */
$trackedUser->emails[] = new Email('private', '[email protected]');
var_dump(getUpdate($trackedUser));
$collection->updateOne(['_id' => $trackedUser->_id], getUpdate($trackedUser));
var_dump($collection->findOne());
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment