├── .gitignore ├── tests ├── integration │ ├── AgnosticTestCase.php │ ├── LaravelEavquentTest.php │ ├── AgnosticEavquentTest.php │ ├── LaravelTestCase.php │ └── EavquentTestTrait.php ├── support │ ├── models │ │ └── Company.php │ ├── migrations │ │ └── 2015_06_19_102752_create_companies_table.php │ └── Dummy.php ├── factories │ └── factories.php └── unit │ ├── Agnostic │ └── ConfigRepositoryTest.php │ ├── HelpersTest.php │ ├── Attribute │ ├── ManagerTest.php │ └── CacheTest.php │ ├── EavquentTest.php │ ├── Value │ ├── BuilderTest.php │ └── CollectionTest.php │ └── InteractorTest.php ├── src ├── Value │ ├── Data │ │ ├── Text.php │ │ ├── Integer.php │ │ ├── Varchar.php │ │ ├── Floatnum.php │ │ ├── Boolean.php │ │ └── Datetime.php │ ├── Trash.php │ ├── Builder.php │ ├── Value.php │ └── Collection.php ├── Attribute │ ├── Repository.php │ ├── Cache.php │ ├── Manager.php │ └── Attribute.php ├── AttributeCache.php ├── Events │ ├── AttributeWasSaved.php │ ├── EntityWasDeleted.php │ └── EntityWasSaved.php ├── helpers.php ├── Migration.php ├── Agnostic │ ├── ConfigRepository.php │ └── BootEavquent.php ├── EagerLoadScope.php ├── EavquentServiceProvider.php ├── RelationBuilder.php ├── Interactor.php └── Eavquent.php ├── .travis.yml ├── readme.md ├── config.php ├── migrations ├── 2016_04_20_000000_create_eav_values_text_table.php ├── 2016_02_29_000000_create_eav_values_boolean_table.php ├── 2016_02_29_000000_create_eav_values_integer_table.php ├── 2016_02_29_000000_create_eav_values_varchar_table.php ├── 2016_02_29_000000_create_eav_values_datetime_table.php ├── 2016_05_10_000000_create_eav_values_floatnum_table.php └── 2015_06_18_000000_create_eav_attributes_table.php ├── phpunit.xml └── composer.json /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | composer.lock 3 | /.idea -------------------------------------------------------------------------------- /tests/integration/AgnosticTestCase.php: -------------------------------------------------------------------------------- 1 | 'boolean', 14 | ]; 15 | } 16 | -------------------------------------------------------------------------------- /src/Value/Data/Datetime.php: -------------------------------------------------------------------------------- 1 | define(Company::class, function (Faker\Generator $faker) { 4 | return [ 5 | 'name' => $faker->company 6 | ]; 7 | }); 8 | 9 | $factory->define(\Devio\Eavquent\Value\Data\Varchar::class, function (Faker\Generator $faker) { 10 | return []; 11 | }); -------------------------------------------------------------------------------- /src/Attribute/Repository.php: -------------------------------------------------------------------------------- 1 | [ 11 | 'package_tables' => 'eav_', 12 | 'value_tables' => 'values_', 13 | ], 14 | 15 | /* 16 | |-------------------------------------------------------------------------- 17 | | Cache Key 18 | |-------------------------------------------------------------------------- 19 | */ 20 | 'cache_key' => 'eav', 21 | ]; 22 | -------------------------------------------------------------------------------- /migrations/2016_04_20_000000_create_eav_values_text_table.php: -------------------------------------------------------------------------------- 1 | text($name); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /migrations/2016_02_29_000000_create_eav_values_boolean_table.php: -------------------------------------------------------------------------------- 1 | boolean($name); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /migrations/2016_02_29_000000_create_eav_values_integer_table.php: -------------------------------------------------------------------------------- 1 | integer($name); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /migrations/2016_02_29_000000_create_eav_values_varchar_table.php: -------------------------------------------------------------------------------- 1 | string($name); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /migrations/2016_02_29_000000_create_eav_values_datetime_table.php: -------------------------------------------------------------------------------- 1 | dateTime($name); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /migrations/2016_05_10_000000_create_eav_values_floatnum_table.php: -------------------------------------------------------------------------------- 1 | float($name); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/support/migrations/2015_06_19_102752_create_companies_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 17 | $table->string('name'); 18 | }); 19 | } 20 | 21 | /** 22 | * Reverse the migrations. 23 | * 24 | * @return void 25 | */ 26 | public function down() 27 | { 28 | Schema::drop('companies'); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/AttributeCache.php: -------------------------------------------------------------------------------- 1 | 'foo']); 11 | 12 | $this->assertTrue($config->has('foo')); 13 | $this->assertFalse($config->has('bar')); 14 | 15 | ConfigRepository::getInstance(['bar' => 'bar']); 16 | $this->assertTrue($config->has('foo')); 17 | $this->assertTrue($config->has('bar')); 18 | } 19 | 20 | /** @test */ 21 | public function create_single_instance() 22 | { 23 | $instance = ConfigRepository::getInstance(['foo' => 'bar']); 24 | 25 | $this->assertEquals($instance, ConfigRepository::getInstance()); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Events/AttributeWasSaved.php: -------------------------------------------------------------------------------- 1 | getManager(); 16 | 17 | // Anytime an attribute is saved (updated or just created) we will refresh 18 | // the attribute cache. This way we'll make sure we are not working with 19 | // with outdated attribute options or even that do not exist anymore. 20 | $manager->refresh(); 21 | } 22 | 23 | /** 24 | * Get the manager instance. 25 | * 26 | * @return Manager 27 | */ 28 | protected function getManager() 29 | { 30 | return Container::getInstance()->make(Manager::class); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 15 | ./tests/ 16 | 17 | 18 | 19 | 20 | ./vendor 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /migrations/2015_06_18_000000_create_eav_attributes_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 17 | $table->string('name'); 18 | $table->string('label'); 19 | $table->string('type'); 20 | $table->string('entity'); 21 | $table->boolean('collection')->default(false); 22 | $table->text('default_value')->nullable(); 23 | 24 | $table->unique(['name', 'type']); 25 | }); 26 | } 27 | 28 | /** 29 | * Reverse the migrations. 30 | * 31 | * @return void 32 | */ 33 | public function down() 34 | { 35 | Schema::drop(eav_table('attributes')); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/helpers.php: -------------------------------------------------------------------------------- 1 | get($key); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Value/Trash.php: -------------------------------------------------------------------------------- 1 | exists) { 20 | $this->push($value); 21 | } 22 | } 23 | } 24 | 25 | /** 26 | * Clear the trash and delete items from database. 27 | */ 28 | public function clear() 29 | { 30 | if (! $this->count()) { 31 | return; 32 | } 33 | 34 | $class = get_class($this->first()); 35 | 36 | // Fetching the first element class we will discover the model we will 37 | // use for deleting. Let's now delete all the values based on their 38 | // id. Once done we just have to reset the trash items to empty. 39 | $class::whereIn('id', $this->pluck('id'))->delete(); 40 | 41 | $this->items = []; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/unit/HelpersTest.php: -------------------------------------------------------------------------------- 1 | [ 11 | 'package_tables' => 'eav_', 12 | 'value_tables' => 'values_' 13 | ], 14 | 'cache_key' => 'eav', 15 | ]); 16 | } 17 | 18 | /** @test */ 19 | public function it_should_read_a_config_key() 20 | { 21 | $this->assertEquals('eav', eav_config('cache_key')); 22 | $this->assertEquals('eav_', eav_config('prefix.package_tables')); 23 | } 24 | 25 | /** @test */ 26 | public function it_should_get_a_table_name() 27 | { 28 | $this->assertEquals('eav_foo', eav_table('foo')); 29 | $this->assertEquals('eav_bar', eav_table('bar')); 30 | } 31 | 32 | /** @test */ 33 | public function it_should_get_a_value_table_name() 34 | { 35 | $this->assertEquals('eav_values_foo', eav_value_table('foo')); 36 | $this->assertEquals('eav_values_foo_bar', eav_value_table('fooBar')); 37 | $this->assertEquals('eav_values_foobar', eav_value_table('foobar')); 38 | } 39 | } -------------------------------------------------------------------------------- /tests/integration/LaravelTestCase.php: -------------------------------------------------------------------------------- 1 | withFactories(__DIR__ . '/../factories'); 10 | 11 | // Running package migrations 12 | $this->artisan('migrate', [ 13 | '--database' => 'testbench', 14 | '--realpath' => realpath(__DIR__ . '/../../migrations'), 15 | ]); 16 | 17 | // Running testing migrations 18 | $this->artisan('migrate', [ 19 | '--database' => 'testbench', 20 | '--realpath' => realpath(__DIR__ . '/../support/migrations'), 21 | ]); 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 | protected function getPackageProviders($app) 36 | { 37 | return [Devio\Eavquent\EavquentServiceProvider::class]; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Migration.php: -------------------------------------------------------------------------------- 1 | tableName()), function (Blueprint $table) { 34 | $table->increments('id'); 35 | 36 | $this->contentColumn($table, 'content'); 37 | 38 | $table->integer('attribute_id')->unsigned(); 39 | $table->integer('entity_id')->unsigned(); 40 | }); 41 | } 42 | 43 | /** 44 | * Reverse the migrations. 45 | * 46 | * @return void 47 | */ 48 | public function down() 49 | { 50 | \Schema::drop(eav_value_table($this->tableName())); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "devio/eavquent", 3 | "description": "EAV modeling package for Eloquent and Laravel.", 4 | "keywords": [ 5 | "eav", "eloquent", "laravel", "dynamic", "schema", "database" 6 | ], 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Israel Ortuño", 11 | "email": "israel@devio.es" 12 | } 13 | ], 14 | "require": { 15 | "php": ">=5.5.9", 16 | "illuminate/support": "~5.1", 17 | "illuminate/database": "~5.1", 18 | "illuminate/validation": "~5.1", 19 | "illuminate/config": "~5.1" 20 | }, 21 | "require-dev": { 22 | "phpspec/phpspec": "^2.4", 23 | "phpunit/phpunit": "~4.0", 24 | "mockery/mockery": "^0.9.4", 25 | "fzaninotto/faker": "~1.4", 26 | "orchestra/testbench": "~3.1" 27 | }, 28 | "autoload": { 29 | "files": [ 30 | "src/helpers.php" 31 | ], 32 | "classmap": [ 33 | "migrations" 34 | ], 35 | "psr-4": { 36 | "Devio\\Eavquent\\": "src" 37 | } 38 | }, 39 | "autoload-dev": { 40 | "classmap": [ 41 | "tests/" 42 | ] 43 | }, 44 | "extra": { 45 | "branch-alias": { 46 | "dev-master": "0.1-dev" 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tests/unit/Attribute/ManagerTest.php: -------------------------------------------------------------------------------- 1 | manager = new Manager(m::mock(Cache::class), m::mock(Repository::class)); 16 | } 17 | 18 | /** @test */ 19 | public function get_all_attributes() 20 | { 21 | $this->manager->getCache()->shouldReceive('exists')->once()->andReturn(true); 22 | $this->manager->getCache()->shouldReceive('get'); 23 | 24 | $this->manager->get(); 25 | } 26 | 27 | /** @test */ 28 | public function refresh_attributes_if_no_cache() 29 | { 30 | $collection = new Collection; 31 | $this->manager->getRepository()->shouldReceive('all')->once()->andReturn($collection); 32 | $this->manager->getCache()->shouldReceive('set')->with($collection)->once(); 33 | $this->manager->getCache()->shouldReceive('exists')->once()->andReturn(false); 34 | $this->manager->getCache()->shouldReceive('get'); 35 | 36 | $this->manager->get(); 37 | } 38 | 39 | public function tearDown() 40 | { 41 | m::close(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Agnostic/ConfigRepository.php: -------------------------------------------------------------------------------- 1 | all(), $items); 43 | 44 | static::$instance->setItems($items); 45 | 46 | return static::$instance; 47 | } 48 | 49 | /** 50 | * Set the items array. 51 | * 52 | * @param array $items 53 | */ 54 | public function setItems(array $items = []) 55 | { 56 | $this->items = $items; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/EagerLoadScope.php: -------------------------------------------------------------------------------- 1 | parseEagerLoads($builder, $model); 21 | } 22 | 23 | /** 24 | * Parse eagerload for eav inclusions. 25 | * 26 | * @param Builder $builder 27 | * @param Model $model 28 | */ 29 | protected function parseEagerLoads(Builder $builder, Model $model) 30 | { 31 | $eagerLoads = $builder->getEagerLoads(); 32 | 33 | // If there is any eagerload matching the eav key, we will replace it with 34 | // all the registered properties for the model. We'll simulate as if the 35 | // user has manually added any of these withs in purpose when querying. 36 | if (array_key_exists('eav', $eagerLoads)) { 37 | $eagerLoads = array_merge($eagerLoads, $model->getAttributeRelations()); 38 | 39 | $builder->setEagerLoads(array_except($eagerLoads, 'eav')); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Agnostic/BootEavquent.php: -------------------------------------------------------------------------------- 1 | container = $container; 27 | } 28 | 29 | /** 30 | * Booting Eavquent. 31 | */ 32 | public function boot() 33 | { 34 | if (! $this->container) { 35 | $this->container = new Container; 36 | } 37 | 38 | $this->registerBindings(); 39 | 40 | Container::setInstance($this->container); 41 | } 42 | 43 | /** 44 | * Registering contianer bindings. 45 | */ 46 | public function registerBindings() 47 | { 48 | $this->container->bind(AttributeCache::class, Cache::class); 49 | 50 | $this->container->singleton(\Illuminate\Contracts\Cache\Repository::class, function () { 51 | $store = new FileStore(new Filesystem, __DIR__ . '/../cache'); 52 | 53 | return new Repository($store); 54 | }); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Attribute/Cache.php: -------------------------------------------------------------------------------- 1 | cache = $cache; 33 | $this->cacheKey = eav_config('cache_key'); 34 | } 35 | 36 | /** 37 | * {@inheritdoc} 38 | */ 39 | public function exists() 40 | { 41 | return $this->cache->has($this->cacheKey); 42 | } 43 | 44 | /** 45 | * {@inheritdoc} 46 | */ 47 | public function get() 48 | { 49 | return $this->cache->get($this->cacheKey); 50 | } 51 | 52 | /** 53 | * {@inheritdoc} 54 | */ 55 | public function set(Collection $attributes) 56 | { 57 | $this->flush(); 58 | 59 | return $this->cache->forever($this->cacheKey, $attributes); 60 | } 61 | 62 | /** 63 | * {@inheritdoc} 64 | */ 65 | public function flush() 66 | { 67 | return $this->cache->forget($this->cacheKey); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /tests/unit/Attribute/CacheTest.php: -------------------------------------------------------------------------------- 1 | shouldReceive('has')->with('eav')->once()->andReturn(false); 15 | $cache = new Cache($repository); 16 | 17 | $this->assertFalse($cache->exists()); 18 | 19 | $repository->shouldReceive('has')->with('eav')->once()->andReturn(true); 20 | $this->assertTrue($cache->exists()); 21 | } 22 | 23 | /** @test */ 24 | public function get_all_cached_attributes() 25 | { 26 | $collection = new Collection(['foo', 'bar']); 27 | $repository = m::mock(Repository::class); 28 | $repository->shouldReceive('get')->with('eav')->once()->andReturn($collection); 29 | $cache = new Cache($repository); 30 | 31 | $this->assertEquals($collection, $cache->get()); 32 | } 33 | 34 | public function store_given_attributes() 35 | { 36 | $collection = new Collection(['foo', 'bar']); 37 | $repository = m::mock(Repository::class); 38 | $repository->shouldReceive('has')->with('eav')->once()->andReturn(false); 39 | $repository->shouldReceive('forget')->with('eav')->once(); 40 | $repository->shouldReceive('forever')->with('eav', $collection)->once(); 41 | 42 | $cache = new Cache($repository); 43 | 44 | $cache->set($collection); 45 | } 46 | 47 | public function tearDown() 48 | { 49 | m::close(); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tests/unit/EavquentTest.php: -------------------------------------------------------------------------------- 1 | entity = new EavquentStub; 20 | 21 | $container->shouldReceive('make')->with(Interactor::class, [$this->entity])->andReturn($interactor); 22 | $container->shouldReceive('make')->with(Manager::class)->andReturn($manager); 23 | 24 | $this->entity->setContainer($container); 25 | } 26 | 27 | /** @test */ 28 | public function resolve_eavquent_dependences() 29 | { 30 | $this->assertInstanceOf(Container::class, $this->entity->getContainer()); 31 | $this->assertInstanceOf(Interactor::class, $this->entity->getInteractor()); 32 | $this->assertInstanceOf(Manager::class, $this->entity->getAttributeManager()); 33 | } 34 | 35 | /** @test */ 36 | public function get_content_of_eav_attribute() 37 | { 38 | $interactor = $this->entity->getInteractor(); 39 | 40 | $interactor->shouldReceive('isAttribute')->with('foo')->andReturn(true); 41 | $interactor->shouldReceive('get')->with('foo')->andReturn('bar'); 42 | 43 | $this->assertEquals('bar', $this->entity->getAttribute('foo')); 44 | } 45 | 46 | public function tearDown() 47 | { 48 | m::close(); 49 | } 50 | } 51 | 52 | class EavquentStub 53 | { 54 | use Eavquent; 55 | } 56 | -------------------------------------------------------------------------------- /src/Attribute/Manager.php: -------------------------------------------------------------------------------- 1 | cache = $cache; 32 | $this->repository = $repository; 33 | } 34 | 35 | /** 36 | * Get all the attributes registered or just for a single entity. 37 | * 38 | * @param string $entity 39 | * @return mixed 40 | */ 41 | public function get($entity = '*') 42 | { 43 | if (! $this->cache->exists()) { 44 | $this->refresh(); 45 | } 46 | 47 | $attributes = $this->cache->get(); 48 | 49 | return $entity == '*' ? 50 | $attributes : $attributes->where('entity', $entity); 51 | } 52 | 53 | /** 54 | * Refresh the cache content. 55 | * 56 | * @return $this 57 | */ 58 | public function refresh() 59 | { 60 | $this->cache->set($this->repository->all()); 61 | 62 | return $this; 63 | } 64 | 65 | /** 66 | * Get the cache instance. 67 | * 68 | * @return AttributeCache 69 | */ 70 | public function getCache() 71 | { 72 | return $this->cache; 73 | } 74 | 75 | /** 76 | * Get the repository instance. 77 | * 78 | * @return Repository 79 | */ 80 | public function getRepository() 81 | { 82 | return $this->repository; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/EavquentServiceProvider.php: -------------------------------------------------------------------------------- 1 | publishes( 20 | [$this->base('config.php') => config_path('eavquent.php')], 'config' 21 | ); 22 | $this->publishes( 23 | [$this->base('migrations/') => database_path('migrations')], 'migrations' 24 | ); 25 | } 26 | 27 | /** 28 | * Register the service provider. 29 | */ 30 | public function register() 31 | { 32 | $this->registerConfig(); 33 | $this->registerBindings(); 34 | } 35 | 36 | /** 37 | * Register the package configuration. 38 | */ 39 | protected function registerConfig() 40 | { 41 | $this->mergeConfigFrom($this->base('config.php'), 'eavquent'); 42 | } 43 | 44 | /** 45 | * Register container bindings. 46 | */ 47 | protected function registerBindings() 48 | { 49 | $this->app->bind(AttributeCache::class, Cache::class); 50 | 51 | $this->app->bind(Interactor::class, function ($app, $params) { 52 | $builder = $app->make(ValueBuilder::class); 53 | 54 | return new Interactor($builder, $params[0]); 55 | }); 56 | } 57 | 58 | /** 59 | * Get the base path. 60 | * 61 | * @param $path 62 | * @return string 63 | */ 64 | protected function base($path) 65 | { 66 | return __DIR__ . "/../{$path}"; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Attribute/Attribute.php: -------------------------------------------------------------------------------- 1 | setTable(eav_table('attributes')); 34 | 35 | parent::__construct($attributes); 36 | } 37 | 38 | /** 39 | * Registering events. 40 | */ 41 | public static function boot() 42 | { 43 | parent::boot(); 44 | 45 | static::saved(AttributeWasSaved::class . '@handle'); 46 | } 47 | 48 | /** 49 | * Get the attribute name name. 50 | * 51 | * @return mixed 52 | */ 53 | public function getName() 54 | { 55 | return $this->getAttribute('name'); 56 | } 57 | 58 | /** 59 | * Get the model class name. 60 | * 61 | * @return mixed 62 | */ 63 | public function getType() 64 | { 65 | return $this->getAttribute('type'); 66 | } 67 | 68 | /** 69 | * Return the model class. 70 | * 71 | * @return mixed 72 | */ 73 | public function getTypeInstance() 74 | { 75 | $class = $this->getType(); 76 | 77 | return new $class; 78 | } 79 | 80 | /** 81 | * Check if attribute is multivalued. 82 | * 83 | * @return bool 84 | */ 85 | public function isCollection() 86 | { 87 | return (bool) $this->getAttribute('collection'); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Value/Builder.php: -------------------------------------------------------------------------------- 1 | getTypeInstance(); 27 | 28 | $this->ensure($entity, $attribute, $instance); 29 | 30 | return $instance->setContent($value); 31 | } 32 | 33 | /** 34 | * @param Model $entity 35 | * @param Attribute $attribute 36 | * @param $value 37 | * 38 | * @return Value 39 | */ 40 | public function ensure(Model $entity, Attribute $attribute, $value) 41 | { 42 | if (is_null($value)) { 43 | return $value; 44 | } 45 | 46 | if ($value instanceof BaseCollection) { 47 | // If we receive a collection of values, we'll just spin through 48 | // them and recursively ensure they are properly linked to the 49 | // entity and attribute instances provided to this method. 50 | foreach ($value as $item) { 51 | $this->ensure($entity, $attribute, $item); 52 | } 53 | 54 | return $value; 55 | } 56 | 57 | // At any way we will try to find out the entity and attribute keys in 58 | // order to set them as foreign keys for the attribute. This way we 59 | // can make sure the value is properly linked to its "parents". 60 | $value->setAttribute('entity_id', $entity->getKey()); 61 | $value->setAttribute($attribute->getForeignKey(), $attribute->getKey()); 62 | 63 | return $value; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Value/Value.php: -------------------------------------------------------------------------------- 1 | table = $this->getAttributeTableName(); 25 | 26 | parent::__construct($attributes); 27 | } 28 | 29 | /** 30 | * Relationship to the attributes table. 31 | * 32 | * @return \Illuminate\Database\Eloquent\Relations\BelongsTo 33 | */ 34 | public function attribute() 35 | { 36 | return $this->belongsTo(Attribute::class); 37 | } 38 | 39 | /** 40 | * Set the content. 41 | * 42 | * @param $content 43 | * @return mixed 44 | */ 45 | public function setContent($content) 46 | { 47 | return $this->setAttribute('content', $content); 48 | } 49 | 50 | /** 51 | * Get the content. 52 | * 53 | * @return mixed 54 | */ 55 | public function getContent() 56 | { 57 | return $this->getAttribute('content'); 58 | } 59 | 60 | /** 61 | * Check if value should push to relations when saving. 62 | * 63 | * @return bool 64 | */ 65 | public function shouldPush() 66 | { 67 | return false; 68 | } 69 | 70 | /** 71 | * Get the attribute table name. 72 | * 73 | * @return string 74 | */ 75 | protected function getAttributeTableName() 76 | { 77 | $class = str_replace('Value', '', class_basename($this)); 78 | 79 | return eav_value_table($class); 80 | } 81 | 82 | /** 83 | * Return an Eavquent Collection instead. 84 | * 85 | * @param array $models 86 | * @return Collection 87 | */ 88 | public function newCollection(array $models = []) 89 | { 90 | return new Collection($models); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Events/EntityWasDeleted.php: -------------------------------------------------------------------------------- 1 | isForceDeleting()) { 23 | return; 24 | } 25 | 26 | $this->delete($model); 27 | } 28 | 29 | /** 30 | * Delete any value for every attribute related to the entity. 31 | * 32 | * @param $model 33 | */ 34 | protected function delete($model) 35 | { 36 | $attributes = $model->getEntityAttributes(); 37 | 38 | foreach ($attributes as $attribute) { 39 | $this->performDeletion( 40 | $attribute->getType(), $model->getRelationValue($attribute->getName()) 41 | ); 42 | } 43 | } 44 | 45 | /** 46 | * Perform the deletion from the model class. 47 | * 48 | * @param $type The model class 49 | * @param $values The values to be deleted 50 | */ 51 | protected function performDeletion($type, $values) 52 | { 53 | if (is_null($values)) { 54 | return; 55 | } 56 | 57 | if (! $values instanceof Collection) { 58 | $values = new Collection([$values]); 59 | } 60 | 61 | // Calling the `destroy` method from the given $type model class name 62 | // will finally delete the records from database if any was found. 63 | // We'll just provide an array containing the ids to be deleted. 64 | if ($values->count()) { 65 | $elements = $values->pluck('id'); 66 | 67 | forward_static_call_array([$type, 'destroy'], [$elements->toArray()]); 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /tests/unit/Value/BuilderTest.php: -------------------------------------------------------------------------------- 1 | builder = new Builder; 16 | } 17 | 18 | /** @test */ 19 | public function build_class_based_on_attribute() 20 | { 21 | $entity = new BuilderEntityStub; 22 | $entity->setRawAttributes(['id' => 101]); 23 | $value = m::mock(Varchar::class); 24 | $attribute = m::mock(Attribute::class); 25 | 26 | $attribute->shouldReceive('getTypeInstance')->once()->andReturn($value); 27 | 28 | $attribute->shouldReceive('getForeignKey')->once()->andReturn('attribute_id'); 29 | $attribute->shouldReceive('getKey')->once()->andReturn(202); 30 | 31 | $value->shouldReceive('setAttribute')->with('entity_id', 101)->once(); 32 | $value->shouldReceive('setAttribute')->with('attribute_id', 202)->once(); 33 | 34 | $value->shouldReceive('setContent')->with('foo')->once()->andReturn($value); 35 | 36 | $result = $this->builder->build($entity, $attribute, 'foo'); 37 | 38 | $this->assertInstanceOf(Varchar::class, $result); 39 | } 40 | 41 | /** @test */ 42 | public function ensure_entity_is_related() 43 | { 44 | $entity = new BuilderEntityStub; 45 | $entity->setRawAttributes(['id' => 101]); 46 | $attribute = m::mock(Attribute::class); 47 | $value = m::mock(Varchar::class); 48 | 49 | $attribute->shouldReceive('getForeignKey')->once()->andReturn('attribute_id'); 50 | $attribute->shouldReceive('getKey')->once()->andReturn(202); 51 | 52 | $value->shouldReceive('setAttribute')->with('entity_id', 101)->once(); 53 | $value->shouldReceive('setAttribute')->with('attribute_id', 202)->once(); 54 | 55 | $this->builder->ensure($entity, $attribute, $value); 56 | } 57 | 58 | public function tearDown() 59 | { 60 | m::close(); 61 | } 62 | } 63 | 64 | class BuilderEntityStub extends Model 65 | { 66 | } 67 | -------------------------------------------------------------------------------- /tests/support/Dummy.php: -------------------------------------------------------------------------------- 1 | 'city', 15 | 'label' => 'City', 16 | 'type' => Varchar::class, 17 | 'entity' => Company::class, 18 | 'default_value' => null 19 | ]); 20 | 21 | // Collection attribute with values 22 | $colorsAttribute = Attribute::create([ 23 | 'name' => 'colors', 24 | 'label' => 'Colors', 25 | 'type' => Varchar::class, 26 | 'entity' => Company::class, 27 | 'default_value' => null, 28 | 'collection' => true 29 | ]); 30 | 31 | // Simple attribute without any value 32 | $addressAttribute = Attribute::create([ 33 | 'name' => 'address', 34 | 'label' => 'Address', 35 | 'type' => Varchar::class, 36 | 'entity' => Company::class, 37 | 'default_value' => null 38 | ]); 39 | 40 | // Collection attribute without any value 41 | $sizesAttribute = Attribute::create([ 42 | 'name' => 'sizes', 43 | 'label' => 'Sizes', 44 | 'type' => Varchar::class, 45 | 'entity' => Company::class, 46 | 'default_value' => null, 47 | 'collection' => true 48 | ]); 49 | 50 | factory(Company::class, 5)->create()->each(function ($item) use ($faker, $cityAttribute, $colorsAttribute) { 51 | factory(Varchar::class)->create([ 52 | 'content' => $faker->city, 53 | 'attribute_id' => $cityAttribute->id, 54 | 'entity_id' => $item->getKey() 55 | ]); 56 | 57 | factory(Varchar::class, 2)->create([ 58 | 'content' => $faker->colorName, 59 | 'attribute_id' => $colorsAttribute->id, 60 | 'entity_id' => $item->getKey() 61 | ]); 62 | }); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/RelationBuilder.php: -------------------------------------------------------------------------------- 1 | getEntityAttributes(); 19 | 20 | // We will manually add a relationship for every attribute registered 21 | // for this entity. Once we know the relation method we have to use 22 | // we will just add it to the attributeRelations class property. 23 | foreach ($attributes as $attribute) { 24 | $relation = $this->getRelationClosure($entity, $attribute); 25 | 26 | $entity->setAttributeRelation($attribute->getName(), $relation); 27 | } 28 | } 29 | 30 | /** 31 | * Generate the relation closure. 32 | * 33 | * @param Model $entity 34 | * @param Attribute $attribute 35 | * @return Closure 36 | */ 37 | protected function getRelationClosure(Model $entity, Attribute $attribute) 38 | { 39 | $method = $this->guessRelationMethod($attribute); 40 | 41 | // This will return a closure fully binded to the current model instance. 42 | // This will help us to simulate any relation as if it was handly made 43 | // in the original model class definition using a function statement. 44 | return Closure::bind(function () use ($entity, $attribute, $method) { 45 | $relation = $entity->$method($attribute->getType(), 'entity_id'); 46 | 47 | // We add a where clausule in order to fetch only the elements that 48 | // are related to the given attribute. If no condition is set, it 49 | // will fetch all the value rows related to the current entity. 50 | return $relation->where($attribute->getForeignKey(), $attribute->getKey()); 51 | }, $entity, get_class($entity)); 52 | } 53 | 54 | /** 55 | * Get the relation name to use. 56 | * 57 | * @param Attribute $attribute 58 | * @return string 59 | */ 60 | protected function guessRelationMethod(Attribute $attribute) 61 | { 62 | return $attribute->isCollection() ? 'hasMany' : 'hasOne'; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /tests/unit/Value/CollectionTest.php: -------------------------------------------------------------------------------- 1 | setRawAttributes(['content' => 'foo']), 19 | (new UnexistingValueStub)->setRawAttributes(['content' => 'bar']), 20 | ]; 21 | 22 | $collection = new CollectionStub($items); 23 | 24 | $this->collection = $collection->link(new CollectionModelStub, m::mock(Attribute::class)); 25 | } 26 | 27 | /** @test */ 28 | public function add_new_value() 29 | { 30 | list($entity, $attribute, $builder) = [ 31 | $this->collection->getEntity(), $this->collection->getAttribute(), $this->collection->getBuilder(), 32 | ]; 33 | 34 | $builder->shouldReceive('build')->with($entity, $attribute, 'baz') 35 | ->andReturn((new UnexistingValueStub)->setRawAttributes(['content' => 'baz'])); 36 | 37 | $this->collection->add('baz'); 38 | 39 | $this->assertCount(3, $this->collection); 40 | $this->assertCount(1, $this->collection->where('content', 'baz')); 41 | } 42 | 43 | /** @test */ 44 | public function trash_current_values() 45 | { 46 | list($entity, $attribute, $builder) = [ 47 | $this->collection->getEntity(), $this->collection->getAttribute(), $this->collection->getBuilder(), 48 | ]; 49 | $trash = $entity->getTrash(); 50 | 51 | $builder->shouldReceive('build')->with($entity, $attribute, 'baz') 52 | ->andReturn((new ExistingValueStub)->setRawAttributes(['content' => 'baz'])); 53 | $trash->shouldReceive('add')->with($this->collection->all())->once(); 54 | 55 | $this->collection->replace(['baz']); 56 | 57 | $this->assertCount(1, $this->collection); 58 | $this->assertCount(1, $this->collection->where('content', 'baz')); 59 | 60 | $builder->shouldReceive('build')->with($entity, $attribute, 'qux') 61 | ->andReturn((new UnexistingValueStub)->setRawAttributes(['content' => 'qux'])); 62 | $builder->shouldReceive('build')->with($entity, $attribute, 'xuq') 63 | ->andReturn((new UnexistingValueStub)->setRawAttributes(['content' => 'xuq'])); 64 | $trash->shouldReceive('add')->with($this->collection->all())->once(); 65 | 66 | 67 | $this->collection->replace(['qux', 'xuq']); 68 | $this->assertCount(2, $this->collection); 69 | $this->assertCount(1, $this->collection->where('content', 'qux')); 70 | $this->assertCount(1, $this->collection->where('content', 'xuq')); 71 | } 72 | 73 | public function tearDown() 74 | { 75 | m::close(); 76 | } 77 | } 78 | 79 | class CollectionStub extends Collection 80 | { 81 | protected $builder; 82 | 83 | public function getBuilder() 84 | { 85 | return $this->builder = $this->builder ?: m::mock(Builder::class); 86 | } 87 | } 88 | 89 | class CollectionModelStub extends Model 90 | { 91 | public function getTrash() 92 | { 93 | return $this->trash = $this->trash ?: m::mock(Trash::class); 94 | } 95 | } 96 | 97 | class ExistingValueStub extends Value 98 | { 99 | public $exists = true; 100 | } 101 | 102 | class UnexistingValueStub extends Value 103 | { 104 | } 105 | -------------------------------------------------------------------------------- /src/Events/EntityWasSaved.php: -------------------------------------------------------------------------------- 1 | isAttributeRelationsBooted() || ! $model->autoPushEnabled()) { 28 | return; 29 | } 30 | 31 | $this->trash = $model->getTrash(); 32 | 33 | $connection = $model->getConnection(); 34 | $connection->beginTransaction(); 35 | 36 | // If autopush is not enabled, we'll let the user handle the saving process. 37 | // When saving a model, we will also clear any trashed value that may be 38 | // queued for deletion for any reason: null, collection replacement... 39 | try { 40 | $this->save($model); 41 | $this->trash->clear(); 42 | } catch (\Exception $e) { 43 | $connection->rollBack(); 44 | throw $e; 45 | } 46 | 47 | $connection->commit(); 48 | 49 | $this->refresh($model); 50 | } 51 | 52 | /** 53 | * Saves the model values. 54 | * 55 | * @param Model $model 56 | */ 57 | protected function save(Model $model) 58 | { 59 | $builder = new Builder; 60 | 61 | foreach ($model->getEntityAttributes() as $attribute) { 62 | if (! $model->relationLoaded($relation = $attribute->getName())) { 63 | continue; 64 | } 65 | 66 | // We will ensure all values are truly linked to the entity record. 67 | // This is specially useful when creating new entity records as 68 | // we do not know its id until it is persisted into database. 69 | $values = $builder->ensure( 70 | $model, $attribute, $model->getRelationValue($relation) 71 | ); 72 | 73 | // We will check for relation loads as we do not want to load any relation 74 | // which was not implicitly loaded. Then iterating over any value model 75 | // existing as relation and save it to persist it and its relations. 76 | $this->saveOrTrash($values); 77 | } 78 | } 79 | 80 | /** 81 | * Persists or trash the values. 82 | * 83 | * @param $values 84 | */ 85 | protected function saveOrTrash($values) 86 | { 87 | $values = $values instanceof Collection 88 | ? $values->all() : [$values]; 89 | 90 | // In order to provide flexibility and let the values have their own 91 | // relationships, here we'll check if a value should be completely 92 | // saved with its relations or just save its own current state. 93 | foreach ($values as $value) { 94 | if (is_null($value) || $this->trash($value)) { 95 | continue; 96 | } 97 | 98 | if ($value->shouldPush()) { 99 | $value->push(); 100 | } else { 101 | $value->save(); 102 | } 103 | } 104 | } 105 | 106 | /** 107 | * Trash the element if null. 108 | * 109 | * @param $value 110 | * @return bool 111 | */ 112 | public function trash($value) 113 | { 114 | if (! is_null($value->getContent())) { 115 | return false; 116 | } 117 | 118 | $this->trash->add($value); 119 | 120 | return true; 121 | } 122 | 123 | /** 124 | * @param $model 125 | */ 126 | protected function refresh($model) 127 | { 128 | foreach ($model->getEntityAttributes() as $attribute) { 129 | if ($attribute->isCollection() 130 | || ! $model->relationLoaded($relation = $attribute->getName()) 131 | || is_null($values = $model->getRelationValue($relation)) 132 | ) { 133 | continue; 134 | } 135 | 136 | if (is_null($values->getContent())) { 137 | $model->setRelation($relation, null); 138 | } 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/Value/Collection.php: -------------------------------------------------------------------------------- 1 | setEntity($entity); 42 | $this->setAttribute($attribute); 43 | 44 | return $this; 45 | } 46 | 47 | /** 48 | * Add new values to the collection. 49 | * 50 | * @param array $values 51 | * @return $this 52 | */ 53 | public function add($values = []) 54 | { 55 | if (! is_array($values) && ! $values instanceof BaseCollection) { 56 | $values = func_get_args(); 57 | } 58 | 59 | // Once we have made sure our input is an array of values, we will convert 60 | // them into value model objects (if no model instances are given). When 61 | // done we will just push all values into the current collection items. 62 | foreach ($values as $value) { 63 | $this->push($this->buildValue($value)); 64 | } 65 | 66 | return $this; 67 | } 68 | 69 | /** 70 | * Queue for deletion current items and set news. 71 | * 72 | * @param $values 73 | * @return $this 74 | */ 75 | public function replace($values = []) 76 | { 77 | if (! is_array($values) && ! $values instanceof BaseCollection) { 78 | $values = func_get_args(); 79 | } 80 | 81 | // We will just store the current value items to the replaced collection 82 | // and replacing them with the new given values. These values will be 83 | // transformed into a data type value based on the linked attribute. 84 | $this->trashCurrentItems(); 85 | 86 | $this->items = $this->buildValues($values); 87 | 88 | return $this; 89 | } 90 | 91 | /** 92 | * Add current items to replaced collection. 93 | * 94 | * @return void 95 | */ 96 | protected function trashCurrentItems() 97 | { 98 | $trash = $this->entity->getTrash(); 99 | 100 | $trash->add($this->items); 101 | } 102 | 103 | /** 104 | * Build a value instance. 105 | * 106 | * @param $value 107 | * @return Model 108 | */ 109 | public function buildValue($value) 110 | { 111 | if ($value instanceof Model) { 112 | return $value; 113 | } 114 | 115 | return $this->getBuilder()->build( 116 | $this->getEntity(), $this->getAttribute(), $value 117 | ); 118 | } 119 | 120 | /** 121 | * Build value objects from array. 122 | * 123 | * @param array $values 124 | * @return mixed 125 | */ 126 | public function buildValues(array $values = []) 127 | { 128 | $result = []; 129 | 130 | // We will iterate through the entire array of values transforming every 131 | // item into the data type object linked to this collection. Any null 132 | // value will be omitted here in order to avoid storing NULL values. 133 | foreach ($values as $value) { 134 | if (! is_null($value)) { 135 | $result[] = $this->buildValue($value); 136 | } 137 | } 138 | 139 | return $result; 140 | } 141 | 142 | /** 143 | * Get the builder instance. 144 | * 145 | * @return Builder 146 | */ 147 | protected function getBuilder() 148 | { 149 | return new Builder; 150 | } 151 | 152 | /** 153 | * Get the replaced values. 154 | * 155 | * @return BaseCollection 156 | */ 157 | public function getReplaced() 158 | { 159 | return $this->replaced; 160 | } 161 | 162 | /** 163 | * Get the entity instance. 164 | * 165 | * @return mixed 166 | */ 167 | public function getEntity() 168 | { 169 | return $this->entity; 170 | } 171 | 172 | /** 173 | * Set the entity instance. 174 | * 175 | * @param mixed $entity 176 | */ 177 | public function setEntity($entity) 178 | { 179 | $this->entity = $entity; 180 | } 181 | 182 | /** 183 | * Get the attribute instance. 184 | * 185 | * @return mixed 186 | */ 187 | public function getAttribute() 188 | { 189 | return $this->attribute; 190 | } 191 | 192 | /** 193 | * Set the attribute instance. 194 | * 195 | * @param mixed $attribute 196 | */ 197 | public function setAttribute($attribute) 198 | { 199 | $this->attribute = $attribute; 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /tests/unit/InteractorTest.php: -------------------------------------------------------------------------------- 1 | shouldReceive('getEntityAttributes')->andReturn(new Collection(['foo' => new Attribute])); 23 | $entity->shouldReceive('bootEavquentIfNotBooted'); 24 | $value->shouldReceive('getContent')->andReturn('bar'); 25 | $entity->shouldReceive('relationLoaded')->with('foo')->andReturn(true); 26 | $entity->shouldReceive('getRelation')->with('foo')->andReturn($value); 27 | 28 | $interactor = new Interactor($builder, $entity); 29 | 30 | $this->assertEquals('bar', $interactor->get('foo')); 31 | } 32 | 33 | /** @test */ 34 | public function read_collection_content() 35 | { 36 | $builder = m::mock(Builder::class); 37 | $entity = m::mock(InteractorModelStub::class); 38 | $attribute = m::mock(Attribute::class); 39 | 40 | $entity->shouldReceive('bootEavquentIfNotBooted'); 41 | $entity->shouldReceive('getEntityAttributes')->andReturn(new Collection(['foo' => $attribute])); 42 | $attribute->shouldReceive('isCollection')->once()->andReturn(true); 43 | $entity->shouldReceive('relationLoaded')->with('foo')->andReturn(true); 44 | $entity->shouldReceive('getRelation')->with('foo')->andReturn(new Collection); 45 | 46 | $interactor = new Interactor($builder, $entity); 47 | 48 | $this->assertInstanceOf(BaseCollection::class, $interactor->get('foo')); 49 | } 50 | 51 | /** @test */ 52 | public function read_raw_object() 53 | { 54 | $builder = m::mock(Builder::class); 55 | $entity = m::mock(InteractorModelStub::class); 56 | 57 | $entity->shouldReceive('bootEavquentIfNotBooted'); 58 | $entity->shouldReceive('getEntityAttributes')->andReturn(new Collection(['foo' => new Attribute])); 59 | $entity->shouldReceive('relationLoaded')->with('foo')->andReturn(true); 60 | $entity->shouldReceive('getRelation')->with('foo')->andReturn('bar'); 61 | 62 | $interactor = new Interactor($builder, $entity); 63 | 64 | $this->assertEquals('bar', $interactor->get('rawFooObject')); 65 | $this->assertEquals('bar', $interactor->get('rawfooobject')); 66 | $this->assertEquals('bar', $interactor->get('rawfooObject')); 67 | $this->assertEquals('bar', $interactor->get('rawFooobject')); 68 | } 69 | 70 | /** @test */ 71 | public function update_content_of_existing_simple_value() 72 | { 73 | $value = m::mock(Value::class); 74 | $builder = m::mock(Builder::class); 75 | $entity = m::mock(InteractorModelStub::class); 76 | $attribute = m::mock(Attribute::class); 77 | 78 | $entity->shouldReceive('bootEavquentIfNotBooted'); 79 | $attribute->shouldReceive('isCollection')->once()->andReturn(false); 80 | $entity->shouldReceive('getEntityAttributes')->andReturn(new Collection(['foo' => $attribute])); 81 | $entity->shouldReceive('relationLoaded')->with('foo')->andReturn(true); 82 | $entity->shouldReceive('getRelation')->with('foo')->andReturn($value); 83 | 84 | $value->shouldReceive('setContent')->with('bar')->once(); 85 | 86 | $interactor = new Interactor($builder, $entity); 87 | 88 | $interactor->set('foo', 'bar'); 89 | } 90 | 91 | /** @test */ 92 | public function set_a_new_simple_value() 93 | { 94 | $value = m::mock(Value::class); 95 | $builder = m::mock(Builder::class); 96 | $entity = m::mock(InteractorModelStub::class); 97 | $attribute = m::mock(Attribute::class); 98 | 99 | $entity->shouldReceive('bootEavquentIfNotBooted'); 100 | $attribute->shouldReceive('isCollection')->once()->andReturn(false); 101 | $attribute->shouldReceive('getName')->once()->andReturn('foo'); 102 | $entity->shouldReceive('getEntityAttributes')->andReturn(new Collection(['foo' => $attribute])); 103 | $entity->shouldReceive('relationLoaded')->with('foo')->once()->andReturn(true); 104 | $entity->shouldReceive('getRelation')->with('foo')->once()->andReturn(null); 105 | $entity->shouldReceive('setRelation')->with('foo', $value)->once(); 106 | 107 | $builder->shouldReceive('build')->with($entity, $attribute, 'bar')->andReturn($value); 108 | 109 | $interactor = new Interactor($builder, $entity); 110 | 111 | $interactor->set('foo', 'bar'); 112 | } 113 | 114 | /** @test */ 115 | public function replace_an_entire_collection() 116 | { 117 | $value = m::mock(Collection::class); 118 | $builder = m::mock(Builder::class); 119 | $entity = m::mock(InteractorModelStub::class); 120 | $attribute = m::mock(Attribute::class); 121 | 122 | $entity->shouldReceive('bootEavquentIfNotBooted'); 123 | $attribute->shouldReceive('isCollection')->once()->andReturn(true); 124 | $entity->shouldReceive('getEntityAttributes')->andReturn(new Collection(['foo' => $attribute])); 125 | $entity->shouldReceive('relationLoaded')->with('foo')->once()->andReturn(true); 126 | $entity->shouldReceive('getRelation')->with('foo')->once()->andReturn($value); 127 | $value->shouldReceive('replace')->with('bar')->once(); 128 | 129 | $interactor = new Interactor($builder, $entity); 130 | 131 | $interactor->set('foo', 'bar'); 132 | } 133 | 134 | public function tearDown() 135 | { 136 | m::close(); 137 | } 138 | } 139 | 140 | class InteractorModelStub extends Model 141 | { 142 | use Eavquent; 143 | } 144 | -------------------------------------------------------------------------------- /src/Interactor.php: -------------------------------------------------------------------------------- 1 | bootEavquentIfNotBooted(); 43 | 44 | $this->entity = $entity; 45 | $this->attributes = $entity->getEntityAttributes(); 46 | $this->builder = $builder; 47 | } 48 | 49 | /** 50 | * Check if the key is an attribute. 51 | * 52 | * @param $key 53 | * @return mixed 54 | */ 55 | public function isAttribute($key) 56 | { 57 | $key = $this->clearGetRawAttributeMutator($key); 58 | 59 | return $this->attributes->has($key); 60 | } 61 | 62 | /** 63 | * Get an attribute. 64 | * 65 | * @param $key 66 | * @return mixed 67 | */ 68 | public function getAttribute($key) 69 | { 70 | return $this->attributes->get($key); 71 | } 72 | 73 | /** 74 | * Read the content of an attribute. 75 | * 76 | * @param $key 77 | * @return mixed|void 78 | */ 79 | public function get($key) 80 | { 81 | if ($this->isGetRawAttributeMutator($key)) { 82 | return $this->getRawContent($key); 83 | } 84 | 85 | return $this->getContent($key); 86 | } 87 | 88 | /** 89 | * Get the content of the given attribute. 90 | * 91 | * @param $key 92 | * @return null 93 | */ 94 | protected function getContent($key) 95 | { 96 | $value = $this->getRawContent($key); 97 | 98 | // In case we are accessing to a multivalued attribute, we will return 99 | // a collection with pairs of id and value content. Otherwise we'll 100 | // just return the single model value content as a plain result. 101 | if ($this->getAttribute($key)->isCollection()) { 102 | return $value->pluck('content'); 103 | } 104 | 105 | return ! is_null($value) ? $value->getContent() : null; 106 | } 107 | 108 | /** 109 | * Get the raw content of the attribute (raw relationship). 110 | * 111 | * @param $key 112 | * @return mixed 113 | */ 114 | protected function getRawContent($key) 115 | { 116 | $key = $this->clearGetRawAttributeMutator($key); 117 | 118 | if ($this->entity->relationLoaded($key)) { 119 | return $this->entity->getRelation($key); 120 | } 121 | 122 | return $this->entity->getRelationValue($key); 123 | } 124 | 125 | /** 126 | * Set the content of the given attribute. 127 | * 128 | * @param $key 129 | * @param $value 130 | * @return $this|mixed 131 | */ 132 | public function set($key, $value) 133 | { 134 | $current = $this->getRawContent($key); 135 | $attribute = $this->getAttribute($key); 136 | 137 | // $current will always contain a collection when an attribute is multivalued 138 | // as morphMany provides collections even if no values were matched, making 139 | // us assume at least an empty collection object will be always provided. 140 | if ($attribute->isCollection()) { 141 | if (is_null($current)) { 142 | $this->entity->setRelation($key, $current = new Collection); 143 | } 144 | 145 | $current->replace($value); 146 | 147 | return $this; 148 | } 149 | 150 | // If the attribute to set is a collection, it will be replaced by the 151 | // new value. If the value model does not exist, we will just create 152 | // and set a new value model, otherwise its value will get updated. 153 | if (is_null($current)) { 154 | return $this->setContent($attribute, $value); 155 | } 156 | 157 | return $this->updateContent($current, $value); 158 | } 159 | 160 | /** 161 | * Set the content of an unexisting value. 162 | * 163 | * @param $attribute 164 | * @param $value 165 | * @return mixed 166 | */ 167 | protected function setContent($attribute, $value) 168 | { 169 | if (! $value instanceof Value) { 170 | $value = $this->builder->build($this->entity, $attribute, $value); 171 | } 172 | 173 | return $this->entity->setRelation( 174 | $attribute->getName(), $value 175 | ); 176 | } 177 | 178 | /** 179 | * Update the content of an existing value. 180 | * 181 | * @param $current 182 | * @param $new 183 | * @return mixed 184 | */ 185 | protected function updateContent($current, $new) 186 | { 187 | if ($new instanceof Value) { 188 | $new = $new->getContent(); 189 | } 190 | 191 | return $current->setContent($new); 192 | } 193 | 194 | /** 195 | * Determine if a get mutator exists for an attribute. 196 | * 197 | * @param string $key 198 | * @return bool 199 | */ 200 | protected function isGetRawAttributeMutator($key) 201 | { 202 | return (bool) preg_match('/^raw(\w+)object$/i', $key); 203 | } 204 | 205 | /** 206 | * Remove any mutator prefix and suffix. 207 | * 208 | * @param $key 209 | * @return mixed 210 | */ 211 | protected function clearGetRawAttributeMutator($key) 212 | { 213 | return $this->isGetRawAttributeMutator($key) ? 214 | Str::camel(str_ireplace(['raw', 'object'], ['', ''], $key)) : $key; 215 | } 216 | } 217 | 218 | // TODO: add schema column check here. 219 | -------------------------------------------------------------------------------- /tests/integration/EavquentTestTrait.php: -------------------------------------------------------------------------------- 1 | assertEquals('colors', $company->rawColorsObject->getAttribute()->name); 20 | $this->assertEquals($company, $company->rawColorsObject->getEntity()); 21 | } 22 | 23 | /** @test */ 24 | public function collections_are_linked_to_entity_and_attribute_when_eager_load() 25 | { 26 | $company = Company::with('eav')->first(); 27 | 28 | $this->assertEquals('colors', $company->rawColorsObject->getAttribute()->name); 29 | $this->assertEquals($company, $company->rawColorsObject->getEntity()); 30 | } 31 | 32 | /** @test */ 33 | public function load_all_attributes_registered_for_an_entity() 34 | { 35 | $company = Company::with('eav')->first(); 36 | 37 | $this->assertTrue($company->relationLoaded('colors')); 38 | $this->assertTrue($company->relationLoaded('city')); 39 | } 40 | 41 | /** @test */ 42 | public function eagerload_all_attributes_from_withs_model_property() 43 | { 44 | $model = CompanyWithEavStub::first(); 45 | 46 | $this->assertTrue($model->relationLoaded('colors')); 47 | $this->assertTrue($model->relationLoaded('city')); 48 | } 49 | 50 | /** @test */ 51 | public function eagerload_attributes_from_withs_model_property() 52 | { 53 | $model = CompanyWithCityStub::first(); 54 | 55 | $this->assertFalse($model->relationLoaded('colors')); 56 | $this->assertTrue($model->relationLoaded('city')); 57 | } 58 | 59 | /** @test */ 60 | public function load_only_certain_attributes_for_an_entity() 61 | { 62 | $company = Company::with('city')->first(); 63 | 64 | $this->assertFalse($company->relationLoaded('colors')); 65 | $this->assertTrue($company->relationLoaded('city')); 66 | } 67 | 68 | /** @test */ 69 | public function get_the_content_of_an_attribute() 70 | { 71 | $company = Company::with('eav')->first(); 72 | 73 | $this->assertInternalType('string', $company->city); 74 | $this->assertInstanceOf(\Illuminate\Support\Collection::class, $company->colors); 75 | $this->assertInstanceOf(\Illuminate\Support\Collection::class, $company->sizes); 76 | $this->assertNull($company->address); 77 | $this->assertCount(0, $company->sizes); 78 | } 79 | 80 | /** @test */ 81 | public function get_the_raw_relation_value() 82 | { 83 | $company = Company::with('eav')->first(); 84 | 85 | $this->assertInstanceOf(Varchar::class, $company->rawCityObject); 86 | } 87 | 88 | /** @test */ 89 | public function attributes_are_included_in_array_as_keys() 90 | { 91 | $company = Company::with('eav')->first()->toArray(); 92 | 93 | $this->assertArrayHasKey('city', $company); 94 | $this->assertArrayHasKey('colors', $company); 95 | $this->assertArrayHasKey('address', $company); 96 | $this->assertArrayHasKey('sizes', $company); 97 | } 98 | 99 | /** @test */ 100 | public function value_collections_are_eavquent_collections() 101 | { 102 | $company = Company::with('eav')->first(); 103 | 104 | $this->assertInstanceOf(\Devio\Eavquent\Value\Collection::class, $company->rawColorsObject); 105 | } 106 | 107 | /** @test */ 108 | public function collections_are_linked_to_entity_and_attribute() 109 | { 110 | $company = Company::with('eav')->first(); 111 | 112 | $attribute = $company->getEntityAttributes()['colors']; 113 | 114 | $this->assertEquals($company, $company->rawColorsObject->getEntity()); 115 | $this->assertEquals($attribute, $company->rawColorsObject->getAttribute()); 116 | } 117 | 118 | /** @test */ 119 | public function updating_content_of_existing_simple_values() 120 | { 121 | $company = Company::with('eav')->first(); 122 | $company->city = 'foo'; 123 | 124 | $this->assertEquals('foo', $company->city); 125 | $this->assertEquals(1, $company->rawCityObject->getKey()); 126 | } 127 | 128 | /** @test */ 129 | public function setting_content_of_unexisting_simple_value() 130 | { 131 | $company = Company::with('eav')->first(); 132 | 133 | $this->assertNull($company->rawAddressObject); 134 | $company->address = 'foo'; 135 | 136 | $value = $company->rawAddressObject; 137 | 138 | $this->assertEquals('foo', $company->address); 139 | $this->assertNull($value->getKey()); 140 | $this->assertInstanceOf(Varchar::class, $value); 141 | } 142 | 143 | /** @test */ 144 | public function replacing_content_of_existing_collection_value() 145 | { 146 | $company = Company::with('eav')->first(); 147 | 148 | $company->colors = ['foo', 'bar']; 149 | 150 | $this->assertCount(2, $company->colors); 151 | $this->assertEquals('foo', $company->colors[0]); 152 | $this->assertEquals('bar', $company->colors[1]); 153 | } 154 | 155 | /** @test */ 156 | public function saving_updated_content_of_existing_value() 157 | { 158 | $company = Company::with('eav')->first(); 159 | $company->city = 'foo'; 160 | $company->save(); 161 | 162 | $company = Company::with('eav')->first(); 163 | $this->assertEquals('foo', $company->city); 164 | } 165 | 166 | /** @test */ 167 | public function saving_setting_content_of_unexisting_value() 168 | { 169 | $company = Company::with('eav')->first(); 170 | $company->address = 'foo'; 171 | $company->save(); 172 | 173 | $company = Company::with('eav')->first(); 174 | $this->assertEquals('foo', $company->address); 175 | } 176 | 177 | /** @test */ 178 | public function saving_replacing_content_of_existing_collection_value() 179 | { 180 | $company = Company::with('eav')->first(); 181 | $company->colors = ['foo', 'bar']; 182 | $company->save(); 183 | 184 | $company = Company::with('eav')->first(); 185 | $this->assertCount(2, $company->colors); 186 | $this->assertEquals('foo', $company->colors[0]); 187 | $this->assertEquals('bar', $company->colors[1]); 188 | } 189 | 190 | /** @test */ 191 | public function deleting_values_when_replacing_collection() 192 | { 193 | $company = Company::with('eav')->first(); 194 | $color1 = $company->rawColorsObject[0]; 195 | $color2 = $company->rawColorsObject[1]; 196 | 197 | $company->colors = []; 198 | $company->save(); 199 | 200 | $this->dontSeeInDatabase('eav_values_varchar', ['id' => $color1->getKey()]); 201 | $this->dontSeeInDatabase('eav_values_varchar', ['id' => $color2->getKey()]); 202 | $this->assertCount(0, $company->getRelationValue('colors')); 203 | } 204 | 205 | /** @test */ 206 | public function deleting_value_when_setting_null() 207 | { 208 | $company = Company::with('eav')->first(); 209 | $city = $company->rawCityObject; 210 | 211 | $company->city = null; 212 | $company->save(); 213 | 214 | $this->dontSeeInDatabase('eav_values_varchar', ['id' => $city->getKey()]); 215 | $this->assertNull($company->getRelationValue('city')); 216 | } 217 | 218 | /** @test */ 219 | public function converting_plain_attributes_to_array_json() 220 | { 221 | $company = Company::with('eav')->first(); 222 | 223 | $result = $company->toArray(); 224 | 225 | $this->assertFalse(array_key_exists('data', $result)); 226 | $this->assertTrue(is_string($result['city'])); 227 | $this->assertTrue(is_string($result['colors'][0])); 228 | $this->assertTrue(is_string($result['colors'][1])); 229 | } 230 | 231 | /** @test */ 232 | public function converting_raw_attributes_to_array_json() 233 | { 234 | $company = CompanyWithRawRelationsStub::with('eav')->first(); 235 | 236 | $result = $company->toArray(); 237 | 238 | $this->assertFalse(array_key_exists('data', $result)); 239 | $this->assertTrue(array_key_exists('id', $result['city'])); 240 | $this->assertTrue(array_key_exists('id', $result['colors'][0])); 241 | $this->assertTrue(array_key_exists('id', $result['colors'][1])); 242 | } 243 | 244 | /** @test */ 245 | public function create_entity_with_values() 246 | { 247 | $company = new Company; 248 | 249 | $company->name = 'fooCompany'; 250 | $company->city = 'foo'; 251 | $company->colors = ['bar', 'baz']; 252 | 253 | $company->save(); 254 | 255 | $this->seeInDatabase('companies', ['name' => 'fooCompany']); 256 | $this->seeInDatabase('eav_values_varchar', ['entity_id' => $company->getKey(), 'content' => 'foo']); 257 | $this->seeInDatabase('eav_values_varchar', ['entity_id' => $company->getKey(), 'content' => 'bar']); 258 | $this->seeInDatabase('eav_values_varchar', ['entity_id' => $company->getKey(), 'content' => 'baz']); 259 | } 260 | } 261 | 262 | class CompanyWithRawRelationsStub extends Company 263 | { 264 | public $table = 'companies'; 265 | 266 | public $morphClass = 'Company'; 267 | 268 | protected $rawAttributeRelations = true; 269 | } 270 | 271 | class CompanyWithEavStub extends Company 272 | { 273 | public $table = 'companies'; 274 | 275 | public $morphClass = 'Company'; 276 | 277 | protected $with = ['eav']; 278 | } 279 | 280 | class CompanyWithCityStub extends Company 281 | { 282 | public $table = 'companies'; 283 | 284 | public $morphClass = 'Company'; 285 | 286 | protected $with = ['city']; 287 | } 288 | -------------------------------------------------------------------------------- /src/Eavquent.php: -------------------------------------------------------------------------------- 1 | getAttributeManager(); 64 | 65 | $attributes = $manager->get($instance->getMorphClass()); 66 | static::$entityAttributes = $attributes->keyBy('name'); 67 | 68 | static::addGlobalScope(new EagerLoadScope); 69 | 70 | static::saved(EntityWasSaved::class . '@handle'); 71 | static::deleted(EntityWasDeleted::class . '@handle'); 72 | } 73 | 74 | /** 75 | * Booting the registered attributes as relations. 76 | */ 77 | public function bootEavquentIfNotBooted() 78 | { 79 | if (! $this->attributeRelationsBooted) { 80 | $this->trash = new Trash; 81 | 82 | $this->getRelationBuilder()->build($this); 83 | 84 | $this->attributeRelationsBooted = true; 85 | } 86 | } 87 | 88 | /** 89 | * Creates a new instance and rebinds relations. 90 | * 91 | * @param array $attributes 92 | * @param bool $exists 93 | * @return mixed 94 | */ 95 | public function newInstance($attributes = [], $exists = false) 96 | { 97 | $model = parent::newInstance($attributes, $exists); 98 | 99 | $model->bootEavquentIfNotBooted(); 100 | 101 | return $model; 102 | } 103 | 104 | /** 105 | * Adding eav attributes to relations array. 106 | * 107 | * @return mixed 108 | */ 109 | public function relationsToArray() 110 | { 111 | $eavAttributes = []; 112 | $attributes = parent::relationsToArray(); 113 | $relations = array_keys($this->getAttributeRelations()); 114 | 115 | foreach ($relations as $relation) { 116 | if (! array_key_exists($relation, $attributes)) { 117 | continue; 118 | } 119 | 120 | // Otherwise, if the relation was loaded, we will assume the user wants 121 | // to include it even if value corresponds to an empty collection or 122 | // null. The Interactor will be responsible for fetching the value. 123 | $value = $this->rawAttributeRelations() ? 124 | $attributes[$relation] : $this->getAttribute($relation); 125 | 126 | $eavAttributes[$relation] = $value; 127 | 128 | // By unsetting the relation from the attributes array we will make 129 | // sure we do not provide a duplicity when adding the namespace. 130 | // Otherwise it would keep the relation as a key in the root. 131 | unset($attributes[$relation]); 132 | } 133 | 134 | return $this->namespacedAttributes($attributes, $eavAttributes); 135 | } 136 | 137 | /** 138 | * Add namespace when converting to array if any. 139 | * 140 | * @param $original 141 | * @param $added 142 | * @return mixed 143 | */ 144 | protected function namespacedAttributes($original, $added) 145 | { 146 | if (is_null($ns = $this->getAttributesNamespace())) { 147 | $original = array_merge($original, $added); 148 | } else { 149 | Arr::set($original, $ns, $added); 150 | } 151 | 152 | return $original; 153 | } 154 | 155 | /** 156 | * Overwrite to link before setting when eager loading. 157 | * 158 | * @param $key 159 | * @param $value 160 | * @return mixed 161 | */ 162 | public function setRelation($key, $value) 163 | { 164 | if (! is_null($value) && ($value instanceof Collection)) { 165 | $this->linkRelationCollection($key, $value); 166 | } 167 | 168 | return parent::setRelation($key, $value); 169 | } 170 | 171 | /** 172 | * Get the relation value from attribute relations. 173 | * 174 | * @param $key 175 | * @return mixed 176 | */ 177 | public function getRelationValue($key) 178 | { 179 | $result = parent::getRelationValue($key); 180 | 181 | // In case any relation value is found, we will just provide it as is. 182 | // Otherwise, we will check if exists any attribute relation for the 183 | // given key. If so, we will load the relation calling its method. 184 | if (is_null($result) && ! $this->relationLoaded($key) && $this->isAttributeRelation($key)) { 185 | $result = $this->getRelationshipFromMethod($key); 186 | } 187 | 188 | // When doing lazy loading, setRelation method is not triggered. Eloquent 189 | // just sets the result of the relation to the key into the $relations 190 | // array so we have to link the relation value collection here again. 191 | if (! is_null($result) && $result instanceof Collection) { 192 | $this->linkRelationCollection($key, $result); 193 | } 194 | 195 | // TODO: This could be removed when setRelation PR gets released. 196 | 197 | return $result; 198 | } 199 | 200 | /** 201 | * Links a Value collection to the current entity and attribute. 202 | * 203 | * @param $key 204 | * @param Collection $value 205 | */ 206 | protected function linkRelationCollection($key, Collection $value) 207 | { 208 | $attributes = $this->getEntityAttributes(); 209 | $value->link($this, $attributes->get($key)); 210 | } 211 | 212 | /** 213 | * Get the namespace for attributes when converting to array. 214 | * 215 | * @return null 216 | */ 217 | public function getAttributesNamespace() 218 | { 219 | return property_exists($this, 'attributesNamespace') ? 220 | $this->attributesNamespace : null; 221 | } 222 | 223 | /** 224 | * Check if relations should be included as raw objects when converting to array. 225 | * 226 | * @return bool 227 | */ 228 | public function rawAttributeRelations() 229 | { 230 | return property_exists($this, 'rawAttributeRelations') ? 231 | $this->rawAttributeRelations : false; 232 | } 233 | 234 | /** 235 | * Check for auto pushing. 236 | * 237 | * @return bool 238 | */ 239 | public function autoPushEnabled() 240 | { 241 | return property_exists($this, 'autoPush') ? $this->autoPush : true; 242 | } 243 | 244 | /** 245 | * Enable auto pushing. 246 | * 247 | * @return bool 248 | */ 249 | public function enableAutoPush() 250 | { 251 | $this->autoPush = true; 252 | 253 | return $this; 254 | } 255 | 256 | /** 257 | * Disable auto pushing. 258 | * 259 | * @return bool 260 | */ 261 | public function disableAutoPush() 262 | { 263 | $this->autoPush = false; 264 | 265 | return $this; 266 | } 267 | 268 | /** 269 | * Get the entity attributes. 270 | * 271 | * @return mixed 272 | */ 273 | public function getEntityAttributes() 274 | { 275 | return static::$entityAttributes; 276 | } 277 | 278 | /** 279 | * Set an attribute. 280 | * 281 | * @param $key 282 | * @param $value 283 | * @return $this|mixed 284 | */ 285 | public function setAttribute($key, $value) 286 | { 287 | $interactor = $this->getInteractor(); 288 | 289 | return $interactor->isAttribute($key) ? 290 | $interactor->set($key, $value) : parent::setAttribute($key, $value); 291 | } 292 | 293 | /** 294 | * Get an attribute. 295 | * 296 | * @param $key 297 | * @return mixed 298 | */ 299 | public function getAttribute($key) 300 | { 301 | $interactor = $this->getInteractor(); 302 | 303 | return $interactor->isAttribute($key) ? 304 | $interactor->get($key) : parent::getAttribute($key); 305 | } 306 | 307 | /** 308 | * Get the interactor instance. 309 | * 310 | * @return Interactor 311 | */ 312 | public function getInteractor() 313 | { 314 | return $this->interactor = $this->interactor 315 | ?: $this->getContainer()->make(Interactor::class, [$this]); 316 | } 317 | 318 | /** 319 | * Set an attribute relation. 320 | * 321 | * @param $relation 322 | * @param $value 323 | * @return $this 324 | */ 325 | public function setAttributeRelation($relation, $value) 326 | { 327 | $this->attributeRelations[$relation] = $value; 328 | 329 | return $this; 330 | } 331 | 332 | /** 333 | * Check if key is an attribute relation. 334 | * 335 | * @param $key 336 | * @return bool 337 | */ 338 | public function isAttributeRelation($key) 339 | { 340 | return isset($this->attributeRelations[$key]); 341 | } 342 | 343 | /** 344 | * Get the attribute relations. 345 | * 346 | * @return array 347 | */ 348 | public function getAttributeRelations() 349 | { 350 | $this->bootEavquentIfNotBooted(); 351 | 352 | return $this->attributeRelations; 353 | } 354 | 355 | /** 356 | * Get the attribtue manager instance. 357 | * 358 | * @return Manager 359 | */ 360 | public function getAttributeManager() 361 | { 362 | return $this->getContainer()->make(Manager::class); 363 | } 364 | 365 | /** 366 | * Get the relation loader instance. 367 | * 368 | * @return RelationLoader 369 | */ 370 | public function getRelationBuilder() 371 | { 372 | return $this->getContainer()->make(RelationBuilder::class); 373 | } 374 | 375 | /** 376 | * Set the container instance. 377 | * 378 | * @param Container $container 379 | */ 380 | public function setContainer(Container $container) 381 | { 382 | $this->container = $container; 383 | } 384 | 385 | /** 386 | * Get the container instance. 387 | * 388 | * @return Container 389 | */ 390 | public function getContainer() 391 | { 392 | return $this->container = $this->container ?: \Illuminate\Container\Container::getInstance(); 393 | } 394 | 395 | /** 396 | * Get the trash instance. 397 | * 398 | * @return Trash 399 | */ 400 | public function getTrash() 401 | { 402 | return $this->trash; 403 | } 404 | 405 | /** 406 | * Check for attribute relations boot. 407 | * 408 | * @return bool 409 | */ 410 | public function isAttributeRelationsBooted() 411 | { 412 | return $this->attributeRelationsBooted; 413 | } 414 | 415 | /** 416 | * Check for forcing deletion when using soft deleting. 417 | * 418 | * @return bool 419 | */ 420 | public function isForceDeleting() 421 | { 422 | return property_exists($this, 'forceDeleting') ? 423 | $this->forceDeleting : false; 424 | } 425 | 426 | /** 427 | * Dynamically pipe calls to attribute relations. 428 | * 429 | * @param string $method 430 | * @param array $parameters 431 | * @return mixed 432 | */ 433 | public function __call($method, $parameters) 434 | { 435 | $this->bootEavquentIfNotBooted(); 436 | 437 | if ($this->isAttributeRelation($method)) { 438 | return call_user_func_array($this->attributeRelations[$method], $parameters); 439 | } 440 | 441 | return parent::__call($method, $parameters); 442 | } 443 | } 444 | --------------------------------------------------------------------------------