├── .gitignore ├── travis.sh ├── test ├── fixtures │ ├── tpl.js │ └── tpl.html ├── settings │ ├── local.json │ ├── travis.json │ ├── travis-install.sh │ └── my.5.5.41.cnf ├── helpers │ ├── randomString.js │ ├── queryEx.js │ ├── expectResult.js │ └── querySequence.js ├── simple_rest.js ├── benchmark │ ├── server.mongo.js │ ├── server.mysql.js │ ├── maxVsOrderBy.js │ └── insertMany.js ├── index.es6 └── MysqlSubscription.js ├── .travis.yml ├── versions.json ├── LICENSE ├── .versions ├── package.js ├── lib ├── LiveMysql.js └── MysqlSubscription.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | *.*~ 2 | *.swp 3 | *.swo 4 | .build* 5 | .npm 6 | settings.json 7 | -------------------------------------------------------------------------------- /travis.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | meteor "$@" --settings test/settings/travis.json 3 | -------------------------------------------------------------------------------- /test/fixtures/tpl.js: -------------------------------------------------------------------------------- 1 | Template.mysqlTest.helpers({ 2 | myScore: function(){ 3 | var data = myScore.reactive(); 4 | return data.length === 1 && data[0].score; 5 | } 6 | }); 7 | -------------------------------------------------------------------------------- /test/fixtures/tpl.html: -------------------------------------------------------------------------------- 1 |
2 | {{> mysqlTest }} 3 | 4 | 5 | 6 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /test/settings/local.json: -------------------------------------------------------------------------------- 1 | { 2 | "mysql": { 3 | "host" : "localhost", 4 | "user" : "root", 5 | "password" : "numtel", 6 | "database" : "test_package", 7 | "serverId" : 129, 8 | "minInterval" : 200 9 | }, 10 | "recreateDb": false 11 | } 12 | -------------------------------------------------------------------------------- /test/settings/travis.json: -------------------------------------------------------------------------------- 1 | { 2 | "mysql": { 3 | "host" : "localhost", 4 | "user" : "root", 5 | "password" : "", 6 | "port" : 3355, 7 | "database" : "myapp_test", 8 | "serverId" : 129, 9 | "minInterval" : 200 10 | }, 11 | "recreateDb": true 12 | } 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | env: TEST_COMMAND=./travis.sh 3 | node_js: 4 | - "0.10" 5 | before_install: 6 | - "curl -L http://git.io/ejPSng | /bin/sh" 7 | before_script: 8 | - sudo apt-get install libaio1 libaio-dev 9 | - ./test/settings/travis-install.sh 10 | after_script: 11 | - kill $(cat mysql-5.5.41-linux2.6-x86_64/mysql.pid) 12 | -------------------------------------------------------------------------------- /test/helpers/randomString.js: -------------------------------------------------------------------------------- 1 | // numtel:mysql 2 | // MIT License, ben@latenightsketches.com 3 | // test/helper.randomString.js, test/performance/server/randomString.js 4 | 5 | randomString = function(length){ 6 | var text = "", 7 | possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz123456789"; 8 | for(var i=0; i < length; i++){ 9 | text += possible.charAt(Math.floor(Math.random() * possible.length)); 10 | }; 11 | return text; 12 | }; 13 | -------------------------------------------------------------------------------- /test/helpers/queryEx.js: -------------------------------------------------------------------------------- 1 | 2 | var Future = Npm.require('fibers/future'); 3 | 4 | // Must be bound to node-mysql connection instance 5 | queryEx = function(query){ 6 | var self = this; 7 | var fut = new Future(); 8 | if(typeof query === 'function'){ 9 | var escId = self.escapeId; 10 | var esc = self.escape.bind(self); 11 | query = query(esc, escId); 12 | } 13 | self.query(query, Meteor.bindEnvironment(function(error, rows, fields){ 14 | if(error) return fut['throw'](error); 15 | fut['return'](rows); 16 | })); 17 | return fut.wait(); 18 | } 19 | -------------------------------------------------------------------------------- /test/simple_rest.js: -------------------------------------------------------------------------------- 1 | // numtel:mysql 2 | // MIT License, ben@latenightsketches.com 3 | // test/simple_rest.js 4 | 5 | var SUITE_PREFIX = 'numtel:mysql - simple:rest support - '; 6 | 7 | Tinytest.addAsync(SUITE_PREFIX + 'allPlayers publication', function(test, done){ 8 | HTTP.get(Meteor.absoluteUrl() + '/publications/allPlayers', 9 | function(error, result) { 10 | test.equal(result.statusCode, 200); 11 | 12 | // JSON data does not come ordered 13 | var data = _.sortBy(result.data.data, '_index'); 14 | 15 | // expectedRows is defined in test/MysqlSubscription.js 16 | test.equal(expectResult(data, expectedRows), true); 17 | done(); 18 | } 19 | ); 20 | }); 21 | -------------------------------------------------------------------------------- /test/settings/travis-install.sh: -------------------------------------------------------------------------------- 1 | # MySQL is going to be run under this user 2 | sudo rm -rf /var/ramfs/mysql/ 3 | sudo mkdir /var/ramfs/mysql/ 4 | sudo chown $USER /var/ramfs/mysql/ 5 | # -------------------------------------------- 6 | 7 | # Download and extract MySQL binaries 8 | wget http://dev.mysql.com/get/Downloads/MySQL-5.5/mysql-5.5.41-linux2.6-x86_64.tar.gz 9 | tar -zxf mysql-5.5.41-linux2.6-x86_64.tar.gz 10 | cd mysql-5.5.41-linux2.6-x86_64/ 11 | 12 | mkdir -p data/mysql/data/tmp 13 | # Initialize information database 14 | ./scripts/mysql_install_db --datadir=./data/mysql --user=$USER 15 | 16 | # Copy configuration 17 | cp ../test/settings/my.5.5.41.cnf ./my.cnf 18 | mkdir binlog 19 | touch binlog/mysql-bin.index 20 | 21 | # Start server 22 | ./bin/mysqld --defaults-file=my.cnf & 23 | sleep 4 24 | 25 | cd .. 26 | 27 | -------------------------------------------------------------------------------- /test/benchmark/server.mongo.js: -------------------------------------------------------------------------------- 1 | // numtel:mysql 2 | // MIT License, ben@latenightsketches.com 3 | // test/benchmark/server.mongo.js 4 | 5 | // Provide server for Mongo benchmarks 6 | MongoPlayers = new Mongo.Collection('MongoPlayers'); 7 | 8 | Meteor.methods({ 9 | resetCollection: function(){ 10 | MongoPlayers.remove({}); 11 | }, 12 | insDocs: function(count){ 13 | if(typeof count !== 'number' || count < 1 || Math.floor(count) !== count) 14 | throw new Error('invalid-count'); 15 | for(var i = 0; i < count; i++){ 16 | MongoPlayers.insert({ 17 | name: randomString(10), 18 | score: Math.floor(Math.random() * 20) * 5 19 | }); 20 | } 21 | }, 22 | insDocsDirect: function(count){ 23 | if(typeof count !== 'number' || count < 1 || Math.floor(count) !== count) 24 | throw new Error('invalid-count'); 25 | var docs = []; 26 | for(var i = 0; i < count; i++){ 27 | docs.push({ 28 | name: randomString(10), 29 | score: Math.floor(Math.random() * 20) * 5 30 | }); 31 | } 32 | MongoPlayers.directInsert(docs); 33 | } 34 | }); 35 | 36 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": [ 3 | [ 4 | "base64", 5 | "1.0.1" 6 | ], 7 | [ 8 | "callback-hook", 9 | "1.0.1" 10 | ], 11 | [ 12 | "check", 13 | "1.0.2" 14 | ], 15 | [ 16 | "ddp", 17 | "1.0.12" 18 | ], 19 | [ 20 | "ejson", 21 | "1.0.4" 22 | ], 23 | [ 24 | "geojson-utils", 25 | "1.0.1" 26 | ], 27 | [ 28 | "id-map", 29 | "1.0.1" 30 | ], 31 | [ 32 | "json", 33 | "1.0.1" 34 | ], 35 | [ 36 | "logging", 37 | "1.0.5" 38 | ], 39 | [ 40 | "meteor", 41 | "1.1.3" 42 | ], 43 | [ 44 | "minimongo", 45 | "1.0.5" 46 | ], 47 | [ 48 | "ordered-dict", 49 | "1.0.1" 50 | ], 51 | [ 52 | "random", 53 | "1.0.1" 54 | ], 55 | [ 56 | "retry", 57 | "1.0.1" 58 | ], 59 | [ 60 | "tracker", 61 | "1.0.3" 62 | ], 63 | [ 64 | "underscore", 65 | "1.0.1" 66 | ] 67 | ], 68 | "pluginDependencies": [], 69 | "toolVersion": "meteor-tool@1.0.36", 70 | "format": "1.0" 71 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 ben@latenightsketches.com 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 | -------------------------------------------------------------------------------- /test/helpers/expectResult.js: -------------------------------------------------------------------------------- 1 | // numtel:mysql 2 | // MIT License, ben@latenightsketches.com 3 | // test/helper.expectResult.js 4 | 5 | var checkMismatch = function(result, expected){ 6 | return ((expected instanceof RegExp && !expected.test(result)) || 7 | (!(expected instanceof RegExp) && result !== expected)); 8 | }; 9 | 10 | // Deep compare variables, allowing regular expressions on expected values 11 | // Accepts array, object and primitives 12 | expectResult = function(result, expected){ 13 | for(var i = 0; i < expected.length; i++){ 14 | if(typeof expected[i] === 'object' && !(expected[i] instanceof RegExp)){ 15 | if(expected.length !== undefined && 16 | result.length !== expected.length) return 'Mismatched lengths'; 17 | if(typeof result[i] !== 'object') return 'Result not object'; 18 | for(var key in expected[i]){ 19 | if(expected[i].hasOwnProperty(key) && 20 | checkMismatch(result[i][key], expected[i][key])) 21 | return 'Value mismatch'; 22 | } 23 | }else if(checkMismatch(result[i], expected[i])){ 24 | return 'Primitive mismatch'; 25 | } 26 | } 27 | return true; 28 | }; 29 | 30 | -------------------------------------------------------------------------------- /test/helpers/querySequence.js: -------------------------------------------------------------------------------- 1 | var Future = Npm.require('fibers/future'); 2 | // Execute a sequence of queries on a node-mysql database connection 3 | // @param {object} connection - Node-Mysql Connection, Connected 4 | // @param {boolean} debug - Print queries as they execute (optional) 5 | // @param {[string]} queries - Queries to execute, in order 6 | // @param {function} callback - Call when complete 7 | querySequence = function(connection, debug, queries, callback){ 8 | var fut = new Future(); 9 | if(debug instanceof Array){ 10 | callback = queries; 11 | queries = debug; 12 | debug = false; 13 | } 14 | var results = []; 15 | var sequence = queries.map(function(queryStr, index, initQueries){ 16 | return function(){ 17 | debug && console.log('Query Sequence', index, queryStr); 18 | connection.query(queryStr, 19 | Meteor.bindEnvironment(function(err, rows, fields){ 20 | if(err) return fut['throw'](err); 21 | results.push(rows); 22 | if(index < sequence.length - 1){ 23 | sequence[index + 1](); 24 | }else{ 25 | fut['return'](results); 26 | } 27 | }) 28 | ); 29 | } 30 | }); 31 | sequence[0](); 32 | return fut.wait(); 33 | }; 34 | -------------------------------------------------------------------------------- /test/settings/my.5.5.41.cnf: -------------------------------------------------------------------------------- 1 | # For advice on how to change settings please see 2 | # http://dev.mysql.com/doc/refman/5.6/en/server-configuration-defaults.html 3 | 4 | [mysqld] 5 | 6 | # Remove leading # and set to the amount of RAM for the most important data 7 | # cache in MySQL. Start at 70% of total RAM for dedicated server, else 10%. 8 | innodb_buffer_pool_size = 128M 9 | 10 | # Remove leading # to turn on a very important data integrity option: logging 11 | # changes to the binary log between backups. 12 | # log_bin 13 | server_id = 5541 14 | binlog_format=row 15 | log_bin = ../../binlog/mysql-bin.log 16 | # binlog_checksum=CRC32 17 | expire_logs_days = 10 18 | max_binlog_size = 100M 19 | 20 | 21 | # These are commonly set, remove the # and set as required. 22 | basedir = ./ 23 | datadir = ./data/mysql 24 | tmpdir = ./data/tmp 25 | port = 3355 26 | socket = ./mysql.sock 27 | pid_file = ./mysql.pid 28 | 29 | # Remove leading # to set options mainly useful for reporting servers. 30 | # The server defaults are faster for transactions and fast SELECTs. 31 | # Adjust sizes as needed, experiment to find the optimal values. 32 | # join_buffer_size = 128M 33 | # sort_buffer_size = 2M 34 | # read_rnd_buffer_size = 2M 35 | 36 | sql_mode=NO_ENGINE_SUBSTITUTION,STRICT_TRANS_TABLES 37 | -------------------------------------------------------------------------------- /.versions: -------------------------------------------------------------------------------- 1 | allow-deny@1.0.4 2 | autopublish@1.0.7 3 | babel-compiler@6.6.4 4 | babel-runtime@0.1.8 5 | base64@1.0.8 6 | binary-heap@1.0.8 7 | blaze@2.1.7 8 | blaze-tools@1.0.8 9 | boilerplate-generator@1.0.8 10 | caching-compiler@1.0.4 11 | caching-html-compiler@1.0.6 12 | callback-hook@1.0.8 13 | check@1.2.1 14 | ddp@1.2.5 15 | ddp-client@1.2.7 16 | ddp-common@1.2.5 17 | ddp-server@1.2.6 18 | deps@1.0.12 19 | diff-sequence@1.0.5 20 | ecmascript@0.4.3 21 | ecmascript-runtime@0.2.10 22 | ejson@1.0.11 23 | geojson-utils@1.0.8 24 | grigio:babel@0.1.1 25 | html-tools@1.0.9 26 | htmljs@1.0.9 27 | http@1.1.5 28 | id-map@1.0.7 29 | insecure@1.0.7 30 | jquery@1.11.8 31 | local-test:numtel:mysql@1.0.6 32 | logging@1.0.12 33 | meteor@1.1.14 34 | minifier-js@1.1.11 35 | minimongo@1.0.16 36 | modules@0.6.1 37 | modules-runtime@0.6.3 38 | mongo@1.1.7 39 | mongo-id@1.0.4 40 | npm-mongo@1.4.43 41 | numtel:mysql@1.0.6 42 | observe-sequence@1.0.11 43 | ordered-dict@1.0.7 44 | promise@0.6.7 45 | random@1.0.9 46 | reactive-var@1.0.9 47 | retry@1.0.7 48 | routepolicy@1.0.10 49 | simple:json-routes@2.1.0 50 | simple:rest@1.1.1 51 | spacebars@1.0.11 52 | spacebars-compiler@1.0.11 53 | templating@1.1.9 54 | templating-tools@1.0.4 55 | test-helpers@1.0.9 56 | tinytest@1.0.10 57 | tracker@1.0.13 58 | ui@1.0.11 59 | underscore@1.0.8 60 | url@1.0.9 61 | webapp@1.2.8 62 | webapp-hashing@1.0.9 63 | -------------------------------------------------------------------------------- /package.js: -------------------------------------------------------------------------------- 1 | Package.describe({ 2 | name: 'numtel:mysql', 3 | summary: 'MySQL support with Reactive Select Subscriptions', 4 | version: '1.0.6', 5 | git: 'https://github.com/numtel/meteor-mysql.git' 6 | }); 7 | 8 | Npm.depends({ 9 | 'mysql': '2.10.2', 10 | 'mysql-live-select': '1.0.6' 11 | }); 12 | 13 | Package.onUse(function(api) { 14 | api.versionsFrom('1.0'); 15 | api.use([ 16 | 'underscore', 17 | 'ddp', 18 | 'tracker' 19 | ]); 20 | 21 | api.addFiles('lib/LiveMysql.js', 'server'); 22 | api.export('LiveMysql', 'server'); 23 | 24 | api.addFiles('lib/MysqlSubscription.js'); 25 | api.export('MysqlSubscription'); 26 | }); 27 | 28 | Package.onTest(function(api) { 29 | api.use([ 30 | 'tinytest', 31 | 'templating', 32 | 'underscore', 33 | 'autopublish', 34 | 'insecure', 35 | 'http', 36 | 'grigio:babel@0.1.1', 37 | 'simple:rest@1.1.1', 38 | 'numtel:mysql', 39 | ]); 40 | api.use('test-helpers'); // Did not work concatenated above 41 | api.addFiles([ 42 | 'test/helpers/expectResult.js', 43 | 'test/helpers/randomString.js' 44 | ]); 45 | 46 | api.addFiles([ 47 | 'test/fixtures/tpl.html', 48 | 'test/fixtures/tpl.js' 49 | ], 'client'); 50 | 51 | api.addFiles([ 52 | 'test/helpers/queryEx.js', 53 | 'test/helpers/querySequence.js', 54 | 'test/index.es6' 55 | ], 'server'); 56 | 57 | api.addFiles([ 58 | 'test/MysqlSubscription.js', 59 | 'test/simple_rest.js' 60 | ]); 61 | }); 62 | -------------------------------------------------------------------------------- /test/benchmark/server.mysql.js: -------------------------------------------------------------------------------- 1 | // numtel:mysql 2 | // MIT License, ben@latenightsketches.com 3 | // test/benchmark/server.mysql.js 4 | 5 | // Provide server for MySQL benchmarks 6 | 7 | var conn = liveDb; 8 | var playersTable = 'benchmark_players'; 9 | Meteor.startup(function(){ 10 | var settings = _.clone(Meteor.settings.mysql); 11 | settings.serverId *= 2; // Unique serverId required 12 | 13 | conn.queryEx('drop table if exists `' + playersTable + '`'); 14 | conn.queryEx([ 15 | "CREATE TABLE `" + playersTable + "` (", 16 | " `id` int(11) NOT NULL AUTO_INCREMENT,", 17 | " `name` varchar(45) DEFAULT NULL,", 18 | " `score` int(11) NOT NULL DEFAULT '0',", 19 | " PRIMARY KEY (`id`)", 20 | ") ENGINE=InnoDB DEFAULT CHARSET=latin1;" 21 | ].join('\n')); 22 | }); 23 | 24 | var connMethods = {}; 25 | connMethods['benchmark_reset'] = function(){ 26 | // Truncate doesn't call delete trigger! 27 | conn.queryEx('delete from `' + playersTable + '`'); 28 | }; 29 | connMethods['benchmark_insert'] = function(count){ 30 | if(typeof count !== 'number' || count < 1 || Math.floor(count) !== count) 31 | throw new Error('invalid-count'); 32 | conn.queryEx(function(esc, escId){ 33 | var query = 'INSERT INTO `' + playersTable + '` (`name`, `score`) VALUES '; 34 | var rows = []; 35 | for(var i = 0; i < count; i++){ 36 | rows.push('(' + esc(randomString(10)) + ', ' + 37 | esc(Math.floor(Math.random() * 20) * 5) + ')'); 38 | } 39 | return query + rows.join(', '); 40 | }); 41 | }; 42 | Meteor.methods(connMethods); 43 | 44 | Meteor.publish(playersTable, function(){ 45 | return conn.select( 46 | 'select * from `' + playersTable + '` order by score desc', 47 | [ { table: playersTable } ]); 48 | }); 49 | -------------------------------------------------------------------------------- /lib/LiveMysql.js: -------------------------------------------------------------------------------- 1 | // numtel:mysql 2 | // MIT License, ben@latenightsketches.com 3 | // lib/LiveMysql.js 4 | var Future = Npm.require('fibers/future'); 5 | 6 | LiveMysql = Npm.require('mysql-live-select'); 7 | 8 | // Convert the LiveMysqlSelect object into a cursor 9 | LiveMysql.LiveMysqlSelect.prototype._publishCursor = function(sub) { 10 | var self = this; 11 | var fut = new Future; 12 | var initLength; 13 | 14 | sub.onStop(function(){ 15 | self.stop(); 16 | }); 17 | 18 | // Send reset message (for code pushes) 19 | sub._session.send({ 20 | msg: 'added', 21 | collection: sub._name, 22 | id: sub._subscriptionId, 23 | fields: { reset: true } 24 | }); 25 | 26 | // Send aggregation of differences 27 | self.on('update', function(diff, rows){ 28 | sub._session.send({ 29 | msg: 'added', 30 | collection: sub._name, 31 | id: sub._subscriptionId, 32 | fields: { diff: diff } 33 | }); 34 | 35 | if(sub._ready === false && !fut.isResolved()){ 36 | fut['return'](); 37 | } 38 | }); 39 | 40 | // Do not crash application on publication error 41 | self.on('error', function(error){ 42 | if(!fut.isResolved()){ 43 | fut['throw'](error); 44 | } 45 | }); 46 | 47 | return fut.wait() 48 | } 49 | 50 | // Support for simple:rest 51 | 52 | // Result set data does not exist in a Mongo Collection, provide generic name 53 | LiveMysql.LiveMysqlSelect.prototype._cursorDescription = { collectionName: 'data' }; 54 | 55 | LiveMysql.LiveMysqlSelect.prototype.fetch = function() { 56 | // HttpSubscription object requires _id field for added() method 57 | // Use 'id' method from result set if available or row number otherwise 58 | var dataWithIds = this.queryCache.data.map(function(row, index) { 59 | var clonedRow = _.clone(row); 60 | if(!('_id' in clonedRow)) { 61 | clonedRow._id = String('id' in clonedRow ? clonedRow.id : index + 1); 62 | } 63 | 64 | // Ensure row index is included since response will not be ordered 65 | if(!('_index' in clonedRow)) { 66 | clonedRow._index = index + 1; 67 | } 68 | 69 | return clonedRow; 70 | }); 71 | 72 | return dataWithIds; 73 | } 74 | -------------------------------------------------------------------------------- /test/benchmark/maxVsOrderBy.js: -------------------------------------------------------------------------------- 1 | // numtel:mysql 2 | // MIT License, ben@latenightsketches.com 3 | // test/benchmark/maxVsOrderBy.js 4 | 5 | // Determine performance difference using each MySQL statement: 6 | // MAX() or ORDER BY DESC LIMIT 1 to get highest value from table 7 | if(Meteor.isClient){ 8 | var genMethod = function(useMax){ 9 | return { 10 | run: function(options, done){ 11 | Meteor.call('performSelectMax', useMax, options.count, done); 12 | }, 13 | reset: function(options, done){ 14 | Meteor.call('resetTableMax', options.rows, done); 15 | } 16 | }; 17 | }; 18 | Benchmark.addCase({ 19 | _label: 'Select MAX() vs. ORDER BY DESC LIMIT 1', 20 | _default: { 21 | rows: 5000, 22 | count: 1000, 23 | sampleSize: 3 24 | }, 25 | 'max': genMethod(true), 26 | 'order-by-desc': genMethod(false) 27 | }); 28 | 29 | }else if(Meteor.isServer){ 30 | var conn = liveDb; 31 | var TABLE = 'benchmark_max_vs_orderby'; 32 | 33 | Meteor.startup(function(){ 34 | }); 35 | 36 | Meteor.methods({ 37 | resetTableMax: function(count){ 38 | // Truncate doesn't call delete trigger! 39 | conn.queryEx('delete from `' + TABLE + '`'); 40 | conn.queryEx(function(esc, escId){ 41 | var query = 'INSERT INTO `' + TABLE + '` (`key`, `update`) VALUES '; 42 | var rows = []; 43 | var key; 44 | for(var i = 0; i < count; i++){ 45 | rows.push('(' + i + ', ' + i + ')'); 46 | } 47 | return query + rows.join(', '); 48 | }); 49 | }, 50 | performSelectMax: function(useMax, count){ 51 | var query; 52 | for(var i = 0; i < count; i++){ 53 | query = [ 54 | 'UPDATE `' + TABLE + '` as p', 55 | 'JOIN (', 56 | ]; 57 | if(useMax){ 58 | query = query.concat([ 59 | 'SELECT MAX(p1.`update`) AS max FROM', 60 | '`' + TABLE + '` as p1', 61 | ]); 62 | }else{ 63 | query = query.concat([ 64 | 'SELECT p1.`update` AS max FROM', 65 | '`' + TABLE + '` as p1', 66 | 'ORDER BY p1.`update` DESC LIMIT 1', 67 | ]); 68 | } 69 | query = query.concat([ 70 | ') as g', 71 | 'SET p.`update`= g.max + 1', 72 | 'WHERE `key` = 1' 73 | ]); 74 | conn.queryEx(query.join('\n')); 75 | } 76 | } 77 | }); 78 | } 79 | -------------------------------------------------------------------------------- /test/benchmark/insertMany.js: -------------------------------------------------------------------------------- 1 | // numtel:mysql 2 | // MIT License, ben@latenightsketches.com 3 | // test/benchmark/insertMany.js 4 | 5 | // Benchmark number of inserts published to client per second 6 | // Compare MySQL binlog, Mongo, and Mongo-Direct 7 | var genMysqlTest = function(){ 8 | var subscription = new MysqlSubscription('benchmark_players'); 9 | return { 10 | run: function(options, done){ 11 | var startCount = subscription.length; 12 | subscription.addEventListener('added.insertRows', function(){ 13 | var result; 14 | if(subscription.length === startCount + options.count){ 15 | subscription.removeEventListener(/insertRows/); 16 | done(); 17 | } 18 | }); 19 | Meteor.call('benchmark_insert', options.count, function(error){ 20 | if(error && error.error === 404){ 21 | subscription.removeEventListener(/insertRows/); 22 | done(); 23 | } 24 | done('server'); 25 | }); 26 | }, 27 | reset: function(options, done){ 28 | if(subscription.length === 0) return done(); 29 | Meteor.call('benchmark_reset'); 30 | subscription.addEventListener('removed.resetTable', function(){ 31 | if(subscription.length === 0){ 32 | subscription.removeEventListener(/resetTable/); 33 | done(); 34 | } 35 | }); 36 | } 37 | } 38 | }; 39 | 40 | // Both Mongo tests use same collection 41 | MongoPlayers = new Mongo.Collection('MongoPlayers'); 42 | var genMongoTest = function(meteorMethod){ 43 | return { 44 | run: function(options, done){ 45 | var cursor = MongoPlayers.find(); 46 | var startCount = cursor.count(); 47 | var observer = cursor.observe({ 48 | added: function(){ 49 | if(cursor.count() === startCount + options.count){ 50 | observer.stop(); 51 | done(); 52 | } 53 | } 54 | }); 55 | Meteor.call(meteorMethod, options.count, function(){ done('server'); }); 56 | }, 57 | reset: function(options, done){ 58 | var cursor = MongoPlayers.find(); 59 | if(cursor.count() === 0) return done(); 60 | var observer = cursor.observe({ 61 | removed: function(){ 62 | if(cursor.count() === 0){ 63 | observer.stop(); 64 | done(); 65 | } 66 | } 67 | }); 68 | Meteor.call('resetCollection'); 69 | } 70 | }; 71 | }; 72 | 73 | Benchmark.addCase({ 74 | _label: 'Insert Rows', 75 | _value: 'rate', 76 | _default: { 77 | count: 1000, 78 | sampleSize: 1, 79 | // Explictly specify methods for easy omission 80 | methods: ['mysql-binlog', 'mongo-standard', 'mongo-direct'] 81 | }, 82 | 'mysql-binlog': genMysqlTest(), 83 | 'mongo-standard': genMongoTest('insDocs'), 84 | 'mongo-direct': genMongoTest('insDocsDirect') 85 | }); 86 | -------------------------------------------------------------------------------- /test/index.es6: -------------------------------------------------------------------------------- 1 | // numtel:mysql 2 | // MIT License, ben@latenightsketches.com 3 | // test/index.js 4 | 5 | // Configure publications 6 | var database = Meteor.settings.mysql.database; 7 | if(Meteor.settings.recreateDb){ 8 | // Primarily for Travis CI compat 9 | delete Meteor.settings.mysql.database; 10 | } 11 | 12 | liveDb = new LiveMysql(Meteor.settings.mysql); 13 | liveDb.queryEx = queryEx.bind(liveDb.db); 14 | 15 | if(Meteor.settings.recreateDb){ 16 | querySequence(liveDb.db, [ 17 | 'DROP DATABASE IF EXISTS ' + liveDb.db.escapeId(database), 18 | 'CREATE DATABASE ' + liveDb.db.escapeId(database), 19 | 'USE ' + liveDb.db.escapeId(database), 20 | ]); 21 | } 22 | 23 | Meteor.startup(function(){ 24 | insertSampleData(); 25 | 26 | Meteor.publish('errorRaising', function(){ 27 | return liveDb.select( 28 | 'SELECT * FROM this_will_cause_an_exception ORDER BY score DESC', 29 | [ { database, table: 'this_will_cause_an_exception' } ] 30 | ); 31 | }); 32 | 33 | Meteor.publish('allPlayers', function(limit){ 34 | return liveDb.select( 35 | 'SELECT * FROM players ORDER BY score DESC' + 36 | (typeof limit === 'number' ? ' LIMIT ' + liveDb.db.escape(limit) : ''), 37 | [ { database, table: 'players' } ] 38 | ); 39 | }); 40 | 41 | Meteor.publish('playerScore', function(name){ 42 | return liveDb.select( 43 | `SELECT id, score FROM players WHERE name = ${liveDb.db.escape(name)}`, 44 | [ 45 | { 46 | database, 47 | table: 'players', 48 | condition: function(row, newRow){ 49 | return row.name === name; 50 | } 51 | } 52 | ] 53 | ); 54 | }); 55 | 56 | Meteor.methods({ 57 | 'setScore': function(id, value){ 58 | return querySequence(liveDb.db, [` 59 | UPDATE players 60 | SET score = ${liveDb.db.escape(value)} 61 | WHERE id = ${liveDb.db.escape(id)} 62 | `]); 63 | }, 64 | 'insPlayer': function(name, score){ 65 | return querySequence(liveDb.db, [` 66 | INSERT INTO players (name, score) VALUES 67 | (${liveDb.db.escape(name)}, ${liveDb.db.escape(score)}) 68 | `]); 69 | }, 70 | 'delPlayer': function(name){ 71 | return querySequence(liveDb.db, 72 | [`DELETE FROM players WHERE name = ${liveDb.db.escape(name)}`]); 73 | }, 74 | }); 75 | 76 | }); 77 | 78 | var insertSampleData = function(){ 79 | querySequence(liveDb.db, [ 80 | `DROP TABLE IF EXISTS players`, 81 | `CREATE TABLE players ( 82 | id int(11) NOT NULL AUTO_INCREMENT, 83 | name varchar(45) DEFAULT NULL, 84 | score int(11) NOT NULL DEFAULT '0', 85 | PRIMARY KEY (id) 86 | ) ENGINE=InnoDB DEFAULT CHARSET=latin1;`, 87 | `INSERT INTO players (name, score) VALUES 88 | ('Kepler', 40),('Leibniz',50),('Maxwell',60),('Planck',70);` 89 | ]); 90 | }; 91 | 92 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # numtel:mysql [](https://travis-ci.org/numtel/meteor-mysql) 2 | Reactive MySQL for Meteor 3 | 4 | Provides Meteor integration of the [`mysql-live-select` NPM module](https://github.com/numtel/mysql-live-select), bringing reactive `SELECT` statement result sets from MySQL >= 5.1.15. 5 | 6 | > If you do not have MySQL server already installed, you may use the [`numtel:mysql-server` Meteor Package](https://github.com/numtel/meteor-mysql-server) to bundle the MySQL server directly to your Meteor application. 7 | 8 | * [`numtel:pg` Reactive PostgreSQL for Meteor](https://github.com/numtel/meteor-pg) 9 | * [How to publish joined queries that update efficiently](https://github.com/numtel/meteor-mysql/wiki/Publishing-Efficient-Joined-Queries) 10 | * [Leaderboard example modified to use MySQL](https://github.com/numtel/meteor-mysql-leaderboard) 11 | * [Talk at Meteor Devshop SF, December 2014](https://www.youtube.com/watch?v=EJzulpXZn6g) 12 | 13 | > This documentation covers `numtel:mysql` **>= 1.0.0**. For older versions (0.1.0 - 0.1.14) that used the old difference calculator, see the [the tree from this commit](https://github.com/numtel/meteor-mysql/tree/9edd9ca83388cc82496f87e91153a4a9f51fb5de). Also see the [old documentation for `mysql-live-select` that matches these older versions](https://github.com/numtel/mysql-live-select/tree/89691160b7e1fbfde1ae7055980668ceb4182f8a). 14 | 15 | > For the oldest versions (< 0.1.0) that included the trigger poll table that worked with MySQL < 5.1.15, see the [old branch](https://github.com/numtel/meteor-mysql/tree/old). 16 | 17 | ## Server Implements 18 | 19 | This package provides the `LiveMysql` class as defined in the [`mysql-live-select` NPM package](https://github.com/numtel/mysql-live-select). Be sure to follow the installation instructions for configuring your MySQL server to output the binary log. 20 | 21 | For operations other than `SELECT`, like `UPDATE` and `INSERT`, an active [`node-mysql`](https://github.com/felixge/node-mysql) connection is exposed on the `LiveMysql.db` property. 22 | 23 | ### `LiveMysql.prototype.select()` 24 | 25 | In this Meteor package, the `LiveMysqlSelect` object returned by the `select()` method is modified to act as a cursor that can be published. 26 | 27 | ```javascript 28 | var liveDb = new LiveMysql(Meteor.settings.mysql); 29 | 30 | Meteor.publish('allPlayers', function(){ 31 | return liveDb.select( 32 | `SELECT * FROM players ORDER BY score DESC`, 33 | [ { table: 'players' } ] 34 | ); 35 | }); 36 | ``` 37 | 38 | ## Client/Server Implements 39 | 40 | ### `MysqlSubscription([connection,] name, [args...])` 41 | 42 | Constructor for subscribing to a published select statement. No extra call to `Meteor.subscribe()` is required. Specify the name of the subscription along with any arguments. 43 | 44 | The first argument, `connection`, is optional. If connecting to a different Meteor server, pass the DDP connection object in this first argument. If not specified, the first argument becomes the name of the subscription (string) and the default Meteor server connection will be used. 45 | 46 | The prototype inherits from `Array` and is extended with the following methods: 47 | 48 | Name | Description 49 | -----|-------------------------- 50 | `change([args...])` | Change the subscription's arguments. Publication name and connection are preserved. 51 | `addEventListener(eventName, listener)` | Bind a listener function to this subscription 52 | `removeEventListener(eventName)` | Remove listener functions from an event queue 53 | `dispatchEvent(eventName, [args...])` | Call the listeners for a given event, returns boolean 54 | `depend()` | Call from inside of a Template helper function to ensure reactive updates 55 | `reactive()` | Same as `depend()` except returns self 56 | `changed()`| Signal new data in the subscription 57 | `ready()` | Return boolean value corresponding to subscription fully loaded 58 | `stop()` | Stop updates for this subscription 59 | 60 | **Notes:** 61 | 62 | * `changed()` is automatically called when the query updates and is most likely to only be called manually from a method stub on the client. 63 | * Event listener methods are similar to native methods. For example, if an event listener returns `false` exactly, it will halt listeners of the same event that have been added previously. A few differences do exist though to make usage easier in this context: 64 | * The event name may also contain an identifier suffix using dot namespacing (e.g. `update.myEvent`) to allow removing/dispatching only a subset of listeners. 65 | * `removeEventListener()` and `dispatchEvent()` both refer to listeners by name only. Regular expessions allowed. 66 | * `useCapture` argument is not available. 67 | 68 | #### Event Types 69 | 70 | Name | Listener Arguments | Description 71 | -----|-------------------|----------------------- 72 | `update` | `diff, data` | Data has been updated according to the differences in the `diff` object. 73 | `reset` | `msg` | Subscription reset (most likely due to code-push), before update 74 | 75 | ## Closing connections between hot code-pushes 76 | 77 | With Meteor's hot code-push feature, a new connection the database server is requested with each restart. In order to close old connections, a handler to your application process's `SIGTERM` signal event must be added that calls the `end()` method on each `LiveMysql` instance in your application. Also, a handler for `SIGINT` can be used to close connections on exit. 78 | 79 | On the server-side of your application, add event handlers like this: 80 | 81 | ```javascript 82 | 83 | var liveDb = new LiveMysql(Meteor.settings.mysql); 84 | 85 | var closeAndExit = function() { 86 | liveDb.end(); 87 | process.exit(); 88 | }; 89 | 90 | // Close connections on hot code push 91 | process.on('SIGTERM', closeAndExit); 92 | // Close connections on exit (ctrl + c) 93 | process.on('SIGINT', closeAndExit); 94 | ``` 95 | 96 | ## Tests / Benchmarks 97 | 98 | A MySQL server configured to output the binary log in row mode is required to run the test suite. 99 | 100 | The MySQL connection settings must be configured in `test/settings/local.json`. 101 | 102 | The database specified should be an empty database with no tables because the tests will create and delete tables as needed. 103 | 104 | If you set the `recreateDb` value to true, the test suite will automatically create the database, allowing you to specify a database name that does not yet exist. 105 | 106 | ```bash 107 | # Install Meteor 108 | $ curl -L https://install.meteor.com/ | /bin/sh 109 | 110 | # Clone Repository 111 | $ git clone https://github.com/numtel/meteor-mysql.git 112 | $ cd meteor-mysql 113 | 114 | # Configure database settings in your favorite editor 115 | # (an empty database is suggested) 116 | $ ed test/settings/local.json 117 | 118 | # Run test/benchmark server 119 | $ meteor test-packages --settings test/settings/local.json ./ 120 | 121 | ``` 122 | 123 | ## License 124 | 125 | MIT 126 | -------------------------------------------------------------------------------- /lib/MysqlSubscription.js: -------------------------------------------------------------------------------- 1 | // numtel:mysql 2 | // MIT License, ben@latenightsketches.com 3 | // lib/MysqlSubscription.js 4 | 5 | var selfConnection; 6 | var buffer = []; 7 | 8 | MysqlSubscription = function(connection, name /* arguments */){ 9 | var self = this; 10 | var subscribeArgs; 11 | 12 | if(!(self instanceof MysqlSubscription)){ 13 | throw new Error('use "new" to construct a MysqlSubscription'); 14 | } 15 | 16 | self._events = []; 17 | 18 | if(typeof connection === 'string'){ 19 | // Using default connection 20 | subscribeArgs = Array.prototype.slice.call(arguments, 0); 21 | name = connection; 22 | if(Meteor.isClient){ 23 | connection = Meteor.connection; 24 | }else if(Meteor.isServer){ 25 | if(!selfConnection){ 26 | selfConnection = DDP.connect(Meteor.absoluteUrl()); 27 | } 28 | connection = selfConnection; 29 | } 30 | }else{ 31 | // Subscription arguments does not use the first argument (the connection) 32 | subscribeArgs = Array.prototype.slice.call(arguments, 1); 33 | } 34 | 35 | Tracker.Dependency.call(self); 36 | // Y U No give me subscriptionId, Meteor?! 37 | var subsBefore = _.keys(connection._subscriptions); 38 | _.extend(self, connection.subscribe.apply(connection, subscribeArgs)); 39 | var subsNew = _.difference(_.keys(connection._subscriptions), subsBefore); 40 | if(subsNew.length !== 1) throw new Error('Subscription failed!'); 41 | self.subscriptionId = subsNew[0]; 42 | 43 | buffer.push({ 44 | connection: connection, 45 | name: name, 46 | subscriptionId: self.subscriptionId, 47 | instance: self, 48 | resetOnDiff: false 49 | }); 50 | 51 | // If first store for this subscription name, register it! 52 | if(_.filter(buffer, function(sub){ 53 | return sub.name === name && sub.connection === connection; 54 | }).length === 1){ 55 | connection.registerStore(name, { 56 | update: function(msg){ 57 | var subBuffers = _.filter(buffer, function(sub){ 58 | return sub.subscriptionId === msg.id; 59 | }); 60 | 61 | // If no existing subscriptions match this message's subscriptionId, 62 | // discard message as it is most likely due to a subscription that has 63 | // been destroyed. 64 | // See test/MysqlSubscription :: Quick Change test cases 65 | if(subBuffers.length === 0) return; 66 | 67 | var subBuffer = subBuffers[0]; 68 | var sub = subBuffer.instance; 69 | 70 | if(msg.msg === 'added' && 71 | msg.fields && msg.fields.reset === true){ 72 | // This message indicates a reset of a result set 73 | if(subBuffer.resetOnDiff === false){ 74 | sub.dispatchEvent('reset', msg); 75 | sub.splice(0, sub.length); 76 | } 77 | }else if(msg.msg === 'added' && 78 | msg.fields && 'diff' in msg.fields){ 79 | // Aggregation of changes has arrived 80 | 81 | if(subBuffer.resetOnDiff === true){ 82 | sub.splice(0, sub.length); 83 | subBuffer.resetOnDiff = false; 84 | } 85 | 86 | var newData = applyDiff(sub, msg.fields.diff); 87 | 88 | // Prepend first 2 splice arguments to array 89 | newData.unshift(sub.length); 90 | newData.unshift(0); 91 | 92 | // Update the subscription's data 93 | sub.splice.apply(sub, newData); 94 | 95 | // Emit event for application 96 | sub.dispatchEvent('update', msg.fields.diff, sub); 97 | } 98 | sub.changed(); 99 | } 100 | }); 101 | } 102 | 103 | }; 104 | 105 | // Inherit from Array and Tracker.Dependency 106 | MysqlSubscription.prototype = new Array; 107 | _.extend(MysqlSubscription.prototype, Tracker.Dependency.prototype); 108 | 109 | /* 110 | * Change the arguments for the subscription. Publication name and connection 111 | * are preserved. 112 | */ 113 | MysqlSubscription.prototype.change = function(/* arguments */){ 114 | var self = this; 115 | var selfBuffer = _.filter(buffer, function(sub){ 116 | return sub.subscriptionId === self.subscriptionId; 117 | })[0]; 118 | 119 | self.stop(); 120 | 121 | var connection = selfBuffer.connection; 122 | var subscribeArgs = Array.prototype.slice.call(arguments); 123 | subscribeArgs.unshift(selfBuffer.name); 124 | 125 | var subsBefore = _.keys(connection._subscriptions); 126 | _.extend(self, connection.subscribe.apply(connection, subscribeArgs)); 127 | var subsNew = _.difference(_.keys(connection._subscriptions), subsBefore); 128 | if(subsNew.length !== 1) throw new Error('Subscription failed!'); 129 | self.subscriptionId = selfBuffer.subscriptionId = subsNew[0]; 130 | 131 | selfBuffer.resetOnDiff = true; 132 | }; 133 | 134 | MysqlSubscription.prototype._eventRoot = function(eventName){ 135 | return eventName.split('.')[0]; 136 | }; 137 | 138 | MysqlSubscription.prototype._selectEvents = function(eventName, invert){ 139 | var self = this; 140 | var eventRoot, testKey, testVal; 141 | if(!(eventName instanceof RegExp)){ 142 | eventRoot = self._eventRoot(eventName); 143 | if(eventName === eventRoot){ 144 | testKey = 'root'; 145 | testVal = eventRoot; 146 | }else{ 147 | testKey = 'name'; 148 | testVal = eventName; 149 | } 150 | } 151 | return _.filter(self._events, function(event){ 152 | var pass; 153 | if(eventName instanceof RegExp){ 154 | pass = event.name.match(eventName); 155 | }else{ 156 | pass = event[testKey] === testVal; 157 | } 158 | return invert ? !pass : pass; 159 | }); 160 | }; 161 | 162 | MysqlSubscription.prototype.addEventListener = function(eventName, listener){ 163 | var self = this; 164 | if(typeof listener !== 'function') 165 | throw new Error('invalid-listener'); 166 | self._events.push({ 167 | name: eventName, 168 | root: self._eventRoot(eventName), 169 | listener: listener 170 | }); 171 | }; 172 | 173 | // @param {string} eventName - Remove events of this name, pass without suffix 174 | // to remove all events matching root. 175 | MysqlSubscription.prototype.removeEventListener = function(eventName){ 176 | var self = this; 177 | self._events = self._selectEvents(eventName, true); 178 | }; 179 | 180 | MysqlSubscription.prototype.dispatchEvent = function(eventName /* arguments */){ 181 | var self = this; 182 | var listenerArgs = Array.prototype.slice.call(arguments, 1); 183 | var listeners = self._selectEvents(eventName); 184 | // Newest to oldest 185 | for(var i = listeners.length - 1; i >= 0; i--){ 186 | // Return false to stop further handling 187 | if(listeners[i].listener.apply(self, listenerArgs) === false) return false; 188 | } 189 | return true; 190 | }; 191 | 192 | MysqlSubscription.prototype.reactive = function(){ 193 | var self = this; 194 | self.depend(); 195 | return self; 196 | }; 197 | 198 | // Copied from mysql-live-select for use on the client side 199 | function applyDiff(data, diff) { 200 | data = data.map(function(row, index) { 201 | row = _.clone(row); 202 | row._index = index + 1; 203 | return row; 204 | }); 205 | 206 | var newResults = data.slice(); 207 | 208 | diff.removed !== null && diff.removed.forEach( 209 | function(removed) { newResults[removed._index - 1] = undefined; }); 210 | 211 | // Deallocate first to ensure no overwrites 212 | diff.moved !== null && diff.moved.forEach( 213 | function(moved) { newResults[moved.old_index - 1] = undefined; }); 214 | 215 | diff.copied !== null && diff.copied.forEach(function(copied) { 216 | var copyRow = _.clone(data[copied.orig_index - 1]); 217 | copyRow._index = copied.new_index; 218 | newResults[copied.new_index - 1] = copyRow; 219 | }); 220 | 221 | diff.moved !== null && diff.moved.forEach(function(moved) { 222 | var movingRow = data[moved.old_index - 1]; 223 | movingRow._index = moved.new_index; 224 | newResults[moved.new_index - 1] = movingRow; 225 | }); 226 | 227 | diff.added !== null && diff.added.forEach( 228 | function(added) { newResults[added._index - 1] = added; }); 229 | 230 | var result = newResults.filter(function(row) { return row !== undefined; }); 231 | 232 | return result.map(function(row) { 233 | row = _.clone(row); 234 | delete row._index; 235 | return row; 236 | }); 237 | } 238 | 239 | -------------------------------------------------------------------------------- /test/MysqlSubscription.js: -------------------------------------------------------------------------------- 1 | // numtel:mysql 2 | // MIT License, ben@latenightsketches.com 3 | // test/MysqlSubscription.js 4 | 5 | var SUITE_PREFIX = 'numtel:mysql - MysqlSubscription - '; 6 | var POLL_WAIT = 700; // update allowance 7 | var LOAD_COUNT = 10; 8 | 9 | // Test error handling (should output to console, not hang app) 10 | errorSub = new MysqlSubscription('errorRaising'); 11 | 12 | players = new MysqlSubscription('allPlayers'); 13 | myScore = new MysqlSubscription('playerScore', 'Maxwell'); 14 | 15 | expectedRows = [ // test/index.es6 :: insertSampleData() 16 | { name: 'Planck', score: 70 }, 17 | { name: 'Maxwell', score: 60 }, 18 | { name: 'Leibniz', score: 50 }, 19 | { name: 'Kepler', score: 40 } 20 | ]; 21 | 22 | Tinytest.addAsync(SUITE_PREFIX + 'Initialization', function(test, done){ 23 | Meteor.setTimeout(function(){ 24 | test.isTrue(players.ready()); 25 | test.equal(expectResult(players, expectedRows), true); 26 | done(); 27 | }, POLL_WAIT); 28 | }); 29 | 30 | Tinytest.addAsync(SUITE_PREFIX + 'Insert / Delete Row Sync', 31 | function(test, done){ 32 | var newPlayer = 'Archimedes'; 33 | var updateCount = 0; 34 | players.addEventListener('update.test1', function(diff, data){ 35 | switch(updateCount) { 36 | case 0: test.equal(data.length, 5); break; 37 | case 1: test.equal(data.length, 4); break; 38 | } 39 | updateCount++; 40 | }); 41 | Meteor.call('insPlayer', newPlayer, 100); 42 | Meteor.setTimeout(function(){ 43 | var newExpected = expectedRows.slice(); 44 | newExpected.unshift({ name: newPlayer, score: 100 }); 45 | test.equal(expectResult(players, newExpected), true, 'Row inserted'); 46 | Meteor.call('delPlayer', newPlayer); 47 | Meteor.setTimeout(function(){ 48 | players.removeEventListener(/test1/); 49 | test.equal(expectResult(players, expectedRows), true, 'Row removed'); 50 | done(); 51 | }, POLL_WAIT); 52 | }, POLL_WAIT); 53 | }); 54 | 55 | Tinytest.addAsync(SUITE_PREFIX + 'Conditional Trigger Update', 56 | function(test, done){ 57 | Meteor.setTimeout(function(){ 58 | test.equal(myScore.length, 1); 59 | test.equal(myScore[0].score, 60); 60 | if(Meteor.isClient){ 61 | var testEl = document.getElementById('myScoreTest'); 62 | var testElVal = parseInt(testEl.textContent, 10); 63 | test.equal(testElVal, 60, 'Reactive template'); 64 | } 65 | Meteor.call('setScore', myScore[0].id, 30); 66 | Meteor.setTimeout(function(){ 67 | test.equal(myScore[0].score, 30); 68 | if(Meteor.isClient){ 69 | testElVal = parseInt(testEl.textContent, 10); 70 | test.equal(testElVal, 30, 'Reactive template'); 71 | } 72 | Meteor.call('setScore', myScore[0].id, 60); 73 | done(); 74 | }, POLL_WAIT); 75 | }, POLL_WAIT); 76 | }); 77 | 78 | testAsyncMulti(SUITE_PREFIX + 'Event Listeners', [ 79 | function(test, expect){ 80 | var buffer = 0; 81 | players.addEventListener('test.cow', function(){ buffer++; }); 82 | players.dispatchEvent('test'); 83 | test.equal(buffer, 1, 'Call suffixed listener without specified suffix'); 84 | players.removeEventListener('test'); 85 | players.dispatchEvent('test'); 86 | test.equal(buffer, 1, 'Remove suffixed listener without specified suffix'); 87 | }, 88 | function(test, expect){ 89 | var buffer = 0; 90 | players.addEventListener('test.cow', function(){ buffer++; }); 91 | players.dispatchEvent('test.cow'); 92 | test.equal(buffer, 1, 'Call suffixed listener with specified suffix'); 93 | players.removeEventListener('test.cow'); 94 | players.dispatchEvent('test.cow'); 95 | test.equal(buffer, 1, 'Remove suffixed listener with specified suffix'); 96 | }, 97 | function(test, expect){ 98 | var buffer = 1; 99 | players.addEventListener('cheese', function(value){ buffer+=value; }); 100 | players.dispatchEvent('cheese', 5); 101 | test.equal(buffer, 6, 'Call non-suffixed listener with argument'); 102 | players.removeEventListener('cheese'); 103 | players.dispatchEvent('cheese'); 104 | test.equal(buffer, 6, 'Remove non-suffixed listener'); 105 | }, 106 | function(test, expect){ 107 | var buffer = 1; 108 | players.addEventListener('balloon', function(value){ buffer+=value; }); 109 | players.dispatchEvent(/ball/, 5); 110 | test.equal(buffer, 6, 'Call listener using RegExp'); 111 | players.removeEventListener(/ball/); 112 | players.dispatchEvent(/ball/); 113 | test.equal(buffer, 6, 'Remove listener using RegExp'); 114 | }, 115 | function(test, expect){ 116 | var buffer = 0; 117 | players.addEventListener('test.a', function(){ buffer++; }); 118 | players.addEventListener('test.b', function(){ buffer++; return false; }); 119 | players.dispatchEvent('test'); 120 | test.equal(buffer, 1, 'Call multiple listeners with halt'); 121 | players.removeEventListener('test'); 122 | players.dispatchEvent('test'); 123 | test.equal(buffer, 1, 'Remove multiple listeners'); 124 | } 125 | ]); 126 | 127 | Tinytest.addAsync(SUITE_PREFIX + 'Multiple Connections', function(test, done){ 128 | var newPlayers = []; 129 | var playersStartLength = players.length; 130 | var checkDone = function(){ 131 | if(_.filter(newPlayers, function(player){ 132 | return player.done; 133 | }).length !== LOAD_COUNT) return; 134 | _.each(newPlayers, function(newPlayer){ 135 | Meteor.call('delPlayer', newPlayer.name); 136 | }); 137 | Meteor.setTimeout(function(){ 138 | test.equal(players.length, playersStartLength); 139 | done(); 140 | }, POLL_WAIT * 2); 141 | }; 142 | 143 | for(var i = 0; i < LOAD_COUNT; i++){ 144 | newPlayers.push({ 145 | name: randomString(10), 146 | score: Math.floor(Math.random() * 100) * 5 147 | }); 148 | } 149 | 150 | _.each(newPlayers, function(newPlayer){ 151 | Meteor.call('insPlayer', newPlayer.name, newPlayer.score); 152 | newPlayer.subscription = 153 | new MysqlSubscription('playerScore', newPlayer.name); 154 | newPlayer.subscription.addEventListener('update', function(){ 155 | newPlayer.subscription.removeEventListener('update'); 156 | newPlayer.done = true; 157 | checkDone(); 158 | }); 159 | }); 160 | }); 161 | 162 | Tinytest.addAsync(SUITE_PREFIX + 'Multiple Transactions per Second', 163 | function(test, done){ 164 | var newPlayers = []; 165 | var playersStartLength = players.length; 166 | for(var i = 0; i < LOAD_COUNT; i++){ 167 | newPlayers.push({ 168 | name: randomString(10), 169 | score: Math.floor(Math.random() * 100) * 5 170 | }); 171 | } 172 | 173 | var checkDone = function(){ 174 | if(players.length === playersStartLength){ 175 | test.equal(expectResult(players, expectedRows), true); 176 | players.removeEventListener('update.forDel'); 177 | done(); 178 | } 179 | }; 180 | 181 | players.addEventListener('update.forAdded', function(diff, data) { 182 | if(players.length === playersStartLength + LOAD_COUNT) { 183 | Meteor.setTimeout(function(){ 184 | players.removeEventListener('update.forAdded'); 185 | players.addEventListener('update.forDel', checkDone); 186 | _.each(newPlayers, function(newPlayer){ 187 | Meteor.call('delPlayer', newPlayer.name); 188 | }); 189 | }, POLL_WAIT); 190 | 191 | } 192 | }); 193 | 194 | _.each(newPlayers, function(newPlayer){ 195 | Meteor.call('insPlayer', newPlayer.name, newPlayer.score); 196 | }); 197 | }); 198 | 199 | Tinytest.addAsync(SUITE_PREFIX + 'Stop Method', 200 | function(test, done){ 201 | var testSub = new MysqlSubscription('allPlayers'); 202 | var playersStartLength = players.length; 203 | testSub.addEventListener('update', function(){ 204 | testSub.removeEventListener('update'); 205 | Meteor.setTimeout(function(){ 206 | testSubReady(); 207 | }, 100); 208 | }); 209 | 210 | var testSubReady = function(){ 211 | testSub.addEventListener('update.stop', function(diff, data){ 212 | test.equal(0, 1, 'Event should not have been emitted after stop'); 213 | }); 214 | 215 | testSub.stop(); 216 | 217 | Meteor.call('insPlayer', 'After Stop', 100); 218 | 219 | // Wait to see if added event dispatches 220 | Meteor.setTimeout(function(){ 221 | testSub.removeEventListener('update.stop'); 222 | Meteor.call('delPlayer', 'After Stop'); 223 | players.addEventListener('update.afterStop', function(diff, data){ 224 | if(players.length === playersStartLength) { 225 | players.removeEventListener('update.afterStop'); 226 | done(); 227 | } 228 | }); 229 | }, 200); 230 | }; 231 | }); 232 | 233 | 234 | Tinytest.addAsync(SUITE_PREFIX + 'Change Method to Empty', 235 | function(test, done){ 236 | test.equal(players.length, expectedRows.length); 237 | test.isTrue(players.ready()); 238 | 239 | // Limit players sub to 0 row 240 | players.change(0); 241 | test.isFalse(players.ready()); 242 | 243 | Meteor.setTimeout(function() { 244 | test.equal(players.length, 0); 245 | test.isTrue(players.ready()); 246 | 247 | // Reset players to original state 248 | players.change(); 249 | 250 | Meteor.setTimeout(function() { 251 | test.equal(players.length, expectedRows.length); 252 | done(); 253 | }, POLL_WAIT); 254 | }, POLL_WAIT); 255 | }); 256 | 257 | Tinytest.addAsync(SUITE_PREFIX + 'Change Method', 258 | function(test, done){ 259 | test.equal(players.length, expectedRows.length); 260 | test.isTrue(players.ready()); 261 | 262 | // Limit players sub to 1 row 263 | players.change(1); 264 | test.isFalse(players.ready()); 265 | 266 | Meteor.setTimeout(function() { 267 | test.equal(players.length, 1); 268 | test.isTrue(players.ready()); 269 | 270 | // Reset players to original state 271 | players.change(); 272 | 273 | Meteor.setTimeout(function() { 274 | test.equal(players.length, expectedRows.length); 275 | done(); 276 | }, POLL_WAIT); 277 | }, POLL_WAIT); 278 | }); 279 | 280 | 281 | Tinytest.addAsync(SUITE_PREFIX + 'Quick Change Synchronously', 282 | function(test, done){ 283 | // Change players sub multiple times synchronously 284 | for (var i = 0; i < 10; i++) { 285 | players.change(i); 286 | } 287 | 288 | // Reset to original state 289 | players.change(); 290 | 291 | Meteor.setTimeout(function () { 292 | test.equal(players.length, expectedRows.length); 293 | done(); 294 | }, POLL_WAIT); 295 | }); 296 | 297 | Tinytest.addAsync(SUITE_PREFIX + 'Quick Change Asynchronously', 298 | function(test, done){ 299 | // How many times to change sub arguments? 300 | var LIMIT_MAX = 10; 301 | // Milliseconds between each change 302 | var CHANGE_TIMEOUT = 5; 303 | // Change players sub multiple times asynchronously 304 | var limitArg = 0; 305 | 306 | var nextChange = function() { 307 | // Change to the next state, without necessarily waiting for it to be ready 308 | if(limitArg < LIMIT_MAX) { 309 | limitArg++; 310 | 311 | players.change(limitArg); 312 | Meteor.setTimeout(nextChange, CHANGE_TIMEOUT); 313 | } else { 314 | // At end of possible states 315 | // Reset to original state 316 | players.change(); 317 | 318 | Meteor.setTimeout(function () { 319 | test.equal(players.length, expectedRows.length); 320 | done(); 321 | }, POLL_WAIT); 322 | } 323 | }; 324 | nextChange(); 325 | }); 326 | --------------------------------------------------------------------------------