├── .gitignore ├── .travis.yml ├── CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── composer.json ├── phpunit.xml ├── src └── ReplaceableModel.php ├── test-migrations └── 2017_10_20_000000_create_test_table.php └── tests └── ReplacableModelTest.php /.gitignore: -------------------------------------------------------------------------------- 1 | .php_cs.cache 2 | .phpunit.result.cache 3 | /vendor 4 | composer.lock 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | cache: 4 | directories: 5 | - $HOME/.cache/pip 6 | - $HOME/.composer/cache/files 7 | 8 | php: 9 | - 7.2 10 | - 7.3 11 | - 8.0 12 | 13 | env: 14 | - LARAVEL_VERSION=7.x-dev 15 | - LARAVEL_VERSION=8.x-dev 16 | - LARAVEL_VERSION=9.x-dev 17 | 18 | matrix: 19 | exclude: 20 | - php: 7.2 21 | env: LARAVEL_VERSION=8.x-dev 22 | - php: 7.2 23 | env: LARAVEL_VERSION=9.x-dev 24 | - php: 7.3 25 | env: LARAVEL_VERSION=9.x-dev 26 | 27 | before_install: 28 | - echo "memory_limit=2G" >> ~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/travis.ini 29 | - cp ~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/xdebug.ini ~/xdebug.ini 30 | - phpenv config-rm xdebug.ini 31 | - composer require "laravel/framework:${LARAVEL_VERSION}" --no-update --prefer-dist 32 | 33 | install: travis_retry composer install --no-interaction --prefer-dist 34 | 35 | before_script: phpenv config-add ~/xdebug.ini 36 | 37 | script: vendor/bin/phpunit tests 38 | 39 | notifications: 40 | email: false 41 | -------------------------------------------------------------------------------- /CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, and in the interest of fostering an open and welcoming community, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, or nationality. 6 | 7 | Examples of unacceptable behavior by participants include: 8 | 9 | * The use of sexualized language or imagery 10 | * Personal attacks 11 | * Trolling or insulting/derogatory comments 12 | * Public or private harassment 13 | * Publishing other's private information, such as physical or electronic addresses, without explicit permission 14 | * Other unethical or unprofessional conduct. 15 | 16 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. By adopting this Code of Conduct, project maintainers commit themselves to fairly and consistently applying these principles to every aspect of managing this project. Project maintainers who do not follow or enforce the Code of Conduct may be permanently removed from the project team. 17 | 18 | This code of conduct applies both within project spaces and in public spaces when an individual is representing the project or its community in a direct capacity. Personal views, beliefs and values of individuals do not necessarily reflect those of the organisation or affiliated individuals and organisations. 19 | 20 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 21 | 22 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.2.0, available at [http://contributor-covenant.org/version/1/2/0/](http://contributor-covenant.org/version/1/2/0/) 23 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. 4 | 5 | We accept contributions via Pull Requests on [Github](https://github.com/jdavidbakr/ReplaceableModel). 6 | 7 | 8 | ## Pull Requests 9 | 10 | - **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](http://pear.php.net/package/PHP_CodeSniffer). 11 | 12 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. 13 | 14 | - **Create feature branches** - Don't ask us to pull from your master branch. 15 | 16 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 17 | 18 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 19 | 20 | **Happy coding**! 21 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) 2016 J David Baker 4 | 5 | > Permission is hereby granted, free of charge, to any person obtaining a copy 6 | > of this software and associated documentation files (the "Software"), to deal 7 | > in the Software without restriction, including without limitation the rights 8 | > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | > copies of the Software, and to permit persons to whom the Software is 10 | > furnished to do so, subject to the following conditions: 11 | > 12 | > The above copyright notice and this permission notice shall be included in 13 | > all copies or substantial portions of the Software. 14 | > 15 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | > THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ReplaceableModel 2 | 3 | [![Latest Version on Packagist][ico-version]][link-packagist] 4 | [![Software License][ico-license]](LICENSE.md) 5 | [![Total Downloads][ico-downloads]][link-downloads] 6 | [![Travis CI][ico-travis]][link-travis] 7 | 8 | The default Eloquent model is great for most cases, but if you have a database table that has additional constraints you may run into race conditions where the standard update() call will fail. 9 | Imagine, for example, the following table structure: 10 | 11 | ``` 12 | id auto increment 13 | user_id 14 | widget_id 15 | date 16 | ``` 17 | 18 | where you have a constraint with each user can only have one widget per day, so you have a unique constraint across user_id and date. Now in your interface you have a form that uses ajax calls to update the entries in this table, which may include removing items based on which items are selected. Because the form submits, say, an entire month's worth of widgets, you don't want to loop through and do individual inserts - you want to perform a single insert query. So you have something like the following in your php code: 19 | 20 | ``` php 21 | Model::where('user_id',$user_id)->delete(); 22 | Model::insert($inserts); 23 | ``` 24 | 25 | This essentially performs the following under the hood: 26 | 27 | ``` sql 28 | delete from table where user_id = A; 29 | insert into table (user_id, widget_id, date) values (A, B, C) ... 30 | ``` 31 | 32 | If you get stuck in a race condition, you might end up with the following: 33 | 34 | ``` sql 35 | delete from table where user_id = A; -- process #1 36 | delete from table where user_id = A; -- process #2 37 | insert into table (user_id, widget_id, date) values (A, B, C) ... -- process #1 38 | insert into table (user_id, widget_id, date) values (A, B, C) ... -- process #2 - Exception! 39 | ``` 40 | 41 | The second insert will result in an exception. If the second query had an additional row than the first one, that insert is lost forever. 42 | 43 | Before Laravel, I would normally have handled this type of situation with REPLACE or INSERT IGNORE commands. REPLACE will do a delete and insert based on any constraints in the query, and INSERT IGNORE will perform the insert but if there are any rows that cause constraint collisions those rows will not be updated. You would use REPLACE if you want the last query to overwrite any existing rows, and you would use INSERT IGNORE to only insert new rows. 44 | 45 | Because this is a specific feature of MySQL, Laravel does not support it in Eloquent. The standard solution is to perform a raw query. This is ok, but it is kind of cumbersome to build this query every time with the bindings, etc, and I decided it would be helpful to create a trait for Eloquent that handles all of this for me and can be accessed in the same way that I would use insert(). 46 | 47 | Note that I'm **NOT** using the Builder class here. I'm directly extending the Model class and as such you won't be able to chain these functions like you might the regular insert() command. This is really just a macro to fix a problem that I had. I welcome any pull requests that solve additional problems you may have with this package. 48 | 49 | The 'saving' and 'saved' events **are** fired for both of these commands. 50 | 51 | ## Install 52 | 53 | Via Composer 54 | 55 | ``` bash 56 | $ composer require jdavidbakr/replaceable-model 57 | ``` 58 | 59 | ## Usage 60 | 61 | Apply the trait to your models to activate the ability to use replace and insertIgnore 62 | 63 | ``` php 64 | class model extends Model { 65 | ... 66 | use \jdavidbakr\ReplaceableModel\ReplaceableModel 67 | ... 68 | } 69 | ``` 70 | 71 | Then build your insert array like you would for the insert() call and call one of the two functions: 72 | 73 | ``` php 74 | $inserts = [...]; 75 | \App\Model::replace($inserts); 76 | \App\Model::insertIgnore($inserts); 77 | ``` 78 | 79 | ## Contributing 80 | 81 | Please see [CONTRIBUTING](CONTRIBUTING.md) and [CONDUCT](CONDUCT.md) for details. 82 | 83 | ## Security 84 | 85 | If you discover any security related issues, please email me@jdavidbaker.com instead of using the issue tracker. 86 | 87 | ## Credits 88 | 89 | - [J David Baker][link-author] 90 | - [All Contributors][link-contributors] 91 | 92 | ## License 93 | 94 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 95 | 96 | [ico-version]: https://img.shields.io/packagist/v/jdavidbakr/replaceable-model.svg?style=flat-square 97 | [ico-license]: https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square 98 | [ico-travis]: https://img.shields.io/travis/jdavidbakr/replaceable-model/master.svg?style=flat-square 99 | [ico-scrutinizer]: https://img.shields.io/scrutinizer/coverage/g/jdavidbakr/replaceable-model.svg?style=flat-square 100 | [ico-code-quality]: https://img.shields.io/scrutinizer/g/jdavidbakr/replaceable-model.svg?style=flat-square 101 | [ico-downloads]: https://img.shields.io/packagist/dt/jdavidbakr/replaceable-model.svg?style=flat-square 102 | 103 | [link-packagist]: https://packagist.org/packages/jdavidbakr/replaceable-model 104 | [link-travis]: https://travis-ci.org/jdavidbakr/replaceable-model 105 | [link-scrutinizer]: https://scrutinizer-ci.com/g/jdavidbakr/replaceable-model/code-structure 106 | [link-code-quality]: https://scrutinizer-ci.com/g/jdavidbakr/replaceable-model 107 | [link-downloads]: https://packagist.org/packages/jdavidbakr/replaceable-model 108 | [link-author]: https://github.com/jdavidbakr 109 | [link-contributors]: ../../contributors 110 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jdavidbakr/replaceable-model", 3 | "type": "library", 4 | "description": "Adds 'REPLACE' and 'INSERT IGNORE' query capability to eloquent models", 5 | "keywords": [ 6 | "jdavidbakr", 7 | "replaceable-model" 8 | ], 9 | "minimum-stability": "dev", 10 | "homepage": "https://github.com/jdavidbakr/replaceable-model", 11 | "license": "MIT", 12 | "authors": [ 13 | { 14 | "name": "J David Baker", 15 | "email": "me@jdavidbaker.com", 16 | "homepage": "http://www.jdavidbaker.com", 17 | "role": "Developer" 18 | } 19 | ], 20 | "require": { 21 | "symfony/http-foundation": ">=5.0.7", 22 | "illuminate/support": "^7.0|^8.0|^9.0", 23 | "php": ">=7.2.5" 24 | }, 25 | "require-dev": { 26 | "phpunit/phpunit": "^8.4|^9.5", 27 | "orchestra/testbench": "5.x-dev|6.x-dev|7.x-dev" 28 | }, 29 | "autoload": { 30 | "psr-4": { 31 | "jdavidbakr\\ReplaceableModel\\": "src" 32 | } 33 | }, 34 | "autoload-dev": { 35 | "psr-4": { 36 | "jdavidbakr\\ReplaceableModel\\": "tests" 37 | } 38 | }, 39 | "scripts": { 40 | "test": "phpunit" 41 | }, 42 | "extra": { 43 | "branch-alias": { 44 | "dev-master": "1.0-dev" 45 | } 46 | } 47 | } -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | ./tests 14 | 15 | 16 | 17 | 18 | ./app 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/ReplaceableModel.php: -------------------------------------------------------------------------------- 1 | GetConnection()->GetDriverName(); 31 | switch ($driver) { 32 | case 'sqlite': 33 | return static::executeQuery('insert or ignore', $attributes); 34 | break; 35 | default: 36 | return static::executeQuery('insert ignore', $attributes); 37 | break; 38 | } 39 | } 40 | 41 | protected static function executeQuery($command, array $attributes) 42 | { 43 | if (!count($attributes)) { 44 | return true; 45 | } 46 | $model = new static(); 47 | 48 | if ($model->fireModelEvent('saving') === false) { 49 | return false; 50 | } 51 | 52 | $attributes = collect($attributes); 53 | $first = $attributes->first(); 54 | if (!is_array($first)) { 55 | $attributes = collect([$attributes->toArray()]); 56 | } 57 | 58 | // Check for timestamps 59 | // Note that because we are actually deleting the record in the case of replace, we don't have reference to the original created_at timestamp; 60 | // If you need to retain that, you shouldn't be using this package and should be using the standard eloquent system. 61 | if ($model->timestamps) { 62 | foreach ($attributes as $key=>$set) { 63 | if (empty($set[static::CREATED_AT])) { 64 | $set[static::CREATED_AT] = Carbon::now(); 65 | } 66 | if (! is_null($model::UPDATED_AT) && empty($set[static::UPDATED_AT])) { 67 | $set[static::UPDATED_AT] = Carbon::now(); 68 | } 69 | $attributes[$key] = $set; 70 | } 71 | } 72 | 73 | $keys = collect($attributes->first())->keys() 74 | ->transform(function ($key) { 75 | return "`".$key."`"; 76 | }); 77 | 78 | $bindings = []; 79 | $query = $command . " into " . DB::connection($model->getConnectionName())->getTablePrefix() . $model->getTable()." (".$keys->implode(",").") values "; 80 | $inserts = []; 81 | foreach ($attributes as $data) { 82 | $qs = []; 83 | foreach ($data as $value) { 84 | $qs[] = '?'; 85 | $bindings[] = $value; 86 | } 87 | $inserts[] = '('.implode(",", $qs).')'; 88 | } 89 | $query .= implode(",", $inserts); 90 | 91 | DB::connection($model->getConnectionName())->insert($query, $bindings); 92 | 93 | $model->fireModelEvent('saved', false); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /test-migrations/2017_10_20_000000_create_test_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 18 | $table->timestamps(); 19 | $table->string('string'); 20 | }); 21 | } 22 | 23 | /** 24 | * Reverse the migrations. 25 | * 26 | * @return void 27 | */ 28 | public function down() 29 | { 30 | Schema::dropIfExists('test_models'); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/ReplacableModelTest.php: -------------------------------------------------------------------------------- 1 | artisan('migrate', ['--path'=>'../../../../test-migrations']); 13 | } 14 | 15 | /** 16 | * @test 17 | */ 18 | public function it_executes_replace_to_create_a_new_model() 19 | { 20 | $inserts = [ 21 | [ 22 | 'id'=>1, 23 | 'string'=>'string-1' 24 | ], 25 | ]; 26 | $model = TestModel::replace($inserts); 27 | $this->assertDatabaseHas('test_models', [ 28 | 'id'=>1, 29 | 'string'=>'string-1' 30 | ]); 31 | // Check for timestamp 32 | $this->assertDatabaseMissing('test_models', [ 33 | 'id'=>1, 34 | 'created_at'=>null, 35 | 'updated_at'=>null, 36 | ]); 37 | } 38 | 39 | /** 40 | * @test 41 | */ 42 | public function it_executes_replace_to_update_and_create() 43 | { 44 | TestModel::create([ 45 | 'id'=>1, 46 | 'string'=>'string-1', 47 | ]); 48 | $inserts = [ 49 | [ 50 | 'id'=>1, 51 | 'string'=>'string-2' 52 | ], 53 | [ 54 | 'id'=>2, 55 | 'string'=>'string-3' 56 | ] 57 | ]; 58 | $model = TestModel::replace($inserts); 59 | $this->assertDatabaseHas('test_models', [ 60 | 'id'=>1, 61 | 'string'=>'string-2' 62 | ]); 63 | $this->assertDatabaseHas('test_models', [ 64 | 'id'=>2, 65 | 'string'=>'string-3' 66 | ]); 67 | // Check for timestamp 68 | $this->assertDatabaseMissing('test_models', [ 69 | 'id'=>1, 70 | 'created_at'=>null, 71 | 'updated_at'=>null, 72 | ]); 73 | $this->assertDatabaseMissing('test_models', [ 74 | 'id'=>2, 75 | 'created_at'=>null, 76 | 'updated_at'=>null, 77 | ]); 78 | } 79 | 80 | /** 81 | * @test 82 | */ 83 | public function it_executes_insert_ignore_to_create_a_new_model() 84 | { 85 | $inserts = [ 86 | [ 87 | 'id'=>1, 88 | 'string'=>'string-1' 89 | ], 90 | ]; 91 | $model = TestModel::insertIgnore($inserts); 92 | $this->assertDatabaseHas('test_models', [ 93 | 'id'=>1, 94 | 'string'=>'string-1' 95 | ]); 96 | // Check for timestamp 97 | $this->assertDatabaseMissing('test_models', [ 98 | 'id'=>1, 99 | 'created_at'=>null, 100 | 'updated_at'=>null, 101 | ]); 102 | } 103 | 104 | 105 | /** 106 | * @test 107 | */ 108 | public function it_executes_insert_ignore_to_update_and_create() 109 | { 110 | TestModel::create([ 111 | 'id'=>1, 112 | 'string'=>'string-1', 113 | ]); 114 | $inserts = [ 115 | [ 116 | 'id'=>1, 117 | 'string'=>'string-2' 118 | ], 119 | [ 120 | 'id'=>2, 121 | 'string'=>'string-3' 122 | ] 123 | ]; 124 | $model = TestModel::insertIgnore($inserts); 125 | // First one should not change 126 | $this->assertDatabaseHas('test_models', [ 127 | 'id'=>1, 128 | 'string'=>'string-1' 129 | ]); 130 | // Second one should be inserted 131 | $this->assertDatabaseHas('test_models', [ 132 | 'id'=>2, 133 | 'string'=>'string-3' 134 | ]); 135 | // Check for timestamp 136 | $this->assertDatabaseMissing('test_models', [ 137 | 'id'=>1, 138 | 'created_at'=>null, 139 | 'updated_at'=>null, 140 | ]); 141 | $this->assertDatabaseMissing('test_models', [ 142 | 'id'=>2, 143 | 'created_at'=>null, 144 | 'updated_at'=>null, 145 | ]); 146 | } 147 | } 148 | 149 | class TestModel extends Model 150 | { 151 | use ReplaceableModel; 152 | protected $guarded = []; 153 | } 154 | --------------------------------------------------------------------------------