Skip to content

Commit fe03d42

Browse files
committed
Add initial middleware
1 parent 923a159 commit fe03d42

File tree

6 files changed

+422
-0
lines changed

6 files changed

+422
-0
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,5 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## [Unreleased]
8+
### Added
9+
- Allow Eloquent models to be populated from Json.

src/EloquentMiddleware.php

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace JsonMapper\EloquentMiddleware;
6+
7+
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
8+
use Illuminate\Database\Eloquent\Model;
9+
use JsonMapper\Builders\PropertyBuilder;
10+
use JsonMapper\Enums\Visibility;
11+
use JsonMapper\JsonMapperInterface;
12+
use JsonMapper\Middleware\AbstractMiddleware;
13+
use JsonMapper\ValueObjects\PropertyMap;
14+
use JsonMapper\Wrapper\ObjectWrapper;
15+
use Psr\SimpleCache\CacheInterface;
16+
17+
/**
18+
* Heavily based on the work of Barry vd. Heuvel in https://github.com/barryvdh/laravel-ide-helper
19+
*/
20+
class EloquentMiddleware extends AbstractMiddleware
21+
{
22+
private const DOC_BLOCK_REGEX = '/@(?P<name>[A-Za-z_-]+)(?:[ \t]+(?P<value>.*?))?[ \t]*\r?$/m';
23+
24+
/** @var CacheInterface */
25+
private $cache;
26+
/** @var string */
27+
private $dateClass;
28+
29+
public function __construct(CacheInterface $cache)
30+
{
31+
$this->cache = $cache;
32+
33+
$this->dateClass = class_exists(\Illuminate\Support\Facades\Date::class)
34+
? '\\' . get_class(\Illuminate\Support\Facades\Date::now())
35+
: '\Illuminate\Support\Carbon';
36+
}
37+
38+
public function handle(
39+
\stdClass $json,
40+
ObjectWrapper $object,
41+
PropertyMap $propertyMap,
42+
JsonMapperInterface $mapper
43+
): void {
44+
$inner = $object->getObject();
45+
if (! $inner instanceof Model) {
46+
return;
47+
}
48+
49+
if ($this->cache->has($object->getName())) {
50+
$propertyMap->merge($this->cache->get($object->getName()));
51+
return;
52+
}
53+
54+
$intermediatePropertyMap = $this->fetchPropertyMapForEloquentModel($inner);
55+
$this->cache->set($object->getName(), $intermediatePropertyMap);
56+
$propertyMap->merge($intermediatePropertyMap);
57+
}
58+
59+
private function fetchPropertyMapForEloquentModel(Model $object): PropertyMap
60+
{
61+
$intermediatePropertyMap = new PropertyMap();
62+
63+
$this->discoverPropertiesFromTable($object, $intermediatePropertyMap);
64+
65+
if (method_exists($object, 'getCasts')) {
66+
$this->discoverPropertiesCasts($object, $intermediatePropertyMap);
67+
}
68+
69+
return $intermediatePropertyMap;
70+
}
71+
72+
protected function discoverPropertiesFromTable(Model $model, PropertyMap $propertyMap): void
73+
{
74+
$table = $model->getConnection()->getTablePrefix() . $model->getTable();
75+
$schema = $model->getConnection()->getDoctrineSchemaManager();
76+
$databasePlatform = $schema->getDatabasePlatform();
77+
$databasePlatform->registerDoctrineTypeMapping('enum', 'string');
78+
79+
$database = null;
80+
if (strpos($table, '.')) {
81+
list($database, $table) = explode('.', $table);
82+
}
83+
84+
$columns = $schema->listTableColumns($table, $database);
85+
86+
if (count($columns) === 0) {
87+
return;
88+
}
89+
90+
foreach ($columns as $column) {
91+
$name = $column->getName();
92+
if (in_array($name, $model->getDates())) {
93+
$type = $this->dateClass;
94+
} else {
95+
$type = $column->getType()->getName();
96+
switch ($type) {
97+
case 'string':
98+
case 'text':
99+
case 'date':
100+
case 'time':
101+
case 'guid':
102+
case 'datetimetz':
103+
case 'datetime':
104+
case 'decimal':
105+
$type = 'string';
106+
break;
107+
case 'integer':
108+
case 'bigint':
109+
case 'smallint':
110+
$type = 'integer';
111+
break;
112+
case 'boolean':
113+
switch (config('database.default')) {
114+
case 'sqlite':
115+
case 'mysql':
116+
$type = 'integer';
117+
break;
118+
default:
119+
$type = 'boolean';
120+
break;
121+
}
122+
break;
123+
case 'float':
124+
$type = 'float';
125+
break;
126+
default:
127+
$type = 'mixed';
128+
break;
129+
}
130+
}
131+
132+
$property = PropertyBuilder::new()
133+
->setName($name)
134+
->setType($type)
135+
->setIsNullable(!$column->getNotnull())
136+
->setVisibility(Visibility::PUBLIC())
137+
->setIsArray(false)
138+
->build();
139+
$propertyMap->addProperty($property);
140+
}
141+
}
142+
143+
protected function discoverPropertiesCasts(Model $model, PropertyMap $propertyMap): void
144+
{
145+
$casts = $model->getCasts();
146+
foreach ($casts as $name => $type) {
147+
switch ($type) {
148+
case 'boolean':
149+
case 'bool':
150+
$realType = 'boolean';
151+
break;
152+
case 'string':
153+
$realType = 'string';
154+
break;
155+
case 'array':
156+
case 'json':
157+
$realType = 'array';
158+
break;
159+
case 'object':
160+
$realType = 'object';
161+
break;
162+
case 'int':
163+
case 'integer':
164+
case 'timestamp':
165+
$realType = 'integer';
166+
break;
167+
case 'real':
168+
case 'double':
169+
case 'float':
170+
$realType = 'float';
171+
break;
172+
case 'date':
173+
case 'datetime':
174+
$realType = $this->dateClass;
175+
break;
176+
case 'collection':
177+
$realType = '\Illuminate\Support\Collection';
178+
break;
179+
default:
180+
$realType = class_exists($type) ? ('\\' . $type) : 'mixed';
181+
break;
182+
}
183+
184+
if (! $propertyMap->hasProperty($name)) {
185+
continue;
186+
}
187+
188+
$realType = $this->checkForCustomLaravelCasts($realType);
189+
190+
$builder = $propertyMap->getProperty($name)->asBuilder();
191+
$property = $builder->setType($realType)->build();
192+
$propertyMap->addProperty($property);
193+
}
194+
}
195+
196+
protected function checkForCustomLaravelCasts(string $type): string
197+
{
198+
if (!class_exists($type) || !interface_exists(CastsAttributes::class)) {
199+
return $type;
200+
}
201+
202+
$reflection = new \ReflectionClass($type);
203+
204+
if (!$reflection->implementsInterface(CastsAttributes::class)) {
205+
return $type;
206+
}
207+
208+
$methodReflection = new \ReflectionMethod($type, 'get');
209+
$docComment = $methodReflection->getDocComment() ?: '';
210+
211+
return $this->getReturnTypeFromReflection($methodReflection)
212+
?: $this->getReturnTypeFromDocBlock($docComment)
213+
?: $type;
214+
}
215+
216+
protected function getReturnTypeFromReflection(\ReflectionMethod $reflection): ?string
217+
{
218+
$returnType = $reflection->getReturnType();
219+
if (!$returnType) {
220+
return null;
221+
}
222+
223+
$type = $returnType instanceof \ReflectionNamedType ? $returnType->getName() : (string)$returnType;
224+
225+
if (!$returnType->isBuiltin()) {
226+
$type = '\\' . $type;
227+
}
228+
229+
return $type;
230+
}
231+
232+
private function getReturnTypeFromDocBlock(string $docBlock): ?string
233+
{
234+
// Strip away the start "/**' and ending "*/"
235+
if (strpos($docBlock, '/**') === 0) {
236+
$docBlock = substr($docBlock, 3);
237+
}
238+
if (substr($docBlock, -2) === '*/') {
239+
$docBlock = substr($docBlock, 0, -2);
240+
}
241+
$docBlock = trim($docBlock);
242+
243+
$return = null;
244+
if (preg_match_all(self::DOC_BLOCK_REGEX, $docBlock, $matches)) {
245+
for ($x = 0, $max = count($matches[0]); $x < $max; $x++) {
246+
if ($matches['name'][$x] === 'return') {
247+
$return = $matches['value'][$x];
248+
}
249+
}
250+
}
251+
252+
return $return ?: null;
253+
}
254+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace JsonMapper\EloquentMiddleware\Tests\Implementation;
6+
7+
use Illuminate\Database\Eloquent\Model;
8+
9+
class EloquentModel extends Model
10+
{
11+
protected $connection = 'testbench';
12+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace JsonMapper\EloquentMiddleware\Tests\Integration;
6+
7+
use JsonMapper\Cache\NullCache;
8+
use JsonMapper\EloquentMiddleware\EloquentMiddleware;
9+
use JsonMapper\JsonMapperInterface;
10+
use JsonMapper\ValueObjects\PropertyMap;
11+
use JsonMapper\Wrapper\ObjectWrapper;
12+
use JsonMapper\EloquentMiddleware\Tests\Implementation\EloquentModel;
13+
use Orchestra\Testbench\TestCase;
14+
15+
class EloquentMiddlewareTest extends TestCase
16+
{
17+
protected function setUp(): void
18+
{
19+
parent::setUp();
20+
$x = __DIR__ . '/../database/migrations';
21+
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
22+
}
23+
24+
protected function getEnvironmentSetUp($app)
25+
{
26+
// Setup default database to use sqlite :memory:
27+
$app['config']->set('database.default', 'testbench');
28+
$app['config']->set('database.connections.testbench', [
29+
'driver' => 'sqlite',
30+
'database' => ':memory:',
31+
'prefix' => '',
32+
]);
33+
}
34+
35+
/**
36+
* @covers \JsonMapper\EloquentMiddleware\EloquentMiddleware
37+
*/
38+
public function testColumnsFromTheDatabaseAreReturned(): void
39+
{
40+
$middleware = new EloquentMiddleware(new NullCache());
41+
$propertyMap = new PropertyMap();
42+
$mapper = $this->createMock(JsonMapperInterface::class);
43+
44+
$middleware->handle(new \stdClass(), new ObjectWrapper(new EloquentModel()), $propertyMap, $mapper);
45+
46+
self::assertTrue($propertyMap->hasProperty('id'));
47+
}
48+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace JsonMapper\EloquentMiddleware\Tests\Integration;
6+
7+
use Doctrine\DBAL\Platforms\AbstractPlatform;
8+
use Doctrine\DBAL\Schema\AbstractSchemaManager;
9+
use Doctrine\DBAL\Schema\Column;
10+
use Doctrine\DBAL\Types\Type;
11+
use Doctrine\DBAL\Types\Types;
12+
use Illuminate\Database\Connection;
13+
use Illuminate\Database\Eloquent\Model;
14+
use JsonMapper\Cache\NullCache;
15+
use JsonMapper\EloquentMiddleware\EloquentMiddleware;
16+
use JsonMapper\JsonMapperInterface;
17+
use JsonMapper\ValueObjects\PropertyMap;
18+
use JsonMapper\Wrapper\ObjectWrapper;
19+
use Orchestra\Testbench\TestCase;
20+
21+
class EloquentMiddlewareTest extends TestCase
22+
{
23+
/**
24+
* @covers \JsonMapper\EloquentMiddleware\EloquentMiddleware
25+
*/
26+
public function testNonEloquentModelRetunsEmptyPropertyMap(): void
27+
{
28+
$middleware = new EloquentMiddleware(new NullCache());
29+
$propertyMap = new PropertyMap();
30+
$mapper = $this->createMock(JsonMapperInterface::class);
31+
32+
$middleware->handle(new \stdClass(), new ObjectWrapper(new \stdClass()), $propertyMap, $mapper);
33+
34+
self::assertEmpty($propertyMap->getIterator());
35+
}
36+
37+
/**
38+
* @covers \JsonMapper\EloquentMiddleware\EloquentMiddleware
39+
*/
40+
public function testColumnsFromTheDatabaseAreReturned(): void
41+
{
42+
$middleware = new EloquentMiddleware(new NullCache());
43+
$propertyMap = new PropertyMap();
44+
$mapper = $this->createMock(JsonMapperInterface::class);
45+
$model = $this->prepareMockedModel(new Column('id', Type::getType(Types::INTEGER)));
46+
47+
$middleware->handle(new \stdClass(), new ObjectWrapper($model), $propertyMap, $mapper);
48+
49+
self::assertTrue($propertyMap->hasProperty('id'));
50+
self::assertEquals('integer', $propertyMap->getProperty('id')->getType());
51+
}
52+
53+
private function prepareMockedModel(Column ...$columns): Model
54+
{
55+
$platform = $this->createMock(AbstractPlatform::class);
56+
57+
$schemaManager = $this->createMock(AbstractSchemaManager::class);
58+
$schemaManager->method('getDatabasePlatform')->willReturn($platform);
59+
$schemaManager->method('listTableColumns')->willReturn($columns);
60+
61+
$dbConnection = $this->createMock(Connection::class);
62+
$dbConnection->method('getDoctrineSchemaManager')->willReturn($schemaManager);
63+
64+
$model = $this->createMock(Model::class);
65+
$model->method('getConnection')->willReturn($dbConnection);
66+
$model->method('getDates')->willReturn([]);
67+
$model->method('getCasts')->willReturn([]);
68+
69+
return $model;
70+
}
71+
}

0 commit comments

Comments
 (0)