├── .gitignore ├── tests ├── teardown.js ├── setup.js ├── test-helpers.js ├── addCustomMigration-tests.js ├── schemas.js └── attachSchema-tests.js ├── client-stubs.js ├── .travis.yml ├── .versions ├── LICENSE ├── package.js ├── README.md └── collection2-migrations.js /.gitignore: -------------------------------------------------------------------------------- 1 | .idea -------------------------------------------------------------------------------- /tests/teardown.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by David Yahalomi on 5/23/15. 3 | */ 4 | 5 | Tinytest.add('Teardown', teardown); -------------------------------------------------------------------------------- /client-stubs.js: -------------------------------------------------------------------------------- 1 | Mongo.Collection.prototype.addCustomMigration = function () {}; 2 | Mongo.Collection.prototype.runMigrations = function () {}; 3 | -------------------------------------------------------------------------------- /tests/setup.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by David Yahalomi on 5/23/15. 3 | */ 4 | Books = new Mongo.Collection('books'); 5 | Stores = new Mongo.Collection('stores'); -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | language: node_js 3 | node_js: 4 | - "0.12" 5 | before_install: 6 | - "curl -L https://raw.githubusercontent.com/arunoda/travis-ci-meteor-packages/master/configure.sh | /bin/sh" -------------------------------------------------------------------------------- /.versions: -------------------------------------------------------------------------------- 1 | aldeed:collection2@2.3.3 2 | aldeed:simple-schema@1.3.3 3 | base64@1.0.3 4 | binary-heap@1.0.3 5 | callback-hook@1.0.3 6 | check@1.0.5 7 | davidyaha:collection2-migrations@0.0.6 8 | ddp@1.1.0 9 | deps@1.0.7 10 | ejson@1.0.6 11 | geojson-utils@1.0.3 12 | id-map@1.0.3 13 | json@1.0.3 14 | local-test:davidyaha:collection2-migrations@0.0.6 15 | logging@1.0.7 16 | meteor@1.1.6 17 | minimongo@1.0.8 18 | mongo@1.1.0 19 | ordered-dict@1.0.3 20 | random@1.0.3 21 | retry@1.0.3 22 | tinytest@1.0.5 23 | tracker@1.0.7 24 | underscore@1.0.3 25 | -------------------------------------------------------------------------------- /tests/test-helpers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by David Yahalomi on 5/23/15. 3 | */ 4 | 5 | teardown = function(test) { 6 | Books._migrations.remove({}); 7 | Books.remove({}); 8 | Stores.remove({}); 9 | 10 | // Deletes collection2 fields 11 | delete Books._c2; 12 | delete Stores._c2; 13 | 14 | test.equal(Books._migrations.find().count(), 0, "Migrations collection is empty"); 15 | test.equal(Books.find().count(), 0, "Books collection is empty"); 16 | test.equal(Stores.find().count(), 0, "Stores collection is empty"); 17 | }; 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 David Yahalomi 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 all 13 | 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 THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /package.js: -------------------------------------------------------------------------------- 1 | Package.describe({ 2 | name: 'davidyaha:collection2-migrations', 3 | version: '0.0.6', 4 | // Brief, one-line summary of the package. 5 | summary: 'Auto DB migrations with collection2 and simple schema on Meteor', 6 | // URL to the Git repository containing the source code for this package. 7 | git: 'https://github.com/davidyaha/meteor-collection2-migrations.git', 8 | // By default, Meteor will default to using README.md for documentation. 9 | // To avoid submitting documentation, set this field to null. 10 | documentation: 'README.md' 11 | }); 12 | 13 | Package.onUse(function(api) { 14 | api.versionsFrom('1.1.0.2'); 15 | 16 | api.use(['underscore', 'aldeed:collection2@2.3.3']); 17 | api.addFiles('collection2-migrations.js', 'server'); 18 | api.addFiles('client-stubs.js', 'client'); 19 | }); 20 | 21 | Package.onTest(function(api) { 22 | // dependencies of tests 23 | api.use(['aldeed:collection2', 'tinytest'], 'server'); 24 | 25 | // this package 26 | api.use('davidyaha:collection2-migrations', 'server'); 27 | 28 | // test helper methods like teardown 29 | api.addFiles('tests/test-helpers.js', 'server'); 30 | 31 | // setup stage for all tests 32 | api.addFiles('tests/setup.js', 'server'); 33 | 34 | // used schemas file 35 | api.addFiles('tests/schemas.js', 'server'); 36 | 37 | // test of features 38 | api.addFiles(['tests/attachSchema-tests.js', 'tests/addCustomMigration-tests.js'], 'server'); 39 | 40 | // last teardown call 41 | api.addFiles('tests/teardown.js', 'server'); 42 | }); 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Collection2-Migrations 2 | [![Build Status](https://travis-ci.org/davidyaha/meteor-collection2-migrations.svg?branch=master)](https://travis-ci.org/davidyaha/meteor-collection2-migrations) 3 | 4 | ## You should probably use [bookmd:schema-migrations](https://www.youtube.com/watch?v=WABQiAwqVJg) 5 | 6 | ### Watch my talk to find out why: 7 | 8 | [![IMAGE ALT TEXT HERE](http://img.youtube.com/vi/WABQiAwqVJg/0.jpg)](http://www.youtube.com/watch?v=WABQiAwqVJg) 9 | 10 | 11 | This package will help you manage your DB migrations with regard of [aldeed:collection2](https://github.com/aldeed/meteor-collection2) and [aldeed:simple-schema](https://github.com/aldeed/meteor-simple-schema). 12 | 13 | 14 | ### Important Notice - Backup of your current DB is strongly advised! 15 | 16 | 17 | ### Auto Migration 18 | 19 | The package will note when there is a change in the schema and after a notable change it will try to auto migrate. 20 | Auto migration will do the following for each document on the collection: 21 | - Remove any deleted fields from schema. 22 | - Use auto or default value to fill in any missing and required fields. 23 | - Use auto conversion of field types using the built in ability of collection2. 24 | - Auto convert types if possible. 25 | 26 | Auto migration will currently not: 27 | - Move fields from one collection to another. 28 | - Rename a field. 29 | - Check for missing ids on field that suppose to relate to another document. 30 | - Rebuild indexes. 31 | - Auto fill values that fail on a regEx. 32 | 33 | All of the above features are planned to be implemented eventually but I will sure appreciate code submissions. 34 | 35 | ### Using 36 | 37 | Use the regular attachSchema call 38 | 39 | ``` 40 | Books = new Mongo.Collection('books'); 41 | 42 | var booksV1 = new SimpleSchema({ 43 | name: { 44 | type: String 45 | }, 46 | author: { 47 | type: String 48 | }, 49 | isbn: { //ISBN 10 50 | type: String, 51 | regEx: /ISBN\x20(?=.{13}$)\d{1,5}([- ])\d{1,7}\1\d{1,6}\1(\d|X)$/, 52 | optional: true, 53 | unique: true 54 | } 55 | } 56 | ); 57 | 58 | Books.attachSchema(booksV1); 59 | ``` 60 | 61 | Add custom migration functions to allow for more difficult migrations: 62 | 63 | ``` 64 | var booksV2 = new SimpleSchema({ 65 | name: { 66 | type: String 67 | }, 68 | author: { 69 | type: String 70 | }, 71 | isbn: { //ISBN 13 72 | type: String, 73 | regEx: /ISBN(?:-13)?:?\x20*(?=.{17}$)97(?:8|9)([ -])\d{1,5}\1\d{1,7}\1\d{1,6}\1\d$/, 74 | unique: true 75 | }, 76 | sold: { 77 | type: Number, 78 | defaultValue: 0 79 | } 80 | } 81 | ); 82 | Books.addCustomMigration('migrate isbn-10 to isbn-13', function () { 83 | Books.find({isbn: { $exists: true }}).forEach(function (doc) { 84 | var newIsbn = doc.isbn.replace('ISBN', 'ISBN-13 978'); 85 | Books.update({_id: doc._id}, {$set: {isbn: newIsbn}}, {validate: false}); 86 | }); 87 | }, true); 88 | 89 | Books.attachSchema(booksV2); 90 | ``` 91 | -------------------------------------------------------------------------------- /tests/addCustomMigration-tests.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by David Yahalomi on 5/23/15. 3 | */ 4 | 5 | Tinytest.add('addCustomMigration - change value on document', function (test) { 6 | Books.attachSchema(Schemas.booksV1, {replace: true}); 7 | 8 | Books.insert({name: 'The Rosie Project', author: 'Graeme C. Simsion', isbn: 'ISBN 0 71817 813 0'}); 9 | 10 | Books.addCustomMigration('migrate isbn-10 to isbn-13', function () { 11 | Books.find({isbn: { $exists: true }}).forEach(function (doc) { 12 | var newIsbn = doc.isbn.replace('ISBN', 'ISBN-13 978'); 13 | Books.update({_id: doc._id}, {$set: {isbn: newIsbn}}, {validate: false}); 14 | }); 15 | }, true); 16 | 17 | Books.attachSchema(Schemas.booksV2, {replace: true}); 18 | 19 | test.equal(Books.find().count(), 1); 20 | test.equal(Books.findOne({name: 'The Rosie Project'}).isbn, 'ISBN-13 978 0 71817 813 0'); 21 | test.equal(Books.findOne({name: 'The Rosie Project'}).sold, 0); 22 | test.equal(Books._migrations.findOne({_id: Books._name}).version, 2); 23 | 24 | teardown(test); 25 | }); 26 | 27 | Tinytest.add('addCustomMigration - copy values to another collection', function (test) { 28 | Books.attachSchema(Schemas.booksV1, {replace: true}); 29 | Stores.attachSchema(Schemas.storesV1, {replace: true}); 30 | 31 | Books.insert({name: 'Crime and Punishment', author: 'Fyodor Dostoyevsky'}); 32 | Books.insert({name: 'The Rosie Project', author: 'Graeme C. Simsion', isbn: 'ISBN 0 71817 813 0'}); 33 | 34 | Stores.insert({name: 'The Travel Bookshop Ltd', address: '13 Blenheim Crescent, London W11 2EE, United Kingdom'}); 35 | Stores.insert({name: 'Barnes & Noble', address: '555 5th Ave New York, NY, United States'}); 36 | 37 | test.equal(Books.find().count(), 2, 'Inserted two books'); 38 | test.equal(Stores.find().count(), 2, 'Inserted two stores'); 39 | 40 | Stores.addCustomMigration('copy all book ids into stores', function () { 41 | 42 | Stores.update( { stock: { $exists: false } }, 43 | { $push: { stock: { $each: Books.find().map(function (doc) { return { book_id: doc._id } } ) } } }, 44 | { validate : false, filter: false , multi: true} ); 45 | }, true); 46 | 47 | Stores.attachSchema(Schemas.storesV2, {replace: true}); 48 | 49 | var store = Stores.findOne({name: 'The Travel Bookshop Ltd'}); 50 | test.equal(store.stock.length, 2, 'Store ' + store.name + ' has two books in stock: ' + JSON.stringify(store.stock)); 51 | 52 | store = Stores.findOne({name: 'Barnes & Noble'}); 53 | test.equal(store.stock.length, 2, 'Store ' + store.name + ' has two books in stock: ' + JSON.stringify(store.stock)); 54 | 55 | test.equal(Books._migrations.findOne({_id: Books._name}).version, 1, 'Books schema version is 1'); 56 | test.equal(Stores._migrations.findOne({_id: Stores._name}).version, 2, 'Stores schema version is 2'); 57 | 58 | teardown(test); 59 | }); 60 | 61 | Tinytest.add('addCustomMigration - fail migration on regular expression mismatch', function (test) { 62 | Books.attachSchema(Schemas.booksV1, {replace: true}); 63 | 64 | Books.insert({name: 'The Rosie Project', author: 'Graeme C. Simsion', isbn: 'ISBN 0 71817 813 0'}); 65 | 66 | Books.attachSchema(Schemas.booksV2, {replace: true}); 67 | 68 | test.equal(Books.find().count(), 1); 69 | test.equal(Books.findOne({name: 'The Rosie Project'}).isbn, 'ISBN 0 71817 813 0'); 70 | test.equal(Books._migrations.findOne({_id: Books._name}).version, 1); 71 | 72 | teardown(test); 73 | }); 74 | -------------------------------------------------------------------------------- /tests/schemas.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by David Yahalomi on 5/23/15. 3 | */ 4 | 5 | var booksV1 = new SimpleSchema({ 6 | name: { 7 | type: String 8 | }, 9 | author: { 10 | type: String 11 | }, 12 | isbn: { //ISBN 10 13 | type: String, 14 | regEx: /ISBN\x20(?=.{13}$)\d{1,5}([- ])\d{1,7}\1\d{1,6}\1(\d|X)$/, 15 | optional: true, 16 | unique: true 17 | } 18 | } 19 | ); 20 | 21 | var booksV2 = new SimpleSchema({ 22 | name: { 23 | type: String 24 | }, 25 | author: { 26 | type: String 27 | }, 28 | isbn: { //ISBN 13 29 | type: String, 30 | regEx: /ISBN(?:-13)?:?\x20*(?=.{17}$)97(?:8|9)([ -])\d{1,5}\1\d{1,7}\1\d{1,6}\1\d$/, 31 | unique: true 32 | }, 33 | sold: { 34 | type: Number, 35 | defaultValue: 0 36 | } 37 | } 38 | ); 39 | 40 | var booksV3 = new SimpleSchema({ 41 | name: { 42 | type: String 43 | }, 44 | author: { 45 | type: String 46 | } 47 | } 48 | ); 49 | 50 | var booksV4 = new SimpleSchema({ 51 | name: { 52 | type: String 53 | }, 54 | author: { 55 | type: String 56 | }, 57 | isbn: { //ISBN 13 58 | type: String, 59 | regEx: /ISBN(?:-13)?:?\x20*(?=.{17}$)97(?:8|9)([ -])\d{1,5}\1\d{1,7}\1\d{1,6}\1\d$/, 60 | optional: true, 61 | unique: true 62 | }, 63 | sold: { 64 | type: String, 65 | defaultValue: "0" 66 | } 67 | } 68 | ); 69 | 70 | var storesV1 = new SimpleSchema({ 71 | name: { 72 | type: String 73 | }, 74 | address: { 75 | type: String 76 | }, 77 | gross: { 78 | type: Number, 79 | defaultValue: 0 80 | } 81 | }); 82 | 83 | var storesV2 = new SimpleSchema({ 84 | name: { 85 | type: String 86 | }, 87 | address: { 88 | type: String 89 | }, 90 | gross: { 91 | type: Number, 92 | defaultValue: 0 93 | }, 94 | stock: { 95 | type: [Object], 96 | defaultValue: [] 97 | }, 98 | 'stock.$.book_id': { 99 | type: String, 100 | regEx: SimpleSchema.RegEx.Id 101 | }, 102 | 'stock.$.quantity': { 103 | type: Number, 104 | min: 0, 105 | defaultValue: 0 106 | } 107 | }); 108 | 109 | var storesV3 = new SimpleSchema({ 110 | name: { 111 | type: String 112 | }, 113 | name_sorting: { 114 | type: String, 115 | autoValue: function () { 116 | var name = this.field('name'); 117 | if (name.isSet) 118 | return name.value.toLowerCase(); 119 | else 120 | return this.unset(); 121 | } 122 | }, 123 | address: { 124 | type: String 125 | }, 126 | gross: { 127 | type: Number, 128 | defaultValue: 0 129 | }, 130 | stock: { 131 | type: [Object], 132 | defaultValue: [] 133 | }, 134 | 'stock.$.book_id': { 135 | type: String, 136 | regEx: SimpleSchema.RegEx.Id 137 | }, 138 | 'stock.$.quantity': { 139 | type: Number, 140 | min: 0, 141 | defaultValue: 0 142 | } 143 | }); 144 | 145 | var storesV4 = new SimpleSchema({ 146 | name: { 147 | type: String 148 | }, 149 | name_sorting: { 150 | type: String, 151 | autoValue: function () { 152 | var name = this.field('name'); 153 | if (name.isSet) 154 | return name.value.toLowerCase(); 155 | else 156 | return this.unset(); 157 | } 158 | }, 159 | address: { 160 | type: String 161 | }, 162 | gross: { 163 | type: Number, 164 | defaultValue: 0 165 | }, 166 | stock: { 167 | type: [Object], 168 | defaultValue: [] 169 | }, 170 | 'stock.$.book_id': { 171 | type: String, 172 | regEx: SimpleSchema.RegEx.Id 173 | }, 174 | 'stock.$.quantity': { 175 | type: Number, 176 | min: 0, 177 | defaultValue: 0 178 | }, 179 | 'update_count' : { 180 | type: Number, 181 | min: 0, 182 | optional: true, 183 | autoValue: function () { 184 | return this.isSet ? 0 : this.value + 1; 185 | } 186 | } 187 | }); 188 | 189 | Schemas = { booksV1: booksV1, booksV2: booksV2, booksV3: booksV3, booksV4: booksV4, 190 | storesV1: storesV1, storesV2: storesV2, storesV3: storesV3, storesV4: storesV4}; 191 | -------------------------------------------------------------------------------- /tests/attachSchema-tests.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by David Yahalomi on 5/23/15. 3 | */ 4 | 5 | Tinytest.add('attachSchema - registering schema for first time', function (test) { 6 | Books.attachSchema(Schemas.booksV1); 7 | Stores.attachSchema(Schemas.storesV1); 8 | 9 | test.equal(Books._migrations.find().count(), 2); 10 | }); 11 | 12 | Tinytest.add('attachSchema - add fields to the schema', function (test) { 13 | Books.insert({name: 'Crime and Punishment', author: 'Fyodor Dostoyevsky'}); 14 | 15 | Books.attachSchema(Schemas.booksV2); //merge with current schema 16 | 17 | test.equal(Books.find().count(), 1); 18 | test.equal(Books._migrations.findOne({_id: Books._name}).version, 2, 'new version of books schema registerd'); 19 | 20 | teardown(test); 21 | }); 22 | 23 | Tinytest.add('attachSchema - call with null', function (test) { 24 | Books.attachSchema(null); 25 | 26 | test.equal(Books._migrations.find().count(), 0); 27 | teardown(test); 28 | }); 29 | 30 | Tinytest.add('attachSchema - change field type', function (test) { 31 | Books.attachSchema(Schemas.booksV1); 32 | Books.attachSchema(Schemas.booksV2); // merge into V1 - isbn is optional 33 | 34 | Books.insert({name: 'Crime and Punishment', author: 'Fyodor Dostoyevsky'}); 35 | 36 | Books.attachSchema(Schemas.booksV4, {replace: true}); 37 | 38 | test.equal(Books.findOne().sold, "0", "crimeNPunishment has string typed zero"); 39 | test.equal(Books.find().count(), 1); 40 | test.equal(Books._migrations.findOne({_id: Books._name}).version, 3); 41 | 42 | teardown(test); 43 | }); 44 | 45 | Tinytest.add('attachSchema - remove fields from the schema', function (test) { 46 | Books.attachSchema(Schemas.booksV1); 47 | 48 | Books.insert({name: 'Crime and Punishment', author: 'Fyodor Dostoyevsky'}); 49 | Books.insert({name: 'The Rosie Project', author: 'Graeme C. Simsion', isbn: 'ISBN 0 71817 813 0'}); 50 | 51 | var crimeNPunishment = Books.findOne({name: 'Crime and Punishment'}); 52 | test.equal(crimeNPunishment.author, 'Fyodor Dostoyevsky'); 53 | 54 | var rosieProject = Books.findOne({name: 'The Rosie Project'}); 55 | test.equal(rosieProject.isbn, 'ISBN 0 71817 813 0'); 56 | 57 | Books.attachSchema(Schemas.booksV3, {replace: true}); 58 | 59 | test.equal(Books.find().count(), 2); 60 | 61 | crimeNPunishment = Books.findOne({name: 'Crime and Punishment'}); 62 | test.equal(crimeNPunishment.author, 'Fyodor Dostoyevsky'); 63 | test.isUndefined(crimeNPunishment.isbn); 64 | 65 | rosieProject = Books.findOne({name: 'The Rosie Project'}); 66 | test.isUndefined(rosieProject.isbn); 67 | 68 | test.equal(Books._migrations.findOne({_id: Books._name}).version, 2, 'new version of books schema registerd'); 69 | 70 | teardown(test); 71 | }); 72 | 73 | Tinytest.add('attachSchema - no change', function (test) { 74 | Stores.attachSchema(Schemas.storesV1, {replace: true}); 75 | 76 | test.equal(Stores._migrations.findOne({_id: Stores._name}).version, 1); 77 | 78 | Stores.attachSchema(Schemas.storesV1, {replace: true}); 79 | 80 | test.equal(Stores._migrations.findOne({_id: Stores._name}).version, 1); 81 | 82 | teardown(test); 83 | }); 84 | 85 | Tinytest.add('attachSchema - no field that is required and no default value or auto value', function (test) { 86 | Books.attachSchema(Schemas.booksV1, {replace: true}); 87 | Stores.attachSchema(Schemas.storesV1, {replace: true}); 88 | 89 | Books.insert({name: 'The Rosie Project', author: 'Graeme C. Simsion'}); 90 | 91 | // failure to migrate 92 | Books.attachSchema(Schemas.booksV2, {replace: true}); 93 | 94 | // manual fix 95 | Books.update({name: 'The Rosie Project'}, {$set: {isbn: 'ISBN-13 978 0 71817 813 0'}}, {validate: false}); 96 | 97 | // try again 98 | Books.attachSchema(Schemas.booksV2, {replace: true}); 99 | 100 | test.equal(Books.find().count(), 1); 101 | test.equal(Books.findOne({name: 'The Rosie Project'}).isbn, 'ISBN-13 978 0 71817 813 0'); 102 | test.equal(Books.findOne({name: 'The Rosie Project'}).sold, 0); 103 | test.equal(Books._migrations.findOne({_id: Books._name}).version, 2); 104 | 105 | teardown(test); 106 | }); 107 | 108 | Tinytest.add('attachSchema - auto value updating existing documents', function (test) { 109 | Stores.attachSchema(Schemas.storesV2, {replace: true}); 110 | 111 | Stores.insert({name: 'The Little Book Store', address: 'Neverland'}); 112 | 113 | Stores.attachSchema(Schemas.storesV3, {replace: true}); 114 | 115 | test.equal(Stores.find().count(), 1); 116 | test.equal(Stores.findOne({name: 'The Little Book Store'}).name_sorting, 'The Little Book Store'.toLowerCase()); 117 | test.equal(Stores._migrations.findOne({_id: Stores._name}).version, 2); 118 | 119 | teardown(test); 120 | }); 121 | 122 | Tinytest.add('attachSchema - optional auto value should not update existing documents', function (test) { 123 | Stores.attachSchema(Schemas.storesV3, {replace: true}); 124 | 125 | Stores.insert({name: 'The Little Book Store', address: 'Neverland'}); 126 | 127 | Stores.attachSchema(Schemas.storesV4, {replace: true}); 128 | 129 | test.equal(Stores.find().count(), 1); 130 | test.isUndefined(Stores.findOne({name: 'The Little Book Store'}).update_count, 131 | 'There should not have been any changes to the existing document'); 132 | test.equal(Stores._migrations.findOne({_id: Stores._name}).version, 2); 133 | 134 | teardown(test); 135 | }); -------------------------------------------------------------------------------- /collection2-migrations.js: -------------------------------------------------------------------------------- 1 | Mongo.Collection.prototype._migrations = new Mongo.Collection('c2Migrations'); 2 | Mongo.Collection.prototype._migrations.customMigrations = {}; 3 | 4 | 5 | var attachSchema = Mongo.Collection.prototype.attachSchema; 6 | 7 | /** 8 | * Overrides the collection2 attachSchema function. This will first call 9 | * the attachSchema of the collection2 package. Then it will start validating 10 | * the collection data and will execute the detected automatic migrations 11 | * as well as the custom migration registered with the addCustomMigration method. 12 | * 13 | * @param ss - the SimpleSchema or schema Object given to the regular attachSchema. 14 | * @param options - not used by this override. passed as is to the regular attachSchema. 15 | */ 16 | Mongo.Collection.prototype.attachSchema = function registerMigration(ss, options) { 17 | var self = this; 18 | 19 | var returnedValue = attachSchema.apply(self, arguments); 20 | 21 | var newSchema = ss instanceof SimpleSchema ? ss.schema() : ss; 22 | 23 | var migrationObject = self._migrations.findOne({_id: self._name}); 24 | 25 | if (migrationObject) { 26 | var currentSchemaKeys = migrationObject.keys; 27 | var currentSchemaValues = migrationObject.values; 28 | } 29 | 30 | if (newSchema) { 31 | var newSchemaKeys = ss instanceof SimpleSchema ? ss._schemaKeys : _.keys(newSchema); 32 | var newSchemaValues = normalizeSchemaValues(_.values(newSchema)); 33 | 34 | if (!_.isEqual(currentSchemaKeys, newSchemaKeys) || 35 | !_.isEqual(currentSchemaValues, newSchemaValues)) { 36 | 37 | if (validateCollection(self)){ 38 | if (!currentSchemaKeys) { 39 | console.log("Collection2-Migrations been activated for the", self._name, "collection\n" + 40 | "It will now start track the associated schema versions"); 41 | 42 | self._migrations.insert({ _id: self._name, version: 1, 43 | keys: newSchemaKeys, values: newSchemaValues}); 44 | } else { 45 | console.log("The given schema for", self._name, "is different from the current tracked version.\n" + 46 | "The new version number is", migrationObject.version + 1); 47 | self._migrations.update({_id: self._name}, 48 | { $inc: { version: 1}, 49 | $set : {keys: newSchemaKeys, values: newSchemaValues}}); 50 | } 51 | 52 | self.runMigrations(); 53 | } else 54 | console.log("Could not validate and update collection", self._name,". schema did not register."); 55 | 56 | } else { 57 | console.log("No change detected for the collection", self._name); 58 | } 59 | } 60 | 61 | return returnedValue; 62 | }; 63 | 64 | /** 65 | * This method will add a custom migration function that will be associated with this collection. 66 | * The given function will be executed once. In order to control if the migration function should 67 | * be executed right away or after the automatic migration actions you should pass the executeNow 68 | * flag. 69 | * 70 | * @param name - name of the migration. this should be unique for this collectionand allow for 71 | * history of done migrations. 72 | * @param migration - A function that will be registered as a migration on this collection. 73 | * @param executeNow - A flag that indicates if the migration should be executed right away. 74 | */ 75 | Mongo.Collection.prototype.addCustomMigration = function addCustomMigration(name, migration, executeNow) { 76 | var self = this; 77 | var uniqueName = self._name + '.' + name; 78 | 79 | if (!self._migrations.findOne({_id: uniqueName})) { 80 | self._migrations.insert({_id: uniqueName, state: 'registered'}); 81 | } 82 | 83 | // saving the actual migration function memory 84 | self._migrations.customMigrations[uniqueName] = migration; 85 | 86 | if (executeNow) 87 | executeMigration(self._name + '.' + name); 88 | 89 | }; 90 | 91 | /** 92 | * Run all the migrations that has not been executed already. 93 | */ 94 | Mongo.Collection.prototype.runMigrations = function () { 95 | this._migrations.find({ _id: { $regex: this._name + '.*' }, state: 'registered' }).forEach(function (doc) { 96 | executeMigration(doc.name); 97 | }); 98 | }; 99 | 100 | function validateCollection(collection) { 101 | if(collection) { 102 | var problem; 103 | var ss = collection.simpleSchema(); 104 | var validator = ss.namedContext(); 105 | 106 | collection.find().forEach(function (doc) { 107 | var docsId = doc._id; 108 | delete doc._id; 109 | 110 | if (!validator.validate(doc, {modifier: false})) { 111 | console.log('object with id', docsId, 'is not valid. trying to migrate..'); 112 | var unsetObject = {}; 113 | 114 | _.forEach(validator.invalidKeys(), function (invalidKey) { 115 | 116 | if ((invalidKey.type === 'keyNotInSchema') && 117 | !findInKeyHierarchy(invalidKey.name, unsetObject)) 118 | unsetObject[invalidKey.name] = 1; 119 | 120 | else { 121 | var schemaKey = getSchemaKey(invalidKey.name); 122 | if ( invalidKey.type === 'required' && 123 | ( ss.schema()[schemaKey].defaultValue === undefined && 124 | ss.schema()[schemaKey].autoValue === undefined )) { 125 | 126 | console.log('missing required key', invalidKey.name, 'on document', 127 | docsId,'and there is no default value or auto value set'); 128 | problem = true; 129 | 130 | } else if ( invalidKey.type === 'regEx') { 131 | console.log(invalidKey.name, 'failed regular expression validation in document with id', docsId); 132 | problem = true; 133 | } else 134 | console.log(invalidKey.name, invalidKey.type); 135 | } 136 | }); 137 | 138 | if (!problem) { 139 | var unsetKeys = _.keys(unsetObject); 140 | if (unsetKeys.length !== 0) { 141 | console.log('The following fields will be deleted', unsetKeys); 142 | 143 | collection.update(docsId, 144 | {'$unset': unsetObject}, 145 | {validate: false, filter: false}); 146 | } 147 | 148 | collection.update(docsId, {$set: ss.clean(doc)}); 149 | 150 | console.log('Updated document', docsId, 'to a valid object'); 151 | } 152 | } 153 | }); 154 | } 155 | 156 | return !problem && !!collection; 157 | } 158 | 159 | function executeMigration(id) { 160 | var migrations = Mongo.Collection.prototype._migrations; 161 | 162 | var migrationEntry = migrations.findOne({_id: id, state: 'registered'}); 163 | if (migrationEntry) 164 | migrations.customMigrations[id](); 165 | 166 | migrations.update({_id: id}, {$set: {state: 'done'}}) 167 | } 168 | 169 | function findInKeyHierarchy(keyToFind, object) { 170 | return _.find(_.keys(object), function (currentKey) { 171 | var keySplitToLevels = keyToFind.split('.'); 172 | var currentLevel = ''; 173 | 174 | for (var keyLevel in keySplitToLevels) { 175 | currentLevel = currentLevel === '' ? keySplitToLevels[keyLevel] : currentLevel + '.' + keySplitToLevels[keyLevel]; 176 | if (currentKey === currentLevel) return true; 177 | } 178 | 179 | return false; 180 | }); 181 | } 182 | 183 | function getSchemaKey(documentKey) { 184 | if ( typeof documentKey !== 'string' ) 185 | throw new Meteor.Error('document key must be a string. instead was ' + typeof documentKey); 186 | else 187 | return documentKey.replace(/\.\d+\./g, '.$.'); 188 | } 189 | 190 | function normalizeSchemaValues(schemaValuesArray) { 191 | return _.map(schemaValuesArray, function (fieldDefintion) { 192 | return _.map(fieldDefintion, function (propertyValue) { 193 | return typeof propertyValue == "function" ? propertyValue.name : propertyValue; 194 | }) 195 | }); 196 | } 197 | --------------------------------------------------------------------------------