Repository: darrylkuhn/dialect Branch: master Commit: 8a53af697c5a Files: 13 Total size: 28.7 KB Directory structure: gitextract_zy_74zq0/ ├── .gitignore ├── .travis.yml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── composer.json ├── configureComposer.sh ├── phpunit.xml.dist ├── src/ │ └── Dialect/ │ ├── InvalidJsonException.php │ └── Json.php └── tests/ ├── JsonDialectTest.php ├── bootstrap.php └── src/ └── MockJsonDialectModel.php ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ /bootstrap/compiled.php /vendor /build ================================================ FILE: .travis.yml ================================================ before_script: - ./configureComposer.sh - composer self-update - composer install --prefer-dist --no-interaction php: - 5.5 - 5.6 - 7.0 - hhvm language: php after_script: CODECLIMATE_REPO_TOKEN=a8b5ee59cd6658ea5710614ec4285cdc81084aeee33f531bd30d1e317c88257c ./vendor/bin/test-reporter script: ./vendor/bin/phpunit --coverage-clover build/logs/clover.xml env: global: secure: uuQ9h1nIwseYMpOBV+j+kOqgD/mPkfMk411b/jxvM0c0u4b9E2q7aep238IDUxqDLFBV4QwGWJobgSBdu/bpm0GQC29CYHhgw+DVceCZ6VlcStTwVvX+XfyBBOVn/KTbEZQapC1lsGneBEzKzp9esUqYW3uOjJPeA9ah20zjV2c= matrix: allow_failures: - php: hhvm ================================================ FILE: CONTRIBUTING.md ================================================ Contributing ============ First off - **you rock!**, thanks you so much for taking to time and energy to make this project better! A healthy community is filled with different coders with differnt coding styles. This can cause cognative friction. Here are a few rules to follow in order to minimize that friction and ease code reviews and discussions before maintainers accept and merge your work. You MUST follow the [PSR-1](http://www.php-fig.org/psr/1/) and [PSR-2](http://www.php-fig.org/psr/2/). Use [PHP-CS-Fixer](http://cs.sensiolabs.org/) to make this task easier if you are unfamiliar with these stantards. Additionally you: # MUST run the test suite. # MUST write (or update) unit tests. # SHOULD write documentation. Please, write [commit messages that make sense](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html), and [rebase your branch](http://git-scm.com/book/en/Git-Branching-Rebasing) before submitting your Pull Request. You may be asked to [squash your commits](http://gitready.com/advanced/2009/02/10/squashing-commits-with-rebase.html) too. This is used to "clean" your Pull Request before merging it (we don't want commits such as `fix tests`, `fix 2`, `fix 3`, etc). Let's do this! ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2015 Darryl Kuhn Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Dialect [![Build Status](https://travis-ci.org/darrylkuhn/dialect.svg?branch=master)](https://travis-ci.org/darrylkuhn/dialect) [![Code Climate](https://codeclimate.com/github/darrylkuhn/dialect/badges/gpa.svg)](https://codeclimate.com/github/darrylkuhn/dialect) [![Test Coverage](https://codeclimate.com/github/darrylkuhn/dialect/badges/coverage.svg)](https://codeclimate.com/github/darrylkuhn/dialect) Dialect provides JSON datatype support for the [Eloquent ORM](http://laravel.com/docs/eloquent). At this point this implementation is pretty bare bones and has been demonstrated to work with PostgreSQL and MySQL. There are lots of opportunities to enhance and improve. If you're interested in contributing please submit merge/pull requests. ## Installation Require this package in your `composer.json` file: `"darrylkuhn/dialect": "dev-master"` ...then run `composer update` to download the package to your vendor directory. ## Usage ### The Basics The feature is exposed through a trait called which allows you to define attributes on the model which are of the json datatype. When the model is read in it will parse the JSON document and set up getters and setters for each top level attribute making it easy to interact with the various attributes within the document. For example we could create a Photos model like this: ```php class Photo extends Eloquent { use Eloquent\Dialect\Json; protected $jsonColumns = ['json_data']; } ``` And then this: ```php $attr = json_decode($photo->json_data); $attr->key = $value; $photo->json_data = json_encode($attr); ``` becomes this: ```php $photo->key = value; ``` Also when calling the toArray() method the attributes are moved to the top level and the 'json_attributes' column is hidden. This essentially hides away the fact that you're using the json datatype and makes it look like we're working with attributes directly. ### Relations You can also establish relationships on a model like this (only supported in PostgreSQL): ```php public function user() { return $this->hasOne( 'User', 'id', "json_data->>'user_id'" ); } ``` ### Structure Hinting Sometimes you may have an empty or partially populated record in which case the trait cannot automatically detect and create getters/setters, etc... When getting or setting an attribute not previously set in the JSON document you'll get an exception. You have two choices to deal with this. You can hint at the full structure as in the example below: ```php class Photo extends Eloquent { use Eloquent\Dialect\Json; protected $jsonColumns = ['json_data']; public function __construct() { parent::__construct(); $this->hintJsonStructure( 'json_data', '{"foo":null}' ); } } ``` Once you create a hint you will be able to make calls to get and set json attributes e.g. `$photo->foo = 'bar';` regardless of whether or not they are already defined in the underlying db record. Alternatly if you prefer not to hint structures then you may call `setJsonAttribute()`. For example if you defined a json column called "json_data" and wanted to set an attribute called 'fizz' so you could call: ```php $photo->setJsonAttribute( 'json_data', 'fizz', 'buzz' ); ``` ### Showing/Hiding Attributes One of the aims of the project is to make json attributes "first class" citizens of the model. This means by default we add the attributes to the models appends array so that when you call `$model->toArray()` or `$model->toJson()` the attribute shows up as a part of the structure like a normal attribute. By default we also hide away the json column holding the underlying data. Both of these settings can be changed using the `showJsonColumns()` and `showJsonAttributes()` as shown below: ```php class Photo extends Eloquent { use Eloquent\Dialect\Json; protected $jsonColumns = ['json_data']; public function __construct() { parent::__construct(); $this->showJsonColumns(true); $this->showJsonAttributes(false); } } ``` ================================================ FILE: composer.json ================================================ { "name": "darrylkuhn/dialect", "description": "Provides JSON datatype support for the Eloquent ORM", "keywords": ["laravel","illuminate","eloquent","json","schemaless","schema-free"], "license": "MIT", "authors": [ { "name": "Darryl Kuhn", "email": "darryl.kuhn@gmail.com" } ], "require": { "php": ">=5.4.0", "illuminate/support": "4.*|5.*|6.*|7.*" }, "require-dev": { "phpunit/phpunit": "4.*", "illuminate/database": "4.*|5.*|6.*|7.*", "codeclimate/php-test-reporter": "dev-master" }, "autoload": { "psr-4": { "Eloquent\\Dialect\\": "src/Dialect/" } } } ================================================ FILE: configureComposer.sh ================================================ if [ "$TRAVIS_SECURE_ENV_VARS" = "true" ]; then mkdir ~/.composer/ echo '{ "config": {"github-oauth":{"github.com": ' > ~/.composer/config.json echo "\"$GH_OAUTH\"" >> ~/.composer/config.json echo '}}}' >> ~/.composer/config.json fi ================================================ FILE: phpunit.xml.dist ================================================ ./tests/ . tests vendor ================================================ FILE: src/Dialect/InvalidJsonException.php ================================================ >'value'). * * @var array */ public static $jsonOperators = [ '->', '->>', '#>', '#>>', ]; /** * Holds the map of attributes and the JSON colums they are stored in. This * will take the form of: * [ 'json_element_1' => 'original_column', * 'json_element_2' => 'original_column' ]. * * @var array */ private $jsonAttributes = []; /** * Holds a list of column names and the structure they *may* contain (e.g. * ['json_column' => "{'foo':null}"]. * * @var array */ private $hintedJsonAttributes = []; /** * By default this trait will hide the json columns when rendering the * model using toArray() or toJson() only exposing the underlying JSON * parameters as top level paremters on the model. Set this parameter to * true if you want to change that behavior. * * @var bool */ private $showJsonColumns = false; /** * By default this trait will append the json attributes when rendering the * model using toArray() or toJson(). Set this parameter to false if you * want to change that behavior. * * @var bool */ private $showJsonAttributes = true; /** * Create a new model instance that is existing. * Overrides parent to set Json columns. * * @param array $attributes * @param string|null $connection * * @return static */ public function newFromBuilder($attributes = array(), $connection = null) { $model = parent::newFromBuilder($attributes, $connection); $model->inspectJsonColumns(); $model->addHintedAttributes(); return $model; } /** * Decodes each of the declared JSON attributes and records the attributes * on each. */ public function inspectJsonColumns() { foreach ($this->jsonColumns as $col) { if (!$this->showJsonColumns) { $this->hidden[] = $col; } if(array_key_exists($col, $this->attributes)) { $obj = json_decode($this->attributes[$col]); } else { $obj = json_decode($this->$col); } if (is_object($obj)) { foreach ($obj as $key => $value) { $this->flagJsonAttribute($key, $col); if ($this->showJsonAttributes) { $this->appends[] = $key; } } } } } /** * Schema free data architecture give us tons of flexibility (yay) but * makes it hard to inspect a structure and build getters/setters. * Therefore you can "hint" the structure to make life easier. */ public function addHintedAttributes() { foreach ($this->hintedJsonAttributes as $col => $structure) { if (!$this->showJsonColumns) { $this->hidden[] = $col; } if (json_decode($structure) === null) { throw new InvalidJsonException(); } $obj = json_decode($structure); if (is_object($obj)) { foreach ($obj as $key => $value) { $this->flagJsonAttribute($key, $col); if ($this->showJsonAttributes) { $this->appends[] = $key; } } } } } /** * Sets a hint for a given column. * * @param string $column name of column that we're hinting * @param string $structure json encoded structure * * @throws InvalidJsonException */ public function hintJsonStructure($column, $structure) { if (json_decode($structure) === null) { throw new InvalidJsonException(); } $this->hintedJsonAttributes[$column] = $structure; // Run the call to add hinted attributes to the internal json // attributes array. This allows callers to get/set parameters when // working with new models $this->addHintedAttributes(); } /** * Record that a given JSON element is found on a particular column. * * @param string $key attribute name within the JSON column * @param string $col name of JSON column */ public function flagJsonAttribute($key, $col) { $this->jsonAttributes[$key] = $col; } /** * Include JSON column in the list of attributes that have a get mutator. * * @param string $key * * @return bool */ public function hasGetMutator($key) { $jsonPattern = '/'.implode('|', self::$jsonOperators).'/'; if (array_key_exists($key, $this->jsonAttributes) !== false) { return true; } // In some cases the key specified may not be a simple key but rather a // JSON expression (e.g. "jsonField->'some_key'). A common case would // be when specifying a relation key. As such we test for JSON // operators and expect a mutator if this is a JSON expression elseif (preg_match($jsonPattern, $key) != false) { return true; } return parent::hasGetMutator($key); } /** * Include the JSON attributes in the list of mutated attributes for a * given instance. * * @return array */ public function getMutatedAttributes() { $attributes = parent::getMutatedAttributes(); $jsonAttributes = array_keys($this->jsonAttributes); return array_merge($attributes, $jsonAttributes); } /** * Check if the key is a known json attribute and return that value. * * @param string $key * @param mixed $value * * @return mixed * * @throws InvalidJsonException */ protected function mutateAttribute($key, $value) { $jsonPattern = '/'.implode('|', self::$jsonOperators).'/'; // Test for JSON operators and reduce to end element $containsJsonOperator = false; if (preg_match($jsonPattern, $key)) { $elems = preg_split($jsonPattern, $key); $key = end($elems); $key = str_replace(['>', "'"], '', $key); $containsJsonOperator = true; } if (!parent::hasGetMutator($key) && array_key_exists($key, $this->jsonAttributes) != false) { // Get the content of the column associated with this JSON // attribute and parse it into an object $value = $this->{$this->jsonAttributes[$key]}; $obj = json_decode($this->{$this->jsonAttributes[$key]}); // Make sure we were able to parse the json. It's possible here // that we've only hinted at an attribute and the column that will // hold that attribute is actually null. This isn't really a parse // error though the json_encode method will return null (just like) // a parse error. To distinguish the two states see if the original // value was null (indicating there was nothing there to parse in // the first place) if ( !($value === 'null' || $value === null) && $obj === null ) { throw new InvalidJsonException(); } // Again it's possible the key will be in the jsonAttributes array // (having been hinted) but not present on the actual record. // Therefore test that the key is set before returning. if (isset($obj->$key)) { return $obj->$key; } else { return; } } elseif ($containsJsonOperator) { return; } return parent::mutateAttribute($key, $value); } /** * Set a given attribute on the known JSON elements. * * @param string $key * @param mixed $value */ public function setAttribute($key, $value) { if (array_key_exists($key, $this->jsonAttributes) !== false && !parent::hasSetMutator($key)) { $this->setJsonAttribute($this->jsonAttributes[$key], $key, $value); return; } parent::setAttribute($key, $value); } /** * Set a given attribute on the known JSON elements. * * @param string $attribute * @param string $key * @param mixed $value */ public function setJsonAttribute($attribute, $key, $value) { // Pull the attribute and decode it $decoded = json_decode($this->{$attribute}); switch (gettype($decoded)) { // It's possible the attribute doesn't exist yet (since we can hint at // structure). In that case we build an object to set values on as a // starting point case 'NULL': $decoded = json_decode('{}'); $decoded->$key = $value; break; case 'array': $decoded[$key] = $value; break; default: $decoded->$key = $value; break; } $this->flagJsonAttribute($key, $attribute); $this->{$attribute} = json_encode($decoded); return; } /** * Add json attributes to the list of things that have changed (when * they've changed). * * @return array */ public function getDirty($includeJson = false) { $dirty = parent::getDirty(); if (!$includeJson) { return $dirty; } foreach (array_unique($this->jsonAttributes) as $attribute) { $originals[$attribute] = json_decode(array_get($this->original, $attribute, 'null'), true); } foreach ($this->jsonAttributes as $jsonAttribute => $jsonColumn) { if ($this->$jsonAttribute !== null && $this->$jsonAttribute !== array_get($originals[$jsonColumn], $jsonAttribute)) { $dirty[$jsonAttribute] = json_encode($this->$jsonAttribute); } } return $dirty; } /** * Allows you to specify if the actual JSON column housing the attributes * should be shown on toArray() and toJson() calls. Set this value in the * models constructor (to make sure it is set before newFromBuilder() is * called). This is false by default. * * @param bool $show * * @return bool */ public function showJsonColumns($show) { return $this->showJsonColumns = $show; } /** * Allows you to specify if the attributes within various json columns * should be shown on toArray() and toJson() calls. Set this value in the * models constructor (to make sure it is set before newFromBuilder() is * called). This is true by default. * * @param bool show * * @return bool */ public function showJsonAttributes($show) { return $this->showJsonAttributes = $show; } } ================================================ FILE: tests/JsonDialectTest.php ================================================ setJsonColumns(['testColumn']); $mock->setAttribute('testColumn', json_encode(['foo' => 'bar'])); // Execute the insepect call $mock->inspectJsonColumns(); // Assert that the column were properly parsed and various bits have // been set on the model $this->assertTrue($mock->hasGetMutator('foo')); $this->assertContains('foo', $mock->getMutatedAttributes()); $this->assertArrayNotHasKey('testColumn', $mock->toArray()); $this->assertArrayHasKey('foo', $mock->toArray()); $this->assertEquals($mock->foo, 'bar'); } /** * Assert that the json columns show up when configured to do so */ public function testDisableHiddenJsonColumns() { // Mock the model with data $mock = new MockJsonDialectModel; $mock->setJsonColumns(['testColumn']); $mock->setAttribute('testColumn', json_encode(['foo' => 'bar'])); $mock->showJsonColumns(true); // Execute the insepect call $mock->inspectJsonColumns(); // Assert that the testColumn shows up $this->assertArrayHasKey('testColumn', $mock->toArray()); } /** * Assert that the json attributes do not show up when configured to do so */ public function testEnableHiddenJsonAttributes() { // Mock the model with data $mock = new MockJsonDialectModel; $mock->setJsonColumns(['testColumn']); $mock->setAttribute('testColumn', json_encode(['foo' => 'bar'])); $mock->showJsonAttributes(false); // Execute the insepect call $mock->inspectJsonColumns(); // Assert that attribute isn't there $this->assertArrayNotHasKey('foo', $mock->toArray()); } /** * Assert that an exception is thrown when given invalid json as a * structure hint * * @expectedException Eloquent\Dialect\InvalidJsonException */ public function testInvalidJsonAttribute() { // Mock the model with data $mock = new MockJsonDialectModel; $mock->hintJsonStructure( 'testColumn', json_encode(['foo'=>null]) ); // Set testColumn to invalid json $mock->setAttribute('testColumn', '{'); // Try to access a property on invalid json - we should get an // exception $mock->foo; } /** * Assert that no exception is thrown when given null json */ public function testNullJsonAttribute() { // Mock the model with data $mock = new MockJsonDialectModel; $mock->hintJsonStructure( 'testColumn', json_encode(['foo'=>null]) ); // Set testColumn to 'null' $mock->setAttribute('testColumn', 'null'); $this->assertNull($mock->foo); // Set testColumn to null $mock->setAttribute('testColumn', null); $this->assertNull($mock->foo); } /** * Assert that JSON attributes can be set through mutators */ public function testSetAttribute() { // Mock the model with data $mock = new MockJsonDialectModel; $mock->setJsonColumns(['testColumn']); $mock->setAttribute('testColumn', json_encode(['foo' => 'bar'])); // Execute the insepect call $mock->inspectJsonColumns(); $mock->foo = 'baz'; $mock->setJsonAttribute('testColumn', 'fizz', 'buzz'); // Assert that the column were properly parsed and various bits have // been set on the model $this->assertEquals($mock->foo, 'baz'); $this->assertEquals($mock->fizz, 'buzz'); } /** * Assert that JSON array attributes can be set through mutators */ public function testSetArrayAttribute() { // Mock the model with data $mock = new MockJsonDialectModel; $mock->hintJsonStructure( 'testColumn', json_encode(['foo'=>null]) ); // Execute the hint call $mock->addHintedAttributes(); $mock->foo = ['bar','baz']; // Assert that the column were properly parsed and various bits have // been set on the model $this->assertEquals('array', gettype($mock->foo)); $this->assertEquals(2, count($mock->foo) ); $this->assertEquals('bar', $mock->foo[0] ); } /** * Assert that attributes with JSON operators are properly recognized as JSON * attributes */ public function testGetMutator() { // Mock the model with data $mock = new MockJsonDialectModel; $this->assertTrue($mock->hasGetMutator("testColumn->>'foo'")); } /** * Assert that defined JSON attributes are properly parsed and exposed * through mutators. */ public function testHintedJsonColumns() { // Mock the model with data $mock = new MockJsonDialectModel; $mock->hintJsonStructure( 'testColumn', json_encode(['foo'=>null]) ); // Execute the hint call $mock->addHintedAttributes(); // Assert that the column were properly parsed and various bits have // been set on the model $this->assertTrue($mock->hasGetMutator('foo')); $this->assertContains('foo', $mock->getMutatedAttributes()); $this->assertContains('testColumn', $mock->getHidden()); $this->assertNull( $mock->foo ); // Set a value for foo $mock->foo = 'bar'; // assert that the column has been set properly $this->assertEquals( 'bar', $mock->foo ); $this->assertEquals( $mock->testColumn, json_encode(['foo'=>'bar']) ); } /** * Assert that defined JSON attributes are returned in the getDirty() * response when expected. */ public function testGetDirtyJson() { // Mock the model with data $mock = new MockJsonDialectModel; $mock->hintJsonStructure( 'testColumn', json_encode(['foo'=>null]) ); // At this point 'foo' should not be dirty $this->assertArrayNotHasKey( 'foo', $mock->getDirty(true) ); $mock->setAttribute('testColumn', json_encode(['foo' => 'bar'])); // Now that 'foo' has been changed it should show up in the getDirty() // response $this->assertArrayHasKey( 'foo', $mock->getDirty(true) ); } /** * Assert that an exception is thrown when given invalid json as a * structure hint * * @expectedException Eloquent\Dialect\InvalidJsonException */ public function testInvalidHint() { // Mock the model with data $mock = new MockJsonDialectModel; $mock->hintJsonStructure( 'testColumn', '{' ); // Execute the hint call $mock->addHintedAttributes(); } /** * Test the ability to allow models to provide their own custom attribute * getters for json attributes */ public function testCustomGetter() { // Mock the model with data $mock = new MockJsonDialectModel; $mock->hintJsonStructure( 'foo', json_encode(['custom_get'=>null]) ); // Execute the hint call $mock->addHintedAttributes(); // Assert that the column were properly parsed and various bits have // been set on the model $this->assertTrue($mock->hasGetMutator('custom_get')); $this->assertEquals($mock->custom_get, 'custom getter result'); } /** * Test the ability to allow models to provide their own custom attribute * getters for json attributes */ public function testCustomSetter() { // Mock the model with data $mock = new MockJsonDialectModel; $mock->hintJsonStructure( 'foo', json_encode(['custom_set'=>null]) ); // Execute the hint call $mock->addHintedAttributes(); // Assert that the column were properly parsed and various bits have // been set on the model $this->assertTrue($mock->hasSetMutator('custom_set')); // Set a value $mock->custom_set = 'value'; // Assert that the attribute was mutated by the mutator on our mock // model $this->assertEquals($mock->custom_set, 'custom value'); } } ================================================ FILE: tests/bootstrap.php ================================================ jsonColumns = $columns; } public function getCustomGetAttribute() { return "custom getter result"; } public function setCustomSetAttribute( $value ) { $this->setJsonAttribute($this->jsonAttributes['custom_set'], 'custom_set', "custom {$value}"); } }