From e31092b4f34f15232715e22e9c1ccb0e2fb33d73 Mon Sep 17 00:00:00 2001 From: Michael Tils Date: Sun, 21 May 2017 15:09:45 +0200 Subject: [PATCH] Fixes #2 Implemented xtype system and eloquent drivers --- src/Ems/Contracts/XType/SelfExplanatory.php | 3 +- src/Ems/XType/Eloquent/ModelReflector.php | 208 ++++++++ src/Ems/XType/Eloquent/ModelTypeFactory.php | 300 +++++++++++ src/Ems/XType/Eloquent/RelationReflector.php | 166 ++++++ src/Ems/XType/Eloquent/XTypeTrait.php | 52 ++ .../XType/Illuminate/XTypeServiceProvider.php | 17 + src/Ems/XType/Skeleton/XTypeBootstrapper.php | 80 +++ src/Ems/XType/TypeFactory.php | 46 +- .../XType/Eloquent/ModelTypeFactoryTest.php | 497 ++++++++++++++++++ .../Eloquent/TypeProviderWithEloquentTest.php | 46 ++ .../TypeProviderWithoutEloquentTest.php | 26 + .../XType/TypeProviderIntegrationTest.php | 46 ++ tests/unit/XType/TypeFactoryTest.php | 4 +- 13 files changed, 1461 insertions(+), 30 deletions(-) create mode 100644 src/Ems/XType/Eloquent/ModelReflector.php create mode 100644 src/Ems/XType/Eloquent/ModelTypeFactory.php create mode 100644 src/Ems/XType/Eloquent/RelationReflector.php create mode 100644 src/Ems/XType/Eloquent/XTypeTrait.php create mode 100644 src/Ems/XType/Illuminate/XTypeServiceProvider.php create mode 100644 src/Ems/XType/Skeleton/XTypeBootstrapper.php create mode 100644 tests/integration/XType/Eloquent/ModelTypeFactoryTest.php create mode 100644 tests/integration/XType/Eloquent/TypeProviderWithEloquentTest.php create mode 100644 tests/integration/XType/Eloquent/TypeProviderWithoutEloquentTest.php create mode 100644 tests/integration/XType/TypeProviderIntegrationTest.php diff --git a/src/Ems/Contracts/XType/SelfExplanatory.php b/src/Ems/Contracts/XType/SelfExplanatory.php index 54929657..c27d56d9 100644 --- a/src/Ems/Contracts/XType/SelfExplanatory.php +++ b/src/Ems/Contracts/XType/SelfExplanatory.php @@ -1,6 +1,5 @@ ['int', 'integer'], + 'number|nativeType:float' => ['real', 'float', 'double'], + 'bool' => ['bool', 'boolean'], + 'object|class:stdClass' => ['object'], + 'array-access' => ['array', 'json'], + 'object|class:Illuminate\Support\Collection' => ['collection'], + 'temporal' => ['date', 'datetime'] + ]; + + /** + * Guess the available keys by standard eloquent mechanisms like casts, + * timestamps, fillable,... + * + * @param Model $model + * + * @return array + **/ + public function keys(Model $model) + { + $pk = $model->getKeyName(); + + $pool = [ + [$pk], + $model->getVisible(), + $model->getHidden(), + $model->getFillable(), + $model->getGuarded(), + $model->getDates(), + array_keys($this->getCasts($model)) + ]; + + // SoftDeletingTrait + if (method_exists($model, 'getDeletedAtColumn')) { + $pool[] = [$model->getDeletedAtColumn()]; + } + + $keysByKey = []; + + foreach ($pool as $i=>$keys) { + foreach ($keys as $key) { + if ($keys == ['*'] || $keys == '*') { + continue; + } + + $keysByKey[$key] = true; + } + } + + return array_keys($keysByKey); + } + + /** + * Calculate a type rule string for $key in $model + * + * @param Model $model + * @param string $key + * + * @return string + **/ + public function typeString(Model $model, $key) + { + $config = $this->baseTypeString($model, $key); + + if ($this->isAutoGenerated($model, $key)) { + $config .= '|readonly'; + } + + return $config; + } + + /** + * Return the basic type rule string for $key in $model + * + * @param Model $model + * @param string $key + * + * @return string + **/ + protected function baseTypeString(Model $model, $key) + { + if ($config = $this->getFromCasts($model, $key)) { + return $config; + } + + if ($this->isCastedToDateTime($model, $key)) { + return 'temporal'; + } + + if ($key == $model->getKeyName()) { + return 'number|nativeType:int'; + } + + return 'string'; + } + + /** + * Check if a $key in $model is casted to datetime (timestamps, casts,...) + * + * @param Model $model + * @param string $key + * + * @return bool + **/ + protected function isCastedToDateTime(Model $model, $key) + { + if (in_array($key, $model->getDates())) { + return true; + } + + if (method_exists($model, 'getDeletedAtColumn')) { + return $key == $model->getDeletedAtColumn(); + } + + return false; + } + + /** + * Get the typename from the casts array + * + * @param Model $model + * @param string $key + * + * @return string The type name + **/ + protected function getFromCasts(Model $model, $key) + { + $casts = $this->getCasts($model); + + if (!isset($casts[$key])) { + return; + } + + $cast = $casts[$key]; + + foreach ($this->xTypeToCasts as $xType=>$casts) { + if (in_array($cast, $casts)) { + return $xType; + } + } + + return 'string'; + } + + /** + * Return the model casts. (By cheat, I did found another method) + * + * @param Model $model + * + * @return array + **/ + protected function getCasts(Model $model) + { + $class = get_class($model); + + if (!isset($this->castsCache[$class])) { + $this->castsCache[$class] = Cheat::get($model, 'casts'); + } + + return $this->castsCache[$class]; + } + + /** + * Return if a key is auto generated (like timestamps, autoincrements,...) + * + * @param Model $model + * @param string $key + * + * @return bool + **/ + protected function isAutoGenerated(Model $model, $key) + { + if ($key == $model->getKeyName() && $model->getIncrementing()) { + return true; + } + + $constants = (new ReflectionClass($model))->getConstants(); + + if (in_array($key, [$constants['CREATED_AT'], $constants['UPDATED_AT']]) && $model->usesTimestamps()) { + return true; + } + + if (!method_exists($model, 'getDeletedAtColumn')) { + return false; + } + + return $key == $model->getDeletedAtColumn(); + } +} diff --git a/src/Ems/XType/Eloquent/ModelTypeFactory.php b/src/Ems/XType/Eloquent/ModelTypeFactory.php new file mode 100644 index 00000000..432f70b2 --- /dev/null +++ b/src/Ems/XType/Eloquent/ModelTypeFactory.php @@ -0,0 +1,300 @@ +typeFactory = $typeFactory; + $this->reflector = $reflector; + $this->relationReflector = $relationReflector; + } + + /** + * @param Model|string $model + * + * @return ObjectType + */ + public function toType($model) + { + list($instance, $class) = $this->instanceAndClass($model); + + if ($type = $this->getFromCache($class)) { + return $type; + } + + $config = $this->buildConfig($instance, $class); + + $objectType = $this->typeFactory->toType("object|class:$class"); + + $objectType->provideKeysBy(function () use (&$config) { + return $this->typeFactory->toType($config); + }); + + return $this->putIntoCacheAndReturn($objectType); + } + + /** + * Merge the autodetected rules with (optional) manually setted, translate + * the relations and return the complete result. + * + * @param Model $instance + * @param string $class + * + * @return array + **/ + protected function buildConfig(Model $instance, $class) + { + $autoRules = $this->getConfigFromReflector($instance); + $manualRules = $this->getManualConfig($instance); + + $keys = $manualRules ? array_keys($manualRules) : array_keys($autoRules); + + $config = []; + + foreach ($keys as $key) { + + // Give manually setted rules priority + if (!isset($manualRules[$key]) && isset($autoRules[$key])) { + $config[$key] = $autoRules[$key]; + continue; + } + + $config[$key] = $manualRules[$key]; + + if ($this->isSequenceTypeRule($config[$key])) { + $keyType = $this->createSequenceType($config[$key]); + $config[$key] = $keyType; + continue; + } + + if ($this->isObjectTypeRule($config[$key])) { + $keyType = $this->createObjectType($config[$key]); + $config[$key] = $this->putIntoCacheAndReturn($keyType); + continue; + } + } + + // Assign not manually setted rules if they dont exist + foreach ($autoRules as $key=>$rule) { + + // Give manually setted rules priority + if (!isset($config[$key])) { + $config[$key] = $autoRules[$key]; + } + } + + return $config; + } + + /** + * (Bogus) determine if the rule is a rule for an object type + * + * @param string $rule + * + * @return bool + **/ + protected function isObjectTypeRule($rule) + { + return strpos($rule, 'object|') === 0; + } + + /** + * (Bogus) determine if the rule is a rule for an sequence type + * + * @param string $rule + * + * @return bool + **/ + protected function isSequenceTypeRule($rule) + { + return strpos($rule, 'sequence|') === 0; + } + + /** + * Create a deferred object type for $config. Object types need to be + * created to provide the keys later by this object. + * + * @param string $config + * + * @return ObjectType + **/ + protected function createObjectType($config) + { + $keyType = $this->typeFactory->toType($config); + + $keyType->provideKeysBy(function () use ($keyType) { + + $cls = $keyType->class; + $instance = new $cls(); + $config = $this->buildConfig($instance, $cls); + + return $this->typeFactory->toType($config); + }); + + return $keyType; + } + + /** + * Create sequence type with a deffered itemType for $config. + * + * @param string $config + * + * @return SequenceType + **/ + protected function createSequenceType($config) + { + $keyType = $this->typeFactory->toType($config); + + if (!$keyType->itemType) { + throw new MisConfiguredException('ModelFactory can only care about SequenceTypes with an itemType'); + } + + $itemType = $keyType->itemType; + + if (!$itemType instanceof ObjectType) { + return $keyType; + } + + $itemType->provideKeysBy(function () use ($itemType) { + + $cls = $itemType->class; + $instance = new $cls(); + $config = $this->buildConfig($instance, $cls); + + return $this->typeFactory->toType($config); + }); + + return $keyType; + } + + /** + * Return the manually setted config of Model or an empty array + * + * @param Model $model + * + * @return array + **/ + protected function getManualConfig(Model $model) + { + if (!method_exists($model, 'xTypeConfig')) { + return []; + } + + $config = $model->xTypeConfig(); + $parsed = []; + $foreignKeys = []; + + foreach ($config as $key=>$rule) { + if ($rule != 'relation') { + $parsed[$key] = $rule; + continue; + } + + $relation = $this->relationReflector->buildRelationXTypeInfo($model, $key); + + $parsed[$key] = $relation['type']; + + if ($relation['foreign_keys']) { + $foreignKeys += $relation['foreign_keys']; + } + } + + return $parsed; + } + + /** + * Load the automatically detected rules + * + * @param Model $model + * + * @return array + **/ + protected function getConfigFromReflector(Model $model) + { + $config = []; + foreach ($this->reflector->keys($model) as $key) { + $config[$key] = $this->reflector->typeString($model, $key); + } + return $config; + } + + /** + * Return an instance of a model and a classname + * + * @param mixed $model + * + * @return array + **/ + protected function instanceAndClass($model) + { + + // Checks without instantiating first + if (!is_subclass_of($model, Model::class)) { + throw new UnsupportedParameterException('ModelTypeFactory only supports Eloquent models not '.Helper::typeName($model) ); + } + + return is_object($model) ? [$model, get_class($model)] : [new $model(), $model]; + } + + /** + * @param string $class + * + * @return ObjectType|null + **/ + protected function getFromCache($class) + { + return isset($this->typeCache[$class]) ? $this->typeCache[$class] : null; + } + + /** + * @param string $class + * @param ObjectType $type + * + * @return ObjectType + **/ + protected function putIntoCacheAndReturn(ObjectType $type) + { + $this->typeCache[$type->class] = $type; + return $type; + } +} diff --git a/src/Ems/XType/Eloquent/RelationReflector.php b/src/Ems/XType/Eloquent/RelationReflector.php new file mode 100644 index 00000000..808e79ec --- /dev/null +++ b/src/Ems/XType/Eloquent/RelationReflector.php @@ -0,0 +1,166 @@ +$key(); + + switch (true) { + + case $relation instanceof BelongsTo: + return $this->buildBelongsToInfo($key, $relation); + + case $relation instanceof BelongsToMany: + return $this->buildBelongsToManyInfo($key, $relation); + + case $relation instanceof HasMany: + return $this->buildHasManyInfo($key, $relation); + + case $relation instanceof HasOne: + return $this->buildHasOneInfo($key, $relation); + + case $relation instanceof HasManyThrough: + return $this->buildHasManyThroughInfo($key, $relation); + + case $relation instanceof MorphOne: + return $this->buildMorphOneInfo($key, $relation); + + case $relation instanceof MorphMany: + return $this->buildMorphManyInfo($key, $relation); + + default: + $modelClass = get_class($model); + $relationType = Helper::typeName($relation); + + throw new UnsupportedParameterException("Result of $modelClass->$key() did not return a Relation (it returned $relationType)"); + } + } + + /** + * @param string $key + * @param BelongsTo $relation + * + * @return array + **/ + protected function buildBelongsToInfo($key, BelongsTo $relation) + { + $other = $relation->getRelated(); + return [ + 'type' => 'object|class:'.get_class($other), + 'foreign_keys' => [$relation->getForeignKey()] + ]; + } + + /** + * @param string $key + * @param HasOne $relation + * + * @return array + **/ + protected function buildHasOneInfo($key, HasOne $relation) + { + $other = $relation->getRelated(); + return [ + 'type' => 'object|class:'.get_class($other), + 'foreign_keys' => [] + ]; + } + + /** + * @param string $key + * @param HasMany $relation + * + * @return array + **/ + protected function buildHasManyInfo($key, HasMany $relation) + { + $other = $relation->getRelated(); + return [ + 'type' => 'sequence|itemType:[object|class:'.get_class($other).']', + 'foreign_keys' => [] + ]; + } + + /** + * @param string $key + * @param BelongsToMany $relation + * + * @return array + **/ + protected function buildBelongsToManyInfo($key, BelongsToMany $relation) + { + $other = $relation->getRelated(); + return [ + 'type' => 'sequence|itemType:[object|class:'.get_class($other).']', + 'foreign_keys' => [$relation->getForeignKey()] + ]; + } + + /** + * @param string $key + * @param HasManyThrough $relation + * + * @return array + **/ + protected function buildHasManyThroughInfo($key, HasManyThrough $relation) + { + $other = $relation->getRelated(); + return [ + 'type' => 'sequence|itemType:[object|class:'.get_class($other).']', + 'foreign_keys' => [] + ]; + } + + /** + * @param string $key + * @param MorphOne $relation + * + * @return array + **/ + protected function buildMorphOneInfo($key, MorphOne $relation) + { + $other = $relation->getRelated(); + return [ + 'type' => 'object|class:'.get_class($other), + 'foreign_keys' => [] + ]; + } + + /** + * @param string $key + * @param MorphMany $relation + * + * @return array + **/ + protected function buildMorphManyInfo($key, MorphMany $relation) + { + $other = $relation->getRelated(); + return [ + 'type' => 'sequence|itemType:[object|class:'.get_class($other).']', + 'foreign_keys' => [] + ]; + } +} diff --git a/src/Ems/XType/Eloquent/XTypeTrait.php b/src/Ems/XType/Eloquent/XTypeTrait.php new file mode 100644 index 00000000..e6997be5 --- /dev/null +++ b/src/Ems/XType/Eloquent/XTypeTrait.php @@ -0,0 +1,52 @@ +_xTypeConfigCache)) { + $config = isset($this->xType) ? $this->xType : []; + $this->_xTypeConfigCache = $this->bootXTypeConfig($config); + } + + return $this->_xTypeConfigCache; + } + + /** + * Boot the config if needed + * + * @param array $config + * + * @return array + **/ + protected function bootXTypeConfig(array $config) + { + return $config; + } +} diff --git a/src/Ems/XType/Illuminate/XTypeServiceProvider.php b/src/Ems/XType/Illuminate/XTypeServiceProvider.php new file mode 100644 index 00000000..583ea3c3 --- /dev/null +++ b/src/Ems/XType/Illuminate/XTypeServiceProvider.php @@ -0,0 +1,17 @@ + TypeFactoryContract::class, + TypeProvider::class => TypeProviderContract::class + ]; + + public function bind() + { + parent::bind(); + + $this->app->resolving(TypeProvider::class, function ($provider) { + $this->addEloquentExtensionsIfInstalled($provider); + }); + } + + /** + * This method is only for testing purposes + * + * @param bool $isInstalled + **/ + public static function setEloquentInstalled($isInstalled) + { + static::$eloquentExists = $isInstalled; + } + + /** + * @param TypeProvider $provider + **/ + protected function addEloquentExtensionsIfInstalled(TypeProvider $provider) + { + if (!$this->isEloquentInstalled()) { + return; + } + + // Make ModelTypeFactory a singleton + $this->app->bind(ModelTypeFactory::class, function ($app) { + return new ModelTypeFactory( + $app(TypeFactoryContract::class), + $app(ModelReflector::class), + $app(RelationReflector::class) + ); + }); + + $provider->extend(Model::class, function ($model) { + return $this->app->make(ModelTypeFactory::class)->toType($model); + }); + } + + /** + * @return bool + **/ + protected function isEloquentInstalled() + { + if (static::$eloquentExists === null) { + static::$eloquentExists = class_exists(Model::class); + } + return static::$eloquentExists; + } +} diff --git a/src/Ems/XType/TypeFactory.php b/src/Ems/XType/TypeFactory.php index f25ffd3c..d9c7e30a 100644 --- a/src/Ems/XType/TypeFactory.php +++ b/src/Ems/XType/TypeFactory.php @@ -1,6 +1,5 @@ canCreate($config)) { - throw new InvalidArgumentException('Cannot create an xtype out of parameter. Please check with canCreate first.'); + throw new InvalidArgumentException('Cannot create an xtype out of parameter. Please check with canCreate first. Received '.Helper::typeName($config)); } if (is_string($config)) { @@ -60,10 +57,10 @@ public function toType($config) } if (!$config instanceof SelfExplanatory) { - return $this->fillType(new ArrayAccessType, $config); + return $this->fillType(new ArrayAccessType(), $config); } - $typeInfo = $config->myXType(); + $typeInfo = $config->xTypeConfig(); if ($typeInfo instanceof ObjectType) { return $typeInfo; @@ -73,7 +70,6 @@ public function toType($config) $type->class = get_class($config); return $this->fillType($type, $typeInfo); - } /** @@ -106,6 +102,11 @@ protected function parseConfig($config) $parsed = []; foreach ($config as $key=>$value) { + if ($value instanceof XType) { + $parsed[$key] = $value; + continue; + } + $parsed[$key] = $this->stringToType($value); } @@ -121,7 +122,6 @@ protected function parseConfig($config) **/ protected function stringToType($config) { - list($typeName, $properties) = $this->splitTypeAndProperties($config); $type = $this->createType($typeName); @@ -129,12 +129,15 @@ protected function stringToType($config) $type->fill($this->parseProperties($properties)); } - if ($type instanceof ObjectType) { + if (!$type instanceof ObjectType) { + return $type; + } + + if (!$type->hasKeyProvider()) { $type->provideKeysBy($this->buildKeyProvider($type)); } return $type; - } /** @@ -149,9 +152,9 @@ protected function buildKeyProvider(ObjectType $type) { $keyClass = $type->class; - return function() use ($keyClass, &$type) { - $root = new $keyClass; - $config = $root->myXType(); + return function () use ($keyClass, &$type) { + $root = new $keyClass(); + $config = $root->xTypeConfig(); return $this->parseConfig($config); }; @@ -166,7 +169,6 @@ protected function buildKeyProvider(ObjectType $type) **/ protected function createType($typeName) { - if (isset($this->typeCache[$typeName])) { return clone $this->typeCache[$typeName]; } @@ -174,7 +176,7 @@ protected function createType($typeName) if (!$this->hasExtension($typeName)) { $class = $this->typeToClassName($typeName); - $this->typeCache[$typeName] = new $class; + $this->typeCache[$typeName] = new $class(); return clone $this->typeCache[$typeName]; } @@ -192,11 +194,9 @@ protected function createType($typeName) **/ protected function parseProperties(array $properties) { - $parsed = []; foreach ($properties as $propertyString) { - if (!mb_strpos($propertyString, ':')) { list($key, $value) = $this->parseBooleanShortcut($propertyString); $parsed[$key] = $value; @@ -215,7 +215,6 @@ protected function parseProperties(array $properties) $type = $this->stringToType(trim($value, '[]')); $parsed[$key] = $type; - } return $parsed; @@ -248,7 +247,6 @@ protected function splitTypeAndProperties($rule) $currentPart = -1; foreach ($chars as $char) { - if ($char == '[') { $level++; } @@ -274,7 +272,6 @@ protected function splitTypeAndProperties($rule) } $parts[$currentPart] .= $char; - } return [$typeName, $parts]; @@ -321,23 +318,21 @@ protected function parseBooleanShortcut($propertyString) **/ protected function typeToClassName($typeName) { + $classBase = Helper::studlyCaps($typeName).'Type'; - $classBase = Helper::studlyCaps($typeName) . 'Type'; - - $class = __NAMESPACE__ . "\\$classBase"; + $class = __NAMESPACE__."\\$classBase"; if (class_exists($class)) { return $class; } - $class = __NAMESPACE__ . "\UnitTypes\\$classBase"; + $class = __NAMESPACE__."\UnitTypes\\$classBase"; if (class_exists($class)) { return $class; } throw new ResourceNotFoundException("XType class for $typeName not found"); - } /** @@ -359,5 +354,4 @@ protected function isArrayWithNonNumericKeys($value) return true; } - } diff --git a/tests/integration/XType/Eloquent/ModelTypeFactoryTest.php b/tests/integration/XType/Eloquent/ModelTypeFactoryTest.php new file mode 100644 index 00000000..8516b8e1 --- /dev/null +++ b/tests/integration/XType/Eloquent/ModelTypeFactoryTest.php @@ -0,0 +1,497 @@ +assertInstanceOf(EloquentModel::class, new PlainUser); + } + + public function test_myXtype_returns_basic_properties() + { + $type = $this->xType(new PlainUser); + $this->assertInstanceOf(NumberType::class, $type['id']); + $this->assertTrue($type['id']->readonly); + + foreach (['created_at','updated_at'] as $key) { + $this->assertInstanceOf(TemporalType::class, $type[$key]); + $this->assertTrue($type[$key]->readonly); + } + + $type = $this->xType(new PlainUser); + $this->assertInstanceOf(NumberType::class, $type['id']); + $this->assertTrue($type['id']->readonly); + + } + + public function test_myXtype_returns_standard_properties() + { + $type = $this->xType(new StandardUser); + + $this->assertInstanceOf(NumberType::class, $type['id']); + $this->assertTrue($type['id']->readonly); + + foreach (['login','email','password','first_name','last_name','mother'] as $key) { + $this->assertInstanceOf(StringType::class, $type[$key]); + } + + foreach (['created_at','updated_at','deleted_at','activated_at', 'last_login'] as $key) { + $this->assertInstanceOf(TemporalType::class, $type[$key]); + if (!in_array($key, ['activated_at', 'last_login'])) { + $this->assertTrue($type[$key]->readonly); + } + } + + $this->assertInstanceOf(NumberType::class, $type['category_id']); + $this->assertEquals('int', $type['category_id']->nativeType); + + $this->assertInstanceOf(NumberType::class, $type['weight']); + $this->assertEquals('float', $type['weight']->nativeType); + + $this->assertInstanceOf(BoolType::class, $type['is_banned']); + + $this->assertInstanceOf(ArrayAccessType::class, $type['permissions']); + $this->assertInstanceOf(ObjectType::class, $type['acl']); + $this->assertInstanceOf(ObjectType::class, $type['paw_patrol']); + $this->assertEquals('Illuminate\Support\Collection', $type['paw_patrol']->class); + + } + + public function test_myXtype_returns_manual_properties() + { + $type = $this->xType(new User); + + $this->assertInstanceOf(NumberType::class, $type['id']); + $this->assertTrue($type['id']->readonly); + + foreach (['created_at','updated_at'] as $key) { + $this->assertInstanceOf(TemporalType::class, $type[$key]); + $this->assertTrue($type[$key]->readonly); + } + + $this->assertInstanceOf(StringType::class, $type['login']); + $this->assertEquals(5, $type['login']->min); + $this->assertEquals(128, $type['login']->max); + + $this->assertInstanceOf(StringType::class, $type['email']); + $this->assertEquals(10, $type['email']->min); + $this->assertEquals(255, $type['email']->max); + + + } + + public function test_myXType_overwrites_auto_detected_types_with_manually_setted() + { + + $type = $this->xType(new User); + + $this->assertInstanceOf(StringType::class, $type['password']); + $this->assertEquals(6, $type['password']->min); + $this->assertEquals(255, $type['password']->max); + $this->assertFalse($type['password']->canBeNull); + + } + + public function test_myXtype_parses_belongsTo_relation() + { + $type = $this->xType(new User); + + $categoryType = $type['category']; + + $this->assertInstanceOf(ObjectType::class, $categoryType); + $this->assertEquals(Category::class, $categoryType->class); + $this->assertInstanceOf(StringType::class, $categoryType['name']); + $this->assertEquals(10, $categoryType['name']->min); + + + } + + public function test_myXtype_parses_hasOne_relation() + { + $type = $this->xType(new User); + + $addressType = $type['address']; + + $this->assertInstanceOf(ObjectType::class, $addressType); + $this->assertEquals(Address::class, $addressType->class); + $this->assertInstanceOf(StringType::class, $addressType['street']); + $this->assertEquals(10, $addressType['street']->min); + $this->assertEquals(255, $addressType['street']->max); + + + } + + public function test_myXtype_parses_hasMany_relation() + { + $type = $this->xType(new User); + + $ordersType = $type['orders']; + + $this->assertInstanceOf(SequenceType::class, $ordersType); + + $orderType = $ordersType->itemType; + $this->assertEquals(Order::class, $orderType->class); + $this->assertInstanceOf(StringType::class, $orderType['name']); + $this->assertEquals(2, $orderType['name']->min); + $this->assertEquals(255, $orderType['name']->max); + + + } + + public function test_myXtype_parses_belongsToMany_relation() + { + $type = $this->xType(new User); + + $tagsType = $type['tags']; + + $this->assertInstanceOf(SequenceType::class, $tagsType); + + $tagType = $type['tags']->itemType; + + $this->assertEquals(Tag::class, $tagType->class); + $this->assertInstanceOf(StringType::class, $tagType['name']); + $this->assertEquals(2, $tagType['name']->min); + $this->assertEquals(255, $tagType['name']->max); + + + } + + public function test_myXtype_parses_hasManyThrough_relation() + { + $type = $this->xType(new Country); + + $residentsType = $type['residents']; + + $this->assertInstanceOf(SequenceType::class, $residentsType); + + $residentType = $residentsType->itemType; + + $this->assertEquals(User::class, $residentType->class); + $this->assertInstanceOf(StringType::class, $residentType['login']); + $this->assertEquals(5, $residentType['login']->min); + $this->assertEquals(128, $residentType['login']->max); + + + } + + public function test_myXtype_parses_morphOne_relation() + { + $type = $this->xType(new User); + + $noteType = $type['note']; + + $this->assertEquals(Note::class, $noteType->class); + $this->assertInstanceOf(StringType::class, $noteType['note']); + $this->assertEquals(5, $noteType['note']->min); + $this->assertEquals(64000, $noteType['note']->max); + + + } + + public function test_myXtype_parses_morphMany_relation() + { + $type = $this->xType(new Order); + + $commentsType = $type['comments']; + + $this->assertInstanceOf(SequenceType::class, $commentsType); + + $commentType = $commentsType->itemType; + + $this->assertEquals(Comment::class, $commentType->class); + $this->assertInstanceOf(StringType::class, $commentType['comment']); + $this->assertEquals(5, $commentType['comment']->min); + $this->assertEquals(255, $commentType['comment']->max); + + + } + + /** + * @expectedException \Ems\Contracts\Core\Errors\ConfigurationError + **/ + public function test_myXtype_throws_exception_ConfigurationError_if_itemType_not_configured() + { + $numbersType = $this->xType(new AdditionalPhoneNumbers)['phone_numbers']; + + $itemType = $emailsType->itemType; + + $this->assertInstanceOf(SequenceType::class, $emailsType); + + $this->assertInstanceOf(StringType::class, $itemType); + $this->assertEquals(10, $itemType->min); + $this->assertEquals(128, $itemType->max); + + + } + + public function test_myXtype_parses_sequenceType_with_string_items() + { + $emailsType = $this->xType(new AdditionalEmails)['emails']; + + $itemType = $emailsType->itemType; + + $this->assertInstanceOf(SequenceType::class, $emailsType); + + $this->assertInstanceOf(StringType::class, $itemType); + $this->assertEquals(10, $itemType->min); + $this->assertEquals(128, $itemType->max); + + + } + + /** + * @expectedException \Ems\Contracts\Core\Errors\Unsupported + **/ + public function test_toType_throws_Unsupported_if_class_not_an_eloquent_model() + { + $this->xType(new \stdClass); + } + + /** + * @expectedException \Ems\Contracts\Core\Errors\Unsupported + **/ + public function test_toType_throws_Unsupported_if_relation_is_no_eloquent_relation() + { + $this->xType(new WrongRelationType); + } + + protected function xType($model, $path=null) + { + return $this->provider()->toType($model); + } + + protected function newUser() + { + return new PlainUser; + } + + protected function factory() + { + if (!$this->typeFactory) { + $this->typeFactory = new TypeFactory; + } + return $this->typeFactory; + } + + protected function provider() + { + if (!$this->typeProvider) { + $this->typeProvider = new ModelTypeFactory( + $this->factory(), + new ModelReflector, + new RelationReflector + ); + } + return $this->typeProvider; + } +} + +class PlainUser extends EloquentModel +{ +// use XTypeTrait; + + public $timestamps = true; +} + +class StandardUser extends EloquentModel +{ + use SoftDeletes; + use XTypeTrait; + + public $timestamps = true; + + protected $visible = ['login', 'email']; + + protected $hidden = ['password']; + + protected $fillable = ['login','email', 'first_name', 'last_name']; + + protected $guarded = ['category_id']; + + protected $dates = ['activated_at']; + + protected $casts = [ + 'category_id' => 'int', + 'is_banned' => 'bool', + 'weight' => 'float', + 'permissions' => 'array', + 'acl' => 'object', + 'last_login' => 'datetime', + 'paw_patrol' => 'collection', + 'mother' => 'string' + ]; +} + +abstract class BaseModel extends EloquentModel +{ + use XTypeTrait; +} + +class AdditionalEmails extends BaseModel +{ + protected $xType = [ + 'emails' => 'sequence|itemType:[string|min:10|max:128]' + ]; +} + +class AdditionalPhoneNumbers extends BaseModel +{ + protected $xType = [ + 'phone_numbers' => 'sequence|max:10' + ]; +} + +class User extends BaseModel +{ + protected $xType = [ + 'login' => 'string|min:5|max:128', + 'email' => 'string|min:10|max:255', + 'password' => 'string|min:6|max:255|required', + 'category' => 'relation', + 'address' => 'relation', + 'orders' => 'relation', + 'tags' => 'relation', + 'note' => 'relation' + ]; + + protected $hidden = ['password']; + + public function category() + { + return $this->belongsTo(Category::class); + } + + public function address() + { + return $this->hasOne(Address::class); + } + + public function orders() + { + return $this->hasMany(Order::class); + } + + public function tags() + { + return $this->belongsToMany(Tag::class); + } + + public function note() + { + return $this->morphOne(Note::class, 'foreign'); + } + +} + +class Category extends BaseModel +{ + protected $xType = [ + 'external_id' => 'string|min:5|max:255', + 'name' => 'string|min:10|max:255' + ]; +} + +class Address extends BaseModel +{ + protected $xType = [ + 'street' => 'string|min:10|max:255', + 'country' => 'relation' + ]; + + public function country() + { + return $this->belongsTo(Country::class); + } + + public function languages() + { + return $this->belongsTo(Country::class); + } +} + +class Country extends BaseModel +{ + protected $xType = [ + 'name' => 'string|min:2|max:255', + 'iso_code' => 'string|min:2|max:2', + 'residents' => 'relation' + ]; + + public function residents() + { + return $this->hasManyThrough(User::class, Address::class); + } +} + +class Tag extends BaseModel +{ + protected $xType = [ + 'name' => 'string|min:2|max:255' + ]; +} + +class Order extends BaseModel +{ + protected $xType = [ + 'name' => 'string|min:2|max:255', + 'comments'=> 'relation' + ]; + + public function comments() + { + return $this->morphMany(Comment::class, 'foreign'); + } +} + +class Comment extends BaseModel +{ + protected $xType = [ + 'comment' => 'string|min:5|max:255', + 'foreign_id' => 'string|min:5|max:64', + 'foreign_type' => 'string|min:5|max:64' + ]; +} + +class Note extends BaseModel +{ + protected $xType = [ + 'note' => 'string|min:5|max:64000', + 'foreign_id' => 'number|min:1|max:64000', + 'foreign_type' => 'string|min:5|max:64' + ]; +} + +class WrongRelationType extends BaseModel +{ + protected $xType = [ + 'note' => 'relation' + ]; + + public function note() + { + return 3; + } +} diff --git a/tests/integration/XType/Eloquent/TypeProviderWithEloquentTest.php b/tests/integration/XType/Eloquent/TypeProviderWithEloquentTest.php new file mode 100644 index 00000000..52fd4da3 --- /dev/null +++ b/tests/integration/XType/Eloquent/TypeProviderWithEloquentTest.php @@ -0,0 +1,46 @@ +xType(PlainUser::class, 'updated_at'); + $this->assertInstanceOf(TemporalType::class, $type); + $this->assertTrue($type->readonly); + } + + public function test_dotted_key_access() + { + $type = $this->xType(User::class, 'orders.comments.comment'); + $this->assertInstanceOf(StringType::class, $type); + $this->assertEquals(5, $type->min); + $this->assertEquals(255, $type->max); + + } + + protected function xType($model, $path=null) + { + return $this->app(TypeProviderContract::class)->xType($model, $path); + } + +} diff --git a/tests/integration/XType/Eloquent/TypeProviderWithoutEloquentTest.php b/tests/integration/XType/Eloquent/TypeProviderWithoutEloquentTest.php new file mode 100644 index 00000000..108b8085 --- /dev/null +++ b/tests/integration/XType/Eloquent/TypeProviderWithoutEloquentTest.php @@ -0,0 +1,26 @@ +assertFalse($this->app()->bound(ModelTypeFactory::class)); + + $type = $this->app(TypeProviderContract::class)->xType(15); + $this->assertInstanceOf(NumberType::class, $type); + } + +} diff --git a/tests/integration/XType/TypeProviderIntegrationTest.php b/tests/integration/XType/TypeProviderIntegrationTest.php new file mode 100644 index 00000000..98845850 --- /dev/null +++ b/tests/integration/XType/TypeProviderIntegrationTest.php @@ -0,0 +1,46 @@ +provider()->toType($model); + } + + protected function newProvider(ExtractorContract $extractor=null, TemplateTypeFactory $templateFactory=null, TypeFactory $typeFactory=null) + { + if (!$extractor && !$templateFactory) { + return $this->app(TypeProviderContract::class); + } + return parent::newProvider($extractor, $templateFactory); + } + + protected function newExtractor() + { + return $this->app(ExtractorContract::class); + } + + protected function newTemplateFactory() + { + return $this->app(TemplateTypeFactory::class); + } + + protected function newTypeFactory() + { + return $this->app(TypeFactoryContract::class); + } + +} diff --git a/tests/unit/XType/TypeFactoryTest.php b/tests/unit/XType/TypeFactoryTest.php index 51069c69..2d09611f 100644 --- a/tests/unit/XType/TypeFactoryTest.php +++ b/tests/unit/XType/TypeFactoryTest.php @@ -256,7 +256,7 @@ protected function newFactory() abstract class TypeFactoryTest_Model implements SelfExplanatory { - public function myXType() + public function xTypeConfig() { $translated = []; @@ -343,7 +343,7 @@ class TypeFactoryTest_Tag extends TypeFactoryTest_Model class TypeFactoryTest_Manual implements SelfExplanatory { - public function myXType() + public function xTypeConfig() { return new ObjectType; }