I'm trying to convert a multidimensional HTML form array to its related entity (database) object classes with one to many relations and nested one to many relations. Consider the following input exmaple (human readable):
order[id]: 1
order[note]: test note
order[ordertime]: 13. Dez. 2018 09:01
order[position][0][id]: 1
order[position][0][ordernumber]: ADSF-11
order[position][0][price]: 45.99
order[position][0][supplier][id]: 1
order[position][0][supplier][name]: test supplier 1
order[position][1][id]: 2
order[position][1][ordernumber]: ADSF-12
order[position][1][price]: 50.99
order[position][1][supplier][id]: 2
order[position][1][supplier][name]: test supplier 2
order[customer][firstname]: Human
order[customer][surname]: Being
order[customer][billingAddress][id]: 1
order[customer][billingAddress][firstname]: Human 2
order[customer][billingAddress][surname]: Being 2
order[customer][billingAddress][street]: test street 1
order[customer][billingAddress][zip]: 99999
order[customer][billingAddress][city]: test city
order[customer][shippingAddress][id]: 2
order[customer][shippingAddress][firstname]: Human 3
order[customer][shippingAddress][surname]: Being 3
order[customer][shippingAddress][street]: test street 100
order[customer][shippingAddress][zip]: 88888
order[customer][shippingAddress][city]: test city 2
We got an abstract class with empty body called AbstractEntity which every entity extends and the entities have public member variables for simple types. For arrays its access is private there are setter methods as well as addXX methods to add one entry at the end of the array (that's why reflection is needed and why we have $method1 and $method2). Additionally it parses date and time from inernationalized string to DateTime.
I would like to access them as in ORM frameworks style like Doctrine like so:
$order->getPosition()[0]->getBillingAddress()->firstname
Here is my worker class that does the main stuff:
<?php
namespace MyApp\Ajax;
use MyApp\Entity\AbstractEntity;
use MyApp\Entity\Repository;
class AjaxRequest
{
private $inputType;
private $data;
private $formatter;
private $objMapping;
private $repo;
public function __construct()
{
$this->inputType = strtolower($_SERVER['REQUEST_METHOD']) === 'post' ? \INPUT_POST : \INPUT_GET;
$this->formatter = new \IntlDateFormatter(
'de_DE',
\IntlDateFormatter::LONG,
\IntlDateFormatter::SHORT,
null,
\IntlDateFormatter::GREGORIAN,
'd. MMM Y HH:mm'
);
$this->objMapping = array(
'order' => "MyApp\\Entity\\Order",
'position' => "MyApp\\Entity\\Article",
'supplier' => "MyApp\\Entity\\Supplier",
'customer' => "MyApp\\Entity\\User",
'billingAddress' => "MyApp\\Entity\\UserAddress",
'shippingAddress' => "MyApp\\Entity\\UserAddress"
);
$this->repo = new Repository();
}
public function save()
{
$obj = $this->convertRequestToObj('order');
$this->data['success'] = $this->repo->save($obj);
$this->data['data'] = $obj;
$this->jsonResponse();
}
private function jsonResponse()
{
header('Content-type: application/json');
echo json_encode(
array(
'success' => $this->data['success'],
'data' => $this->convertToPublicObjects($this->data['data'])
)
);
}
private function convertToPublicObjects($object)
{
$names = array();
if (is_object($object) && !$object instanceof \DateTimeInterface) {
$reflection = new \ReflectionClass($object);
$columns = $reflection->getProperties();
$methods = $reflection->getMethods(\ReflectionMethod::IS_PUBLIC);
foreach ($columns as $column) {
$colName = $column->getName();
$method1 = 'get' . ucfirst($colName);
$method2 = 'is' . ucfirst($colName);
try {
if ($column->isPublic()) {
$names[$colName] = $column->getValue($object);
} else {
if ($reflection->hasMethod($method1) && $this->checkPublicMethods($methods, $method1)) {
$names[$colName] = $object->{$method1}();
} else {
if ($reflection->hasMethod($method2) && $this->checkPublicMethods($methods, $method2)) {
$names[$colName] = $object->{$method2}();
}
}
}
} catch (\ReflectionException $ex) {
$names[$colName] = null;
} catch (\TypeError $exc) {
$names[$colName] = null;
}
if (array_key_exists($colName, $names) && is_object($names[$colName])) {
if ($names[$colName] instanceof \DateTimeInterface) {
$names[$colName] = $this->formatter->format($names[$colName]);
} else {
$names[$colName] = $this->convertToPublicObjects($names[$colName]);
}
} elseif (array_key_exists($colName, $names) && is_array($names[$colName])) {
array_walk_recursive($names[$colName], array($this, 'walkReturnArray'));
}
}
}
return $names;
}
private function walkReturnArray(&$item, $key)
{
if (is_object($item)) {
$item = $this->convertToPublicObjects($item);
}
}
/**
* @param \ReflectionMethod[] $methods
* @param string $method
*
* @return bool
*/
private function checkPublicMethods(array $methods, string $method)
{
$found = false;
foreach ($methods as $meth) {
if ($meth->getName() === $method) {
$found = true;
break;
}
}
return $found;
}
/**
* Converts ORM like objects from the request from arrays to objects.
*
* @param string $key
*
* @return AbstractEntity
*/
private function convertRequestToObj(string $key)
{
$ar = filter_input($this->inputType, $key, \FILTER_DEFAULT, \FILTER_REQUIRE_ARRAY);
$baseObj = new $this->objMapping[$key]();
$this->mapArrayToObj($ar, $baseObj);
return $baseObj;
}
private function mapArrayToObj(array $ar, AbstractEntity $baseObj)
{
foreach ($ar as $column => $value) {
$reflection = new \ReflectionClass($baseObj);
$method1 = 'add' . ucfirst($column);
$method2 = 'set' . ucfirst($column);
$methods = $reflection->getMethods(\ReflectionMethod::IS_PUBLIC);
if (is_array($value)) {
$newObj = new $this->objMapping[$column]();
$this->addObjectTo($methods, $method1, $method2, $baseObj, $newObj);
$reflection = new \ReflectionClass($newObj);
$methods = $reflection->getMethods(\ReflectionMethod::IS_PUBLIC);
foreach ($value as $subCol => $subVal) {
$method2 = 'set' . ucfirst($subCol);
if (is_array($subVal)) {
if (is_numeric($subCol)) {
$this->mapArrayToObj($subVal, $newObj);
}
} else {
$this->parseSimpleType($newObj, $column, $value, $methods, $method2);
}
}
} else {
$this->parseSimpleType($baseObj, $column, $value, $methods, $method2);
}
}
}
private function parseSimpleType(AbstractEntity $obj, $column, $value, array $methods, $method2)
{
$timestamp = $this->formatter->parse($value);
if ($timestamp) {
try {
$value = new \DateTime($timestamp);
} catch (\Exception $ex) {
// nothing to do...
}
}
if ($this->checkPublicMethods($methods, $method2)) {
$obj->$method2($value);
} else {
$obj->{$column} = $value;
}
}
private function addObjectTo(array $methods, $method1, $method2, AbstractEntity $baseObj, AbstractEntity $newObj)
{
if ($this->checkPublicMethods($methods, $method1)) {
$baseObj->$method1($newObj);
} elseif ($this->checkPublicMethods($methods, $method2)) {
$baseObj->$method2($newObj);
} else {
$baseObj->{$column} = $newObj;
}
}
private function getNestedObject(AbstractEntity $obj, array $keys, $levelUp = 0)
{
if ($levelUp > 0) {
for ($i = 0; $i < $levelUp; $i++) {
unset($keys[count($keys) - 1]);
}
}
$innerObj = $obj;
$lastObj = $obj;
if (count($keys) > 0) {
foreach ($keys as $key) {
if (is_numeric($key)) {
$innerObj = $innerObj[$key];
} else {
$method = 'get' . ucfirst($key);
$reflection = new \ReflectionClass($innerObj);
$methods = $reflection->getMethods(\ReflectionMethod::IS_PUBLIC);
$lastObj = $innerObj;
if ($this->checkPublicMethods($methods, $method)) {
$innerObj = $innerObj->$method();
} else {
$innerObj = $innerObj->{$key};
}
}
}
if ($innerObj === null) {
$innerObj = $lastObj;
}
}
return $innerObj;
}
private function setNestedObject(array $parsedObjs, array $keys, AbstractEntity $objToAdd)
{
$ref = &$parsedObjs;
foreach ($keys as $key) {
$ref = &$ref[$key];
}
$ref = $objToAdd;
return $parsedObjs;
}
}
Let`s say this example calls the pubic method save. For some reason, it does the nesting wrong. Although the other way around, from objects to array using convertToPublicObjects works fine.
Here are my other tries:
With bypassed reference depth:
/**
* Converts ORM like objects from the request from arrays to objects.
*
* @param string $key
*
* @return AbstractEntity
*/
private function convertRequestToObj(string $key)
{
$ar = filter_input($this->inputType, $key, \FILTER_DEFAULT, \FILTER_REQUIRE_ARRAY);
$baseObj = new $this->objMapping[$key]();
$this->mapArrayToObj($ar, $baseObj, $baseObj);
return $baseObj;
}
private function mapArrayToObj(array $ar, AbstractEntity $baseObj, AbstractEntity $veryBaseObj, $refDepth = '')
{
foreach ($ar as $column => $value) {
$reflection = new \ReflectionClass($baseObj);
$method1 = 'add' . ucfirst($column);
$method2 = 'set' . ucfirst($column);
$methods = $reflection->getMethods(\ReflectionMethod::IS_PUBLIC);
if (is_array($value) && !is_numeric($column)) {
$refDepth .= $column .',';
$newObj = new $this->objMapping[$column]();
$this->addObjectTo($methods, $method1, $method2, $baseObj, $newObj);
$this->mapArrayToObj($value, $newObj, $veryBaseObj, $refDepth);
} elseif (is_array($value) && is_numeric($column)) {
$refDepth .= $column .',';
$refKeys = explode(',', substr($refDepth, 0, strrpos($refDepth, ',')));
$toAddObj = $this->getNestedObject($veryBaseObj, $refKeys);
$column = substr($refDepth, 0, strrpos($refDepth, ','));
$column = substr($column, 0, strrpos($column, ','));
$newObj = new $this->objMapping[$column]();
$this->addObjectTo($methods, $method1, $method2, $baseObj, $newObj);
$reflection = new \ReflectionClass($newObj);
$methods = $reflection->getMethods(\ReflectionMethod::IS_PUBLIC);
foreach ($value as $subCol => $subVal) {
if (is_array($subVal)) {
// sanitize strings like userMain,0,1,:
$refDepth = substr($refDepth, 0, strrpos($refDepth, ','));
$refDepth = substr($refDepth, 0, strrpos($refDepth, ',') + 1);
$refDepth .= $subCol . ',';
$this->mapArrayToObj($subVal, $newObj, $veryBaseObj, $refDepth);
} else {
$method2 = 'set' . ucfirst($subCol);
$this->parseSimpleType($newObj, $subCol, $subVal, $methods, $method2);
}
}
// sanitize strings like position,0,1,:
$refDepth = substr($refDepth, 0, strrpos($refDepth, ','));
$refDepth = substr($refDepth, 0, strrpos($refDepth, ',') + 1);
} else {
$refDepth = '';
$this->parseSimpleType($baseObj, $column, $value, $methods, $method2);
}
}
}
With if branches inside:
/**
* Converts ORM like objects from the request from arrays to objects.
*
* @param string $key
*
* @return AbstractEntity
*/
private function convertRequestToObj(string $key)
{
$ar = filter_input($this->inputType, $key, \FILTER_DEFAULT, \FILTER_REQUIRE_ARRAY);
$baseObj = new $this->objMapping[$key]();
$this->mapArrayToObj($ar, $baseObj, $baseObj);
return $baseObj;
}
private function mapArrayToObj(array $ar, AbstractEntity $baseObj, AbstractEntity $veryBaseObj, $refDepth = '')
{
foreach ($ar as $column => $value) {
$reflection = new \ReflectionClass($baseObj);
$method1 = 'add' . ucfirst($column);
$method2 = 'set' . ucfirst($column);
$methods = $reflection->getMethods(\ReflectionMethod::IS_PUBLIC);
if (is_array($value)) {
$refDepth .= $column .',';
$refDepthBackup = $refDepth;
$refKeys = explode(',', substr($refDepth, 0, strrpos($refDepth, ',')));
if (is_numeric($column)) {
$column = substr($refDepth, 0, strrpos($refDepth, ','));
$column = substr($column, 0, strrpos($column, ','));
$method1 = 'add' . ucfirst($column);
$toAddObj = $this->getNestedObject($veryBaseObj, $refKeys, 2);
// sanitize strings like position,0,1,:
$refDepth = substr($refDepth, 0, strrpos($refDepth, ','));
$refDepth = substr($refDepth, 0, strrpos($refDepth, ',') + 1);
} else {
$toAddObj = $baseObj;
}
$reflection = new \ReflectionClass($toAddObj);
$method1 = 'add' . ucfirst($column);
$method2 = 'set' . ucfirst($column);
$methods = $reflection->getMethods(\ReflectionMethod::IS_PUBLIC);
$newObj = new $this->objMapping[$column]();
$this->addObjectTo($methods, $method1, $method2, $toAddObj, $newObj);
$this->mapArrayToObj($value, $newObj, $veryBaseObj, $refDepthBackup);
} else {
$refDepth = '';
$this->parseSimpleType($baseObj, $column, $value, $methods, $method2);
}
}
}
With an inner foreach loop:
/**
* Converts ORM like objects from the request from arrays to objects.
*
* @param string $key
*
* @return AbstractEntity
*/
private function convertRequestToObj(string $key)
{
$ar = filter_input($this->inputType, $key, \FILTER_DEFAULT, \FILTER_REQUIRE_ARRAY);
$baseObj = new $this->objMapping[$key]();
$this->mapArrayToObj($ar, $baseObj);
return $baseObj;
}
private function mapArrayToObj(array $ar, AbstractEntity $baseObj)
{
foreach ($ar as $column => $value) {
$reflection = new \ReflectionClass($baseObj);
$method1 = 'add' . ucfirst($column);
$method2 = 'set' . ucfirst($column);
$methods = $reflection->getMethods(\ReflectionMethod::IS_PUBLIC);
if (is_array($value)) {
$newObj = new $this->objMapping[$column]();
$this->addObjectTo($methods, $method1, $method2, $baseObj, $newObj);
$reflection = new \ReflectionClass($newObj);
$methods = $reflection->getMethods(\ReflectionMethod::IS_PUBLIC);
foreach ($value as $subCol => $subVal) {
$method2 = 'set' . ucfirst($subCol);
if (is_array($subVal)) {
if (is_numeric($subCol)) {
$this->mapArrayToObj($subVal, $newObj);
}
} else {
$this->parseSimpleType($newObj, $column, $value, $methods, $method2);
}
}
} else {
$this->parseSimpleType($baseObj, $column, $value, $methods, $method2);
}
}
}
I have not worked with orm, so not sure if this is what you wanted.
Some hints:
I used php 5.6.30 so your milage may vary.
OOP is information hiding, that means teach each class what to do, no reflection.
Use fields to implement data driven framework
Implement magic get and call to dynamically access data and objects
Each class must validate its data, not implemented here
Each class must throw and catch its own exceptions, not implemented here
Use a factory pattern to create the data classes.
The interface defines the order class facade pattern.
The trait implements the default methods for all order classes.
I toyed with the idea of using XML classes, but this seems to work okay.
This is the class file that implements an order factory pattern. When creating model objects, use the factory class (static, do not instantiate) and don't instantiate the classes directly. The getValue() handles the factory::create when required. The result is the classes create themselves using the factory.
<?php /* ormorder.php */
// Object Relational Mapping (OrmOrder)
// order OrmOrder class interface methods
interface IORM
{
// function initFields(); // this should not be public?
function toArray();
function __get($name);
function __call($name,$value);
}
// order OrmOrder class trait methods
trait FORM
{
protected $fields;
protected $data;
function __construct($data)
{
parent::__construct();
$this->initFields();
$this->setData($data);
}
// always override, never call
protected function initFields(){ $this->fields = null;}
// sometimes override, never call
protected function setData($data)
{
foreach($this->fields as $field)
if(isset($data[$field]))
$this->data[$field] = $this->getValue($field,$data[$field]);
}
// seldom override, never call
protected function getValue($field,$data) { return $data; }
function toArray(){ return $this->data; }
final function __get($name)
{
if('data' == $name)
return $this->data;
return $this->data[$name];
}
function __call($name,$value)
{
$attr = $value[0];
$val = $value[1];
$result = null;
if(in_array($name, $this->fields))
if(isset($this->data[$name]))
if(is_array($this->data[$name]))
foreach($this->data[$name] as $obj)
if($obj->$attr == $val)
{
$result = $obj;
break;
}
else $result = $this->data[$name];
return $result;
}
}
// pacify php parent::_construct()
abstract
class ORMAbstract
{
function __construct() {}
}
// Main Order class that does (almost) everything
abstract
class Orm extends ORMAbstract implements IORM
{ use FORM;
}
// you should override getValue()
class Order extends Orm
{
}
class Position extends Orm
{
}
class Supplier extends Orm
{
}
class Customer extends Orm
{
}
class Address extends Orm
{
}
// static class to return OrmOrder objects
// keep factory in sync with classes
// Call directly never implement
class OrderFactory
{
static
function create($name, $data)
{
switch($name)
{
case 'supplier': return new Item($data);
case 'position': return new LineItem($data);
case 'address': return new Address($data);
case 'customer': return new Customer($data);
case 'order': return new Order($data);
default: return null;
}
}
}
?>
The model file (and main function). Run this from command prompt
/* assume php is properly setup */
> ordermodel
This file contains the top level model, the order model used to inspect the data. The toArray() returns a multidimensional array. The OrderModel class must be instantiated and passed the (html) multidimension array.
<?php /* ordermodel.php */
require_once('ormorder.php');
// sample database, development only, delete in production
$data['order'][0]['id'] = 0;
$data['order'][0]['note'] = 'test orders';
$data['order'][0]['date'] = '23 Mar 13';
$data['order'][0]['customer'][0]['id'] = 1;
$data['order'][0]['customer'][0]['account'] = '3000293826';
$data['order'][0]['customer'][0]['name'] = 'John Doe';
$data['order'][0]['customer'][0]['billing'][0] = 'Sand Castle';
$data['order'][0]['customer'][0]['billing'][1] = '1 beach street';
$data['order'][0]['customer'][0]['billing'][2] = 'strand';
$data['order'][0]['customer'][0]['billing'][3] = 'Lagoon';
$data['order'][0]['customer'][0]['billing'][4] = 'Fairy Island';
$data['order'][0]['customer'][0]['billing'][5] = '55511';
$data['order'][0]['customer'][0]['delivery'][0] = 'Nine Acres';
$data['order'][0]['customer'][0]['delivery'][1] = '3 corn field';
$data['order'][0]['customer'][0]['delivery'][2] = 'Butterworth';
$data['order'][0]['customer'][0]['delivery'][3] = 'Foam Vale';
$data['order'][0]['customer'][0]['delivery'][4] = 'Buttress Lake';
$data['order'][0]['customer'][0]['delivery'][5] = '224433';
$data['order'][0]['customer'][0]['items'][0]['supplier'] = '4000392292';
$data['order'][0]['customer'][0]['items'][0]['stock'] = '2000225571';
$data['order'][0]['customer'][0]['items'][0]['quantity'] = 5;
$data['order'][0]['customer'][0]['items'][0]['unitprice'] = 35.3;
$data['order'][0]['customer'][0]['items'][1]['supplier'] = '4000183563';
$data['order'][0]['customer'][0]['items'][1]['stock'] = '2000442279';
$data['order'][0]['customer'][0]['items'][1]['quantity'] = 12;
$data['order'][0]['customer'][0]['items'][1]['unitprice'] = 7.4;
// Top level Order management class
// could also be an OrmOrder class
class OrderModel
{
private $orders;
function __construct($data)
{
foreach($data['order'] as $order)
$this->orders[] = OrderFactory::create('order',$order);
}
function __call($name,$value)
{
$o = null;
$attribute = $value[0];
$val = $value[1];
foreach($this->orders as $order)
{
if($order->$attribute == $val)
{
$o = $order;
break;
}
}
return $o;
}
function toArray()
{
$data = null;
foreach($this->orders as $order)
$data['order'][] = $order->toArray();
return $data;
}
}
/* development only, delete in production */
function main($data)
{
$model = new OrderModel($data);
echo $model->order('id',12)->note;
var_dump($model->order('date',
'23 Mar 13')->customer('account','3000293826')->delivery->data);
// var_dump($model->toArray());
}
main($data);
?>
The output should be similar to:
PHP Notice: Trying to get property 'note' of non-object in C:\Users\Peter\Docum
ents\php\ordermodel.php on line 70
Notice: Trying to get property 'note' of non-object in C:\Users\Peter\Documents\
php\ordermodel.php on line 70
array(6) {
[0]=>
string(10) "Nine Acres"
[1]=>
string(12) "3 corn field"
[2]=>
string(11) "Butterworth"
[3]=>
string(9) "Foam Vale"
[4]=>
string(13) "Buttress Lake"
[5]=>
string(6) "224433"
}
Hopefully this does the kind of inspection you're looking for, probably not the same as Doctrine, but perhaps close enough to be useful.
To implement the answer in your code try this:
<?PHP
require_once('ordermodel.php');
/*..... */
private function jsonResponse()
{
header('Content-type: application/json');
echo json_encode(
array(
'success' => $this->data['success'],
'data' => new OrderModel($this->data['data'])
)
);
}
?>
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With