From 0991ba8ed2953169dd67fcbc8adf53888870320e Mon Sep 17 00:00:00 2001
From: Jared Hancock <jared@osticket.com>
Date: Sat, 31 Oct 2015 10:41:31 -0500
Subject: [PATCH] orm: Add support for overlay annotations

This is a new concept for many-to-many relationships, where the extra fields
from the middle table can be overlaid over the related model using the
AnnotatedModel paradigm. The overlaid fields are writable and updates are
saved to the middle model.

This is performed using a new ::wrap method of the AnnotatedModel class.

AnnotatedModel::wrap($what, $overlay, [$class])

Which will create a new class which is a subclass of `$what`, and will have
the extra properties in `$overlay` accessible as normal properties.
`$overlay` can be an array or a VerySimpleModel instance. The latter is
required for writable overlays.
---
 include/class.orm.php | 116 +++++++++++++++++++++++++++++++++---------
 1 file changed, 91 insertions(+), 25 deletions(-)

diff --git a/include/class.orm.php b/include/class.orm.php
index 45b300f64..50b0a3046 100644
--- a/include/class.orm.php
+++ b/include/class.orm.php
@@ -697,6 +697,10 @@ class VerySimpleModel {
             $pk[$f] = $this->ht[$f];
         return $pk;
     }
+
+    function __getDbFields() {
+        return $this->ht;
+    }
 }
 
 /**
@@ -707,40 +711,100 @@ class VerySimpleModel {
  * will delegate most all of the heavy lifting to the wrapped Model instance.
  */
 class AnnotatedModel {
+    static function wrap(VerySimpleModel $model, $extras=array(), $class=false) {
+        static $classes;
 
-    var $model;
-    var $annotations;
+        $class = $class ?: get_class($model);
 
-    function __construct($model, $annotations) {
-        $this->model = $model;
-        $this->annotations = $annotations;
-    }
+        if ($extras instanceof VerySimpleModel) {
+            $extra = "Writeable";
+        }
+        if (!isset($classes[$class])) {
+            $classes[$class] = eval(<<<END_CLASS
+class AnnotatedModel___{$class}_{$extra}
+extends {$class} {
+    var \$__overlay__;
+    use {$extra}AnnotatedModelTrait;
 
-    function __get($what) {
-        return $this->get($what);
+    function __construct(\$ht, \$annotations) {
+        parent::__construct(\$ht);
+        \$this->__overlay__ = \$annotations;
     }
-    function get($what) {
-        if (isset($this->annotations[$what]))
-            return $this->annotations[$what];
-        return $this->model->get($what, null);
+}
+return "AnnotatedModel___{$class}_{$extra}";
+END_CLASS
+            );
+        }
+        return new $classes[$class]($model->ht, $extras);
     }
+}
 
-    function __set($what, $to) {
-        return $this->set($what, $to);
+trait AnnotatedModelTrait {
+    function get($what) {
+        if (isset($this->__overlay__[$what]))
+            return $this->__overlay__[$what];
+        return parent::get($what);
     }
+
     function set($what, $to) {
-        if (isset($this->annotations[$what]))
+        if (isset($this->__overlay__[$what]))
             throw new OrmException('Annotated fields are read-only');
-        return $this->model->set($what, $to);
+        return parent::set($what, $to);
     }
 
     function __isset($what) {
-        return isset($this->annotations[$what]) || $this->model->__isset($what);
+        if (isset($this->__overlay__[$what]))
+            return true;
+        return parent::__isset($what);
     }
 
-    // Delegate everything else to the model
-    function __call($what, $how) {
-        return call_user_func_array(array($this->model, $what), $how);
+    function __getDbFields() {
+        return $this->__overlay__ + parent::__getDbFields();
+    }
+}
+
+/**
+ * Slight variant on the AnnotatedModelTrait, except that the overlay is
+ * another model. Its fields are preferred over the wrapped model's fields.
+ * Updates to the overlayed fields are tracked in the overlay model and
+ * therefore kept separate from the annotated model's fields. ::save() will
+ * call save on both models. Delete will only delete the overlay model (that
+ * is, the annotated model will remain).
+ */
+trait WriteableAnnotatedModelTrait {
+    function get($what) {
+        if ($this->__overlay__->__isset($what))
+            return $this->__overlay__->get($what);
+        return parent::get($what);
+    }
+
+    function set($what, $to) {
+        if ($this->__overlay__->__isset($what)) {
+            return $this->__overlay__->set($what, $to);
+        }
+        return parent::set($what, $to);
+    }
+
+    function __isset($what) {
+        if ($this->__overlay__->__isset($what))
+            return true;
+        return parent::__isset($what);
+    }
+
+    function __getDbFields() {
+        return $this->__overlay__->__getDbFields() + parent::__getDbFields();
+    }
+
+    function save() {
+        $this->__overlay__->save();
+        return parent::save();
+    }
+
+    function delete() {
+        if ($rv = $this->__overlay__->delete())
+            // Mark the annotated object as deleted
+            $this->__deleted__ = true;
+        return $rv;
     }
 }
 
@@ -1740,7 +1804,7 @@ implements IteratorAggregate {
         }
         // Wrap annotations in an AnnotatedModel
         if ($extras) {
-            $m = new AnnotatedModel($m, $extras);
+            $m = AnnotatedModel::wrap($m, $extras, $modelClass);
         }
         // TODO: If the model has deferred fields which are in $fields,
         // those can be resolved here
@@ -1933,12 +1997,14 @@ extends ModelResultSet {
     }
 
     function add($object, $at=false) {
-        if (!$object || !$object instanceof $this->model)
+        // NOTE: Attempting to compare $object to $this->model will likely
+        // be problematic, and limits creative applications of the ORM
+        if (!$object) {
             throw new Exception(sprintf(
-                'Attempting to add invalid object to list. Expected <%s>, but got <%s>',
-                $this->model,
-                get_class($object)
+                'Attempting to add invalid object to list. Expected <%s>, but got <NULL>',
+                $this->model
             ));
+        }
 
         foreach ($this->key as $field=>$value)
             $object->set($field, $value);
-- 
GitLab