├── .gitignore ├── .meteor ├── release ├── .gitignore ├── platforms ├── .finished-upgraders ├── packages ├── .id └── versions ├── packages └── meteor-postgres │ ├── lib │ ├── init.js │ ├── client.coffee │ ├── collection.coffee │ ├── sql.coffee │ └── server.coffee │ ├── tests │ ├── db-settings.pg.json │ └── jasmine │ │ ├── collectionSpec.coffee │ │ ├── client │ │ └── clientSpec.coffee │ │ └── server │ │ └── serverSpec.coffee │ ├── package.js │ └── README.md ├── .versions ├── simple-todo.html ├── README.md ├── simple-todo.css └── simple-todo.js /.gitignore: -------------------------------------------------------------------------------- 1 | .npm 2 | -------------------------------------------------------------------------------- /.meteor/release: -------------------------------------------------------------------------------- 1 | METEOR@1.1.0.2 2 | -------------------------------------------------------------------------------- /.meteor/.gitignore: -------------------------------------------------------------------------------- 1 | local 2 | postgresdb 3 | -------------------------------------------------------------------------------- /.meteor/platforms: -------------------------------------------------------------------------------- 1 | server 2 | browser 3 | -------------------------------------------------------------------------------- /packages/meteor-postgres/lib/init.js: -------------------------------------------------------------------------------- 1 | SQL = {} 2 | -------------------------------------------------------------------------------- /packages/meteor-postgres/tests/db-settings.pg.json: -------------------------------------------------------------------------------- 1 | { 2 | "port": 5439 3 | } 4 | -------------------------------------------------------------------------------- /.meteor/.finished-upgraders: -------------------------------------------------------------------------------- 1 | # This file contains information which helps Meteor properly upgrade your 2 | # app when you run 'meteor update'. You should check it into version control 3 | # with your project. 4 | 5 | notices-for-0.9.0 6 | notices-for-0.9.1 7 | 0.9.4-platform-file 8 | notices-for-facebook-graph-api-2 9 | -------------------------------------------------------------------------------- /.meteor/packages: -------------------------------------------------------------------------------- 1 | # Meteor packages used by this project, one per line. 2 | # Check this file (and the other files in this directory) into your repository. 3 | # 4 | # 'meteor add' and 'meteor remove' will edit this file for you, 5 | # but you can also edit it by hand. 6 | 7 | meteor-platform 8 | insecure 9 | storeness:meteor-postgres 10 | -------------------------------------------------------------------------------- /.meteor/.id: -------------------------------------------------------------------------------- 1 | # This file contains a token that is unique to your project. 2 | # Check it into your repository along with the rest of this directory. 3 | # It can be used for purposes such as: 4 | # - ensuring you don't accidentally deploy one app on top of another 5 | # - providing package authors with aggregated statistics 6 | 7 | 26kl8oltc4t01liqtc3 8 | -------------------------------------------------------------------------------- /.versions: -------------------------------------------------------------------------------- 1 | base64@1.0.3 2 | binary-heap@1.0.3 3 | blaze@2.1.2 4 | blaze-tools@1.0.3 5 | callback-hook@1.0.3 6 | check@1.0.5 7 | ddp@1.1.0 8 | deps@1.0.7 9 | ejson@1.0.6 10 | geojson-utils@1.0.3 11 | html-tools@1.0.4 12 | htmljs@1.0.4 13 | id-map@1.0.3 14 | jquery@1.11.3_2 15 | json@1.0.3 16 | local-test:storeness:meteor-postgres@0.2.0 17 | logging@1.0.7 18 | meteor@1.1.6 19 | minifiers@1.1.5 20 | minimongo@1.0.8 21 | mongo@1.1.0 22 | observe-sequence@1.0.6 23 | ordered-dict@1.0.3 24 | random@1.0.3 25 | reactive-var@1.0.5 26 | retry@1.0.3 27 | spacebars@1.0.6 28 | spacebars-compiler@1.0.6 29 | storeness:meteor-postgres@0.2.0 30 | templating@1.1.1 31 | test-helpers@1.0.4 32 | tinytest@1.0.5 33 | tracker@1.0.7 34 | underscore@1.0.3 35 | -------------------------------------------------------------------------------- /packages/meteor-postgres/tests/jasmine/collectionSpec.coffee: -------------------------------------------------------------------------------- 1 | describe 'SQL.Collection', -> 2 | 3 | describe 'initialize', -> 4 | 5 | it 'throws an error if not constructed with `new`', -> 6 | expect( -> SQL.Collection(null)).toThrow(new Error 'Use new to construct a SQL.Collection') 7 | 8 | it 'throws an error if first argument does not exist', -> 9 | expect( -> new SQL.Collection()).toThrow(new Error 'First argument to new SQL.Collection must exist') 10 | 11 | it 'throws an error if first argument is not a string', -> 12 | expect( -> new SQL.Collection(123)).toThrow(new Error 'First argument to new SQL.Collection must be a string or null') 13 | expect( -> new SQL.Collection([])).toThrow(new Error 'First argument to new SQL.Collection must be a string or null') 14 | expect( -> new SQL.Collection({})).toThrow(new Error 'First argument to new SQL.Collection must be a string or null') 15 | expect( -> new SQL.Collection('123')).not.toThrow() 16 | -------------------------------------------------------------------------------- /.meteor/versions: -------------------------------------------------------------------------------- 1 | agershun:alasql@0.2.0 2 | autoupdate@1.2.1 3 | base64@1.0.3 4 | binary-heap@1.0.3 5 | blaze@2.1.2 6 | blaze-tools@1.0.3 7 | boilerplate-generator@1.0.3 8 | callback-hook@1.0.3 9 | check@1.0.5 10 | coffeescript@1.0.6 11 | ddp@1.1.0 12 | deps@1.0.7 13 | ejson@1.0.6 14 | fastclick@1.0.3 15 | geojson-utils@1.0.3 16 | html-tools@1.0.4 17 | htmljs@1.0.4 18 | http@1.1.0 19 | id-map@1.0.3 20 | insecure@1.0.3 21 | jquery@1.11.3_2 22 | json@1.0.3 23 | launch-screen@1.0.2 24 | livedata@1.0.13 25 | logging@1.0.7 26 | meteor@1.1.6 27 | meteor-platform@1.2.2 28 | minifiers@1.1.5 29 | minimongo@1.0.8 30 | mobile-status-bar@1.0.3 31 | mongo@1.1.0 32 | observe-sequence@1.0.6 33 | ordered-dict@1.0.3 34 | random@1.0.3 35 | reactive-dict@1.1.0 36 | reactive-var@1.0.5 37 | reload@1.1.3 38 | retry@1.0.3 39 | routepolicy@1.0.5 40 | session@1.1.0 41 | spacebars@1.0.6 42 | spacebars-compiler@1.0.6 43 | storeness:meteor-postgres@0.2.0 44 | templating@1.1.1 45 | tracker@1.0.7 46 | ui@1.0.6 47 | underscore@1.0.3 48 | url@1.0.4 49 | webapp@1.2.0 50 | webapp-hashing@1.0.3 51 | -------------------------------------------------------------------------------- /simple-todo.html: -------------------------------------------------------------------------------- 1 | 2 | Todo List 3 | 4 | 5 | 6 | 7 | 8 |
9 |
10 |

Todo List

11 |
12 | 13 | 15 |
16 |
17 | 18 | 25 |
26 |
27 | 28 | 33 |
34 | 35 | 36 | 37 | 46 | -------------------------------------------------------------------------------- /packages/meteor-postgres/package.js: -------------------------------------------------------------------------------- 1 | Package.describe({ 2 | name: 'storeness:meteor-postgres', 3 | version: '0.2.2', 4 | summary: 'PostgreSQL support for Meteor', 5 | git: 'https://github.com/storeness/meteor-postgres', 6 | documentation: 'README.md' 7 | }); 8 | 9 | Npm.depends({ 10 | 'pg': '4.3.0' 11 | }); 12 | 13 | Package.onUse(function (api) { 14 | api.versionsFrom('1.1.0.2'); 15 | api.use('coffeescript'); 16 | api.use('underscore'); 17 | api.use('tracker'); 18 | api.use('ddp'); 19 | api.use('agershun:alasql@0.2.0'); 20 | 21 | api.addFiles([ 22 | 'lib/init.js', 23 | 'lib/sql.coffee' 24 | ], ['client', 'server']); 25 | 26 | api.addFiles([ 27 | 'lib/client.coffee' 28 | ], 'client'); 29 | 30 | api.addFiles([ 31 | 'lib/server.coffee' 32 | ], 'server'); 33 | 34 | api.addFiles([ 35 | 'lib/collection.coffee' 36 | ]); 37 | 38 | api.export('SQL'); 39 | }); 40 | 41 | Package.onTest(function (api) { 42 | api.use('sanjo:jasmine@0.15.1'); 43 | api.use('coffeescript'); 44 | api.use('spacebars'); 45 | api.use('underscore'); 46 | api.use('storeness:meteor-postgres'); 47 | 48 | // Start postgres test-server 49 | api.use('numtel:pg-server'); 50 | api.addFiles('tests/db-settings.pg.json'); 51 | 52 | api.addFiles([ 53 | 'tests/jasmine/collectionSpec.coffee' 54 | ]); 55 | 56 | api.addFiles([ 57 | 'tests/jasmine/client/clientSpec.coffee' 58 | ], 'client'); 59 | 60 | api.addFiles([ 61 | 'tests/jasmine/server/serverSpec.coffee' 62 | ], 'server'); 63 | }); 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PostgreSQL + Meteor 2 | 3 | Adds postgres support to Meteor via `SQL.Collection`, which is similar to 4 | `Mongo.Collection` and provides the same functionality namely livequery, pub/sub, latency compensation and client side cache. 5 | 6 | Still I would not recommend using it in production yet as the ORM layer is open for SQL-injections. You can check out [this SQL package blueprint](https://github.com/storeness/sql) for a 7 | possible more sophisticated SQL implementation that could support most popular SQL 8 | Databases, Models and Migrations. 9 | 10 | ### Improvements 11 | 12 | - Tests 13 | - Proper support for IDs (including strings) 14 | - Cleaner code and API 15 | - Support of underscores in table and column names 16 | - Many bug fixes including 17 | - errors on creating existent tables (convenient for server startup) 18 | - postgres client event leak 19 | - alasql column bug 20 | - Working example 21 | 22 | ### Installation 23 | 24 | Run the following from your command line. 25 | 26 | ``` 27 | meteor add storeness:meteor-postgres 28 | ``` 29 | 30 | or add this to your `package.js` 31 | 32 | ``` 33 | api.use('storeness:meteor-postgres'); 34 | ``` 35 | 36 | ### Usage 37 | 38 | To get started you might want to take a look at the [todo-example 39 | code](https://github.com/storeness/meteor-postgres/blob/simple-todo.js). You can run 40 | the code by cloning this repo locally and start it by running 41 | `MP_POSTGRES=postgres://{username}:{password}@{host}:{port}/{database_name} 42 | meteor` inside the cloned directory. 43 | 44 | ### Tests 45 | 46 | To run the test execute the following from your command line. 47 | 48 | ``` 49 | MP_POSTGRES=postgres://{YOUR USERNAME ON THE MACHINE}:numtel@localhost:5439/postgres JASMINE_SERVER_UNIT=1 VELOCITY_TEST_PACKAGES=1 meteor --port 4000 test-packages --driver-package velocity:html-reporter storeness:meteor-postgres 50 | ``` 51 | 52 | and check on [localhost:4000](http://localhost:4000) 53 | 54 | ### Implementation 55 | 56 | We use [Node-Postgres](https://github.com/brianc/node-postgres) on the server and [AlaSQL](https://github.com/agershun/alasql) on the client. 57 | Also thanks to [Meteor-Postgres](http://www.meteorpostgres.com/) as this project 58 | is based on their initial work, but which gets not longer maintained. 59 | 60 | ### License 61 | 62 | Released under the MIT license. See the LICENSE file for more info. 63 | -------------------------------------------------------------------------------- /packages/meteor-postgres/README.md: -------------------------------------------------------------------------------- 1 | # PostgreSQL + Meteor 2 | 3 | Adds postgres support to Meteor via `SQL.Collection`, which is similar to 4 | `Mongo.Collection` and provides the same functionality namely livequery, pub/sub, latency compensation and client side cache. 5 | 6 | Still I would not recommend using it in production yet as the ORM layer is open for SQL-injections. You can check out [this SQL package blueprint](https://github.com/storeness/sql) for a 7 | possible more sophisticated SQL implementation that could support most popular SQL 8 | Databases, Models and Migrations. 9 | 10 | ### Improvements 11 | 12 | - Tests 13 | - Proper support for IDs (including strings) 14 | - Cleaner code and API 15 | - Support of underscores in table and column names 16 | - Many bug fixes including 17 | - errors on creating existent tables (convenient for server startup) 18 | - postgres client event leak 19 | - alasql column bug 20 | - Working example 21 | 22 | ### Installation 23 | 24 | Run the following from your command line. 25 | 26 | ``` 27 | meteor add storeness:meteor-postgres 28 | ``` 29 | 30 | or add this to your `package.js` 31 | 32 | ``` 33 | api.use('storeness:meteor-postgres'); 34 | ``` 35 | 36 | ### Usage 37 | 38 | To get started you might want to take a look at the [todo-example 39 | code](https://github.com/storeness/meteor-postgres/blob/simple-todo.js). You can run 40 | the code by cloning this repo locally and start it by running 41 | `MP_POSTGRES=postgres://{username}:{password}:{url}:{port}/{database_name} 42 | meteor` inside the cloned directory. 43 | 44 | ### Tests 45 | 46 | To run the test execute the following from your command line. 47 | 48 | ``` 49 | MP_POSTGRES=postgres://{YOUR USERNAME ON THE MACHINE}:numtel@localhost:5439/postgres JASMINE_SERVER_UNIT=1 VELOCITY_TEST_PACKAGES=1 meteor --port 4000 test-packages --driver-package velocity:html-reporter storeness:meteor-postgres 50 | ``` 51 | 52 | and check on [localhost:4000](http://localhost:4000) 53 | 54 | ### Implementation 55 | 56 | We use [Node-Postgres](https://github.com/brianc/node-postgres) on the server and [AlaSQL](https://github.com/agershun/alasql) on the client. 57 | Also thanks to [Meteor-Postgres](http://www.meteorpostgres.com/) as this project 58 | is based on their initial work, but which gets not longer maintained. 59 | 60 | ### License 61 | 62 | Released under the MIT license. See the LICENSE file for more info. 63 | -------------------------------------------------------------------------------- /simple-todo.css: -------------------------------------------------------------------------------- 1 | /* CSS declarations go here */ 2 | body { 3 | font-family: sans-serif; 4 | background-color: #315481; 5 | background-image: linear-gradient(to bottom, #315481, #918e82 100%); 6 | background-attachment: fixed; 7 | 8 | position: absolute; 9 | top: 0; 10 | bottom: 0; 11 | left: 0; 12 | right: 0; 13 | 14 | padding: 0; 15 | margin: 0; 16 | 17 | font-size: 14px; 18 | } 19 | 20 | .container { 21 | max-width: 600px; 22 | margin: 0 auto; 23 | min-height: 100%; 24 | background: white; 25 | } 26 | 27 | header { 28 | background: #d2edf4; 29 | background-image: linear-gradient(to bottom, #d0edf5, #e1e5f0 100%); 30 | padding: 20px 15px 15px 15px; 31 | position: relative; 32 | } 33 | 34 | #login-buttons { 35 | display: block; 36 | } 37 | 38 | h1 { 39 | font-size: 1.5em; 40 | margin: 0; 41 | margin-bottom: 10px; 42 | display: inline-block; 43 | margin-right: 1em; 44 | } 45 | 46 | form { 47 | margin-top: 10px; 48 | margin-bottom: -10px; 49 | position: relative; 50 | } 51 | 52 | .new-user input { 53 | box-sizing: border-box; 54 | padding: 10px 0; 55 | background: transparent; 56 | border: none; 57 | width: 100%; 58 | padding-right: 80px; 59 | font-size: 1em; 60 | } 61 | 62 | .new-user input:focus{ 63 | outline: 0; 64 | } 65 | 66 | .new-task input { 67 | box-sizing: border-box; 68 | padding: 10px 0; 69 | background: transparent; 70 | border: none; 71 | width: 100%; 72 | padding-right: 80px; 73 | font-size: 1em; 74 | } 75 | 76 | .new-task input:focus{ 77 | outline: 0; 78 | } 79 | 80 | ul { 81 | margin: 0; 82 | padding: 0; 83 | background: white; 84 | } 85 | 86 | .delete { 87 | float: right; 88 | font-weight: bold; 89 | background: none; 90 | font-size: 1em; 91 | border: none; 92 | position: relative; 93 | } 94 | 95 | li { 96 | position: relative; 97 | list-style: none; 98 | padding: 15px; 99 | border-bottom: #eee solid 1px; 100 | } 101 | 102 | li .text { 103 | margin-left: 10px; 104 | } 105 | 106 | li.checked { 107 | color: #888; 108 | } 109 | 110 | li.checked .text { 111 | text-decoration: line-through; 112 | } 113 | 114 | li.private { 115 | background: #eee; 116 | border-color: #ddd; 117 | } 118 | 119 | header .hide-completed { 120 | float: right; 121 | } 122 | 123 | .toggle-private { 124 | margin-left: 5px; 125 | } 126 | 127 | @media (max-width: 600px) { 128 | li { 129 | padding: 12px 15px; 130 | } 131 | 132 | .search { 133 | width: 150px; 134 | clear: both; 135 | } 136 | 137 | .new-task input { 138 | padding-bottom: 5px; 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /packages/meteor-postgres/lib/client.coffee: -------------------------------------------------------------------------------- 1 | SQL.Client = (Collection) -> 2 | Collection = Collection or Object.create SQL.Client:: 3 | Collection.table = Collection.tableName 4 | 5 | Collection.tableElements = {} 6 | SQL.Client::clearAll() 7 | Collection 8 | 9 | 10 | # Load all the shared SQL methods 11 | _.extend SQL.Client::, SQL.Sql:: 12 | 13 | SQL.Client::createTable = (tableObj) -> 14 | alasql.fn.Date = Date 15 | startString = "CREATE TABLE IF NOT EXISTS #{@table} (" 16 | item = undefined 17 | subKey = undefined 18 | valOperator = undefined 19 | inputString = '' 20 | 21 | for key of tableObj 22 | @tableElements[key] = key 23 | inputString += " #{key} " 24 | inputString += @_DataTypes[tableObj[key][0]] 25 | if _.isArray(tableObj[key]) and tableObj[key].length > 1 26 | i = 1 27 | count = tableObj[key].length 28 | while i < count 29 | item = tableObj[key][i] 30 | if _.isObject item 31 | subKey = Object.keys item 32 | inputString += " #{@_TableConstraints[subKey]}#{item[subKey]}" 33 | else 34 | inputString += " #{@_TableConstraints[item]}" 35 | i++ 36 | inputString += ', ' 37 | 38 | # check to see if id already provided 39 | startString += 'id varchar(255) primary key,' if inputString.indexOf(' id') is -1 40 | 41 | @inputString = "#{startString}#{inputString} created_at Date);" 42 | @prevFunc = 'CREATE TABLE' 43 | # create the table 44 | alasql @inputString, @dataArray 45 | @clearAll() 46 | return 47 | 48 | SQL.Client::fetch = (server) -> 49 | @reactiveData?.depend() 50 | 51 | starter = @updateString or @deleteString or @selectString 52 | input = if @inputString.length > 0 then @inputString else starter + @joinString + @whereString + @orderString + @limitString + @offsetString + @groupString + @havingString + ';' 53 | 54 | try 55 | result = alasql(input, @dataArray) 56 | catch e 57 | @clearAll() 58 | 59 | if server is 'server' 60 | input = if @inputString.length > 0 then @inputString else starter + @joinString + @whereString + @orderString + @limitString + @offsetString + @groupString + @havingString + ';' 61 | Meteor.call "#{@table}_fetch", @_convertQueryForServer(input), @dataArray 62 | @clearAll() 63 | result 64 | 65 | SQL.Client::save = (client) -> 66 | starter = @updateString or @deleteString or @selectString 67 | input = if @inputString.length > 0 then @inputString else starter + @joinString + @whereString + ';' 68 | 69 | try 70 | result = alasql(input, @dataArray) 71 | catch e 72 | @clearAll() 73 | 74 | unless client is 'client' 75 | input = if @inputString.length > 0 then @inputString else starter + @joinString + @whereString + ';' 76 | @unvalidated = true 77 | Meteor.call "#{@table}_save", @_convertQueryForServer(input), @dataArray 78 | 79 | @reactiveData.changed() if @reactiveData 80 | @clearAll() 81 | result 82 | 83 | SQL.Client::_convertQueryForServer = (input) -> 84 | counter = 1 85 | _.map(input.split(''), (character) -> if character is "?" then "$#{counter++}" else character).join('') 86 | -------------------------------------------------------------------------------- /simple-todo.js: -------------------------------------------------------------------------------- 1 | // Defining 2 SQL collections. The additional paramater is the postgres connection string which will only run on the server 2 | tasks = new SQL.Collection('tasks'); 3 | username = new SQL.Collection('username'); 4 | 5 | if (Meteor.isClient) { 6 | var newUser = 'all'; 7 | var taskTable = { 8 | id: ['$number'], 9 | text: ['$string', '$notnull'], 10 | checked: ['$bool'], 11 | usernameid: ['$number'] 12 | }; 13 | 14 | tasks.createTable(taskTable); 15 | 16 | var usersTable = { 17 | id: ['$number'], 18 | name: ['$string', '$notnull'] 19 | }; 20 | username.createTable(usersTable); 21 | 22 | 23 | Template.body.helpers({ 24 | usernames: function () { 25 | return username.select().fetch(); 26 | }, 27 | tasks: function () { 28 | if (newUser === 'all'){ 29 | return tasks.select('tasks.id', 'tasks.text', 'tasks.checked', 'tasks.created_at', 'username.name') 30 | .join(['OUTER JOIN'], ['usernameid'], [['username', ['id']]]) 31 | .fetch(); 32 | } 33 | else { 34 | return tasks.select('tasks.id', 'tasks.text', 'tasks.checked', 'tasks.created_at', 'username.name') 35 | .join(['OUTER JOIN'], ['usernameid'], [['username', ['id']]]) 36 | .where("name = ?", newUser) 37 | .fetch(); 38 | } 39 | } 40 | }); 41 | 42 | 43 | Template.body.events({ 44 | "submit .new-task": function (event) { 45 | if (event.target.category.value){ 46 | var user = username.select() 47 | .where("name = ?", event.target.category.value) 48 | .fetch(); 49 | user = user[0].id; 50 | var text = event.target.text.value; 51 | tasks.insert({ 52 | text:text, 53 | checked:false, 54 | usernameid: user 55 | }).save(); 56 | event.target.text.value = ""; 57 | } else{ 58 | alert("please add a user first"); 59 | } 60 | return false; 61 | }, 62 | "submit .new-user": function (event) { 63 | var text = event.target.text.value; 64 | username.insert({ 65 | name:text 66 | }).save(); 67 | event.target.text.value = ""; 68 | 69 | return false; 70 | }, 71 | "click .toggle-checked": function () { 72 | tasks.update({id: this.id, "checked": !this.checked}) 73 | .where("id = ?", this.id) 74 | .save(); 75 | }, 76 | "click .delete": function () { 77 | tasks.remove() 78 | .where("id = ?", this.id) 79 | .save(); 80 | }, 81 | "change .catselect": function(event){ 82 | newUser = event.target.value; 83 | tasks.reactiveData.changed(); 84 | } 85 | }); 86 | } 87 | 88 | if (Meteor.isServer) { 89 | tasks.createTable({text: ['$string'], checked: ["$bool", {'$default': false}], usernameid: ['$string']}); 90 | username.createTable({name: ['$string', '$unique']}); 91 | 92 | username.insert({name:'all'}).save(); 93 | 94 | tasks.publish('tasks', function(){ 95 | return tasks.select('tasks.id as id', 'tasks.text', 'tasks.checked', 'tasks.created_at', 'username.id as usernameid', 'username.name') 96 | .join(['INNER JOIN'], ["usernameid"], [["username", 'id']]) 97 | .order('created_at DESC') 98 | .limit(100); 99 | }); 100 | 101 | username.publish('username', function(){ 102 | return username.select('id', 'name') 103 | .order('created_at DESC') 104 | .limit(100); 105 | }); 106 | } 107 | -------------------------------------------------------------------------------- /packages/meteor-postgres/tests/jasmine/client/clientSpec.coffee: -------------------------------------------------------------------------------- 1 | describe 'SQL.Server', -> 2 | 3 | tableTestTasks = 4 | text: ['$string', '$notnull'] 5 | 6 | tableTestUsers = 7 | username: ['$string', '$notnull'] 8 | age: ['$number'] 9 | 10 | sqlStub = (name) -> 11 | stub = SQL.Client() 12 | stub.table = name 13 | stub 14 | 15 | testTasks = sqlStub 'test_tasks' 16 | testUsers = sqlStub 'test_users' 17 | 18 | beforeEach (done) -> 19 | try 20 | testTasks.dropTable().save() 21 | testUsers.dropTable().save() 22 | catch e 23 | console.error e 24 | 25 | testTasks.createTable(tableTestTasks) 26 | _(3).times (n) -> testTasks.insert({ id: "#{n+1}", text: "testing#{n + 1}" }).save() 27 | _(5).times (n) -> testTasks.insert({ id: "#{n+1+3}", text: "testing1" }).save() 28 | 29 | 30 | testUsers.createTable(tableTestUsers) 31 | _(3).times (n) -> 32 | testUsers.insert({ id: "#{n*2+1}", username: "eddie#{n + 1}", age: 2 * n }).save() 33 | _(3).times (n) -> 34 | testUsers.insert({ id: "#{n*2+2}", username: "paulo", age: 27 }).save() 35 | done() 36 | 37 | describe 'createTable', -> 38 | 39 | it 'has string IDs', -> 40 | console.log testTasks.select().fetch() 41 | result = testTasks.findOne().fetch() 42 | expect(result[0].id).toEqual(jasmine.any(String)) 43 | 44 | describe 'fetch', -> 45 | 46 | describe 'findOne', -> 47 | 48 | it 'returns first object without argument', -> 49 | result = testTasks.findOne().fetch() 50 | expect(result).toEqual(jasmine.any(Array)) 51 | expect(result?.length).toBe(1) 52 | expect(result[0]).toEqual(jasmine.any(Object)) 53 | expect(result[0].text).toEqual('testing1') 54 | 55 | it 'returns object with id as argument', -> 56 | result = testTasks.findOne('3').fetch() 57 | expect(result).toEqual(jasmine.any(Array)) 58 | expect(result?.length).toBe(1) 59 | expect(result[0]).toEqual(jasmine.any(Object)) 60 | expect(result[0].text).toEqual('testing3') 61 | 62 | describe 'where', -> 63 | 64 | it 'works with basic where', -> 65 | string_where = testTasks.select().where('text = ?', 'testing1').fetch() 66 | expect(string_where?.length).toBe(6) 67 | _.each string_where, (row) -> expect(row?.text).toBe('testing1') 68 | 69 | array_where = testTasks.select().where('text = ?', ['testing1']).fetch() 70 | expect(JSON.stringify(array_where)).toEqual(JSON.stringify(string_where)) 71 | 72 | it 'works with basic where and limit', -> 73 | result = testTasks.select().where('text = ?', 'testing1').limit(3).fetch() 74 | expect(result.length).toBe(3) 75 | _.each result, (row) -> expect(row?.text).toBe('testing1') 76 | 77 | it 'works with array where', -> 78 | result = testTasks.select().where('text = ?', ['testing1', 'testing2']).fetch() 79 | expect(result.length).toBe(7) 80 | expect(result[1].text).toBe('testing2') 81 | 82 | it 'works with multiple placeholder', -> 83 | result = testTasks.select().where('id = ? AND text = ?', '2', 'testing2').fetch() 84 | expect(result.length).toBe(1) 85 | 86 | it 'works with multiple placeholders and array wheres', -> 87 | result = testTasks.select().where('id = ? AND text = ?', ['1', '2', '3'], ['testing1', 'testing2']).fetch() 88 | expect(result.length).toBe(2) 89 | expect(result[0].id).toBe('1') 90 | expect(result[1].id).toBe('2') 91 | expect(result[0].text).toBe('testing1') 92 | expect(result[1].text).toBe('testing2') 93 | 94 | describe 'order', -> 95 | 96 | it 'orders correct and ASC by default', -> 97 | asc_default = testTasks.select().order('text').fetch() 98 | asc = testTasks.select().order('text ASC').fetch() 99 | desc = testTasks.select().order('text DESC').fetch() 100 | expect(JSON.stringify(asc_default)).toEqual(JSON.stringify(asc)) 101 | expect(JSON.stringify(asc_default)).not.toEqual(JSON.stringify(desc)) 102 | expect(JSON.stringify(asc[6])).toEqual(JSON.stringify(desc[1])) 103 | 104 | describe 'first', -> 105 | 106 | it 'picks the right `first`', -> 107 | first = testTasks.select().where('text = ?', 'testing1').order('id DESC').limit(3).first().fetch() 108 | second = testTasks.select().first(2).fetch() 109 | expect(JSON.stringify(first[0])).toEqual(JSON.stringify(second[0])) 110 | 111 | describe 'last', -> 112 | 113 | it 'picks the right `last`', -> 114 | first = testTasks.select().last(4).fetch() 115 | expect(first[1].id).toEqual('7') 116 | second = testTasks.select().where('text = ?', 'testing1').order('id DESC').limit(3).last().fetch() 117 | expect(JSON.stringify(first[0])).toEqual(JSON.stringify(second[0])) 118 | 119 | describe 'take', -> 120 | 121 | it 'picks the right with `take`', -> 122 | first = testTasks.select().order('id DESC').limit(3).take().fetch() 123 | second = testTasks.select().take().fetch() 124 | expect(JSON.stringify(first)).toEqual(JSON.stringify(second)) 125 | 126 | describe 'save', -> 127 | 128 | describe 'insert', -> 129 | 130 | it 'creates a string ID if none provided on INSERT', -> 131 | testTasks.insert({ text: 'stringIdTest' }).save() 132 | result = testTasks.select().where('text = ?', 'stringIdTest').fetch() 133 | expect(result[0].id).toEqual(jasmine.any(String)) 134 | expect(result[0].id.split('').length).toBeGreaterThan(7) 135 | 136 | describe 'update', -> 137 | 138 | it 'updates correctly with single argument', -> 139 | before = testTasks.select().where('text = ?', 'testing1').fetch() 140 | testTasks.update({ text: 'testing1' }).where('id = ?', '2').save() 141 | after = testTasks.select().where('text = ?', 'testing1').fetch() 142 | expect(before.length + 1).toEqual(after.length) 143 | 144 | it 'calls the server save method', -> 145 | spyOn(Meteor, 'call') 146 | testTasks.update({ text: 'testing1' }).where('id = ?', '2').save() 147 | expect(Meteor.call).toHaveBeenCalled() 148 | expect(Meteor.call.calls.argsFor(0)[0]).toBe('test_tasks_save') 149 | 150 | 151 | it 'updates correctly with multiple arguments', -> 152 | testUsers.update({username: 'PaulOS', age: 100}).where('username = ?', 'paulo').save() 153 | result = testUsers.select().where('username = ?', 'PaulOS').fetch() 154 | expect(result.length).toBe(3) 155 | _.each result, (item) -> 156 | expect(item?.username).toEqual('PaulOS') 157 | expect(item?.age).toBe(100) 158 | 159 | it 'updates not when where does not find entries', -> 160 | testUsers.update({username: 'PaulOS', age: 100}).where('username = ?', 'notexist').save() 161 | result = testUsers.select().where('username = ?', 'PaulOS').fetch() 162 | expect(result.length).toBe(3) 163 | 164 | it 'updates all', -> 165 | first = testTasks.select().where('text = ?', 'testing1').fetch() 166 | testTasks.update( {text: 'testing3'} ).save() 167 | second = testTasks.select().where('text = ?', 'testing3').fetch() 168 | expect(first.length).toBe(7) 169 | expect(second.length).toBe(9) 170 | 171 | describe 'remove', -> 172 | 173 | it 'removes correctly', -> 174 | testTasks.where('id = ?', '2').remove().save() 175 | result = testTasks.select().fetch() 176 | expect(result.length).toBe(8) 177 | 178 | it 'removes all', -> 179 | testTasks.remove().save() 180 | result = testTasks.select().fetch() 181 | expect(result.length).toBe(0) 182 | -------------------------------------------------------------------------------- /packages/meteor-postgres/lib/collection.coffee: -------------------------------------------------------------------------------- 1 | buffer = [] 2 | 3 | ###* 4 | # @summary Namespace for SQL-related items 5 | # @namespace 6 | ### 7 | 8 | SQL.Collection = (connection) -> 9 | unless @ instanceof SQL.Collection 10 | throw new Error 'Use new to construct a SQL.Collection' 11 | 12 | @unvalidated = false 13 | @reactiveData = new (Tracker.Dependency) 14 | @tableName = connection 15 | @table = connection 16 | @saveMethod = @tableName + '_save' 17 | @fetchMethod = @tableName + '_fetch' 18 | @_events = [] 19 | 20 | unless @tableName 21 | throw new Error 'First argument to new SQL.Collection must exist' 22 | 23 | unless _.isNull(@tableName) or _.isString(@tableName) 24 | throw new Error 'First argument to new SQL.Collection must be a string or null' 25 | 26 | SQL.Client(@) if Meteor.isClient 27 | SQL.Server(@) if Meteor.isServer 28 | 29 | if Meteor.isClient 30 | # Added will only be triggered on the initial population of the database client side. 31 | # Data added to any client while the page is already loaded will trigger a 'changed event' 32 | @addEventListener 'added', (index, msg, name) -> 33 | @remove().save 'client' 34 | @insert(message).save 'client' for message in msg.results 35 | # Triggering Meteor's reactive data to allow for full stack reactivity 36 | return 37 | 38 | # Changed will be triggered whenever the server database changed while the client has the page open. 39 | # This could happen from an addition, an update, or a removal, from that specific client, or another client 40 | @addEventListener 'changed', (index, msg, name) -> 41 | if msg.removed 42 | @remove().where('id = ?', msg.tableId).save 'client' 43 | else if msg.modified 44 | @update(msg.results).where('id = ?', msg.results.id).save 'client' 45 | else 46 | # The message is a new insertion of a message 47 | # If the message was submitted by this client then the insert message triggered 48 | # by the server should be an update rather than an insert 49 | # We use the unvalidated boolean variabe to keep track of this 50 | if @unvalidated 51 | @update(msg.results).where('id = ?', -1).save 'client' 52 | @unvalidated = false 53 | else 54 | # The data was added by another client so just a regular insert 55 | @insert(msg.results).save 'client' 56 | return 57 | 58 | # setting up the connection between server and client 59 | selfConnection = undefined 60 | subscribeArgs = undefined 61 | if _.isString connection 62 | subscribeArgs = Array::slice.call arguments, 0 63 | name = connection 64 | connection = Meteor.connection if Meteor.isClient 65 | connection = DDP.connect Meteor.absoluteUrl() if Meteor.isServer 66 | else 67 | # SQL.Collection arguments does not use the first argument (the connection) 68 | subscribeArgs = Array::slice.call arguments, 1 69 | 70 | subsBefore = _.keys connection._subscriptions 71 | _.extend @, connection.subscribe.apply(connection, subscribeArgs) 72 | subsNew = _.difference(_.keys(connection._subscriptions), subsBefore) 73 | 74 | unless subsNew.length is 1 75 | throw new Error 'Subscription failed!' 76 | 77 | @subscriptionId = subsNew[0] 78 | buffer.push 79 | connection: connection 80 | name: name 81 | subscriptionId: @subscriptionId 82 | instance: @ 83 | 84 | # If first store for this subscription name, register it! 85 | if _.filter(buffer, ((sub) -> sub.name is name and sub.connection is connection)).length is 1 86 | registerStore connection, name 87 | return 88 | 89 | #The code below is originally from Numtel's meteor-mysql but adapted for the purposes of this project (https://github.com/numtel/meteor-mysql/blob/8d7ce8458892f6b255618d884fcde0ec4d04039b/lib/MysqlSubscription.js) 90 | 91 | registerStore = (connection, name) -> 92 | connection.registerStore name, 93 | beginUpdate: (batchSize, reset) -> 94 | update: (msg) -> 95 | idSplit = msg.id.split(':') 96 | sub = _.filter(buffer, (sub) -> sub.subscriptionId == idSplit[0] )[0].instance 97 | if idSplit.length is 1 and msg.msg is 'added' and msg.fields and msg.fields.reset is true 98 | # This message indicates a reset of a result set 99 | sub.dispatchEvent 'reset', msg 100 | sub.splice 0, sub.length 101 | else 102 | index = msg.id 103 | oldRow = undefined 104 | sub.dispatchEvent 'update', index, msg 105 | switch msg.msg 106 | when 'added' 107 | sub.splice index, 0, msg.fields 108 | sub.dispatchEvent msg.msg, index, msg.fields, msg.collection 109 | when 'changed' 110 | sub.splice index, 0, msg.fields 111 | sub.dispatchEvent msg.msg, index, msg.fields, msg.collection 112 | sub.changed() 113 | return 114 | endUpdate: -> 115 | saveOriginals: -> 116 | retrieveOriginals: -> 117 | return 118 | 119 | # Inherit from Array and Tracker.Dependency 120 | SQL.Collection:: = new Array 121 | _.extend(SQL.Collection::, Tracker.Dependency::) 122 | _.extend(SQL.Collection::, SQL.Client::) if Meteor.isClient 123 | _.extend(SQL.Collection::, SQL.Server::) if Meteor.isServer 124 | 125 | SQL.Collection::publish = (collname, pubFunc) -> 126 | methodObj = {} 127 | context = @ 128 | 129 | methodObj[@saveMethod] = (input, dataArray) -> 130 | context.save input, dataArray, (error, result) -> 131 | if error 132 | console.error error.message, input 133 | 134 | methodObj[@fetchMethod] = (input, dataArray) -> 135 | context.fetch input, dataArray, (error, result) -> 136 | if error 137 | console.error error.message, input 138 | 139 | Meteor.methods methodObj 140 | Meteor.publish collname, -> 141 | # For this implementation to work you must call getCursor and provide a callback with the select 142 | # statement that needs to be reactive. The 'caboose' on the chain of calls must be autoSelect 143 | # and it must be passed the param 'sub' which is defining in the anon function. 144 | # This is a limitation of our implementation and will be fixed in later versions 145 | { _publishCursor: (sub) -> 146 | pubFunc().autoSelect sub 147 | } 148 | return 149 | 150 | SQL.Collection::_eventRoot = (eventName) -> 151 | eventName.split('.')[0] 152 | 153 | SQL.Collection::_selectEvents = (eventName, invert) -> 154 | eventRoot = undefined 155 | testKey = undefined 156 | testVal = undefined 157 | unless eventName instanceof RegExp 158 | eventRoot = @_eventRoot(eventName) 159 | if eventName is eventRoot 160 | testKey = 'root' 161 | testVal = eventRoot 162 | else 163 | testKey = 'name' 164 | testVal = eventName 165 | _.filter @_events, (event) -> 166 | pass = undefined 167 | if eventName instanceof RegExp 168 | pass = event.name.match(eventName) 169 | else 170 | pass = event[testKey] is testVal 171 | if invert then !pass else pass 172 | 173 | SQL.Collection::addEventListener = (eventName, listener) -> 174 | unless _.isFunction listener 175 | throw new Error 'invalid-listener' 176 | 177 | @_events.push 178 | name: eventName 179 | root: @_eventRoot eventName 180 | listener: listener 181 | 182 | SQL.Collection::initialValue = (eventName, listener) -> 183 | Postgres.select @tableName 184 | 185 | # @param {string} eventName - Remove events of this name, pass without suffix 186 | # to remove all events matching root. 187 | 188 | SQL.Collection::removeEventListener = (eventName) -> 189 | @_events = @_selectEvents eventName, true 190 | 191 | SQL.Collection::dispatchEvent = (eventName) -> 192 | listenerArgs = Array::slice.call arguments, 1 193 | listeners = @_selectEvents eventName 194 | # Newest to oldest 195 | i = listeners.length - 1 196 | while i >= 0 197 | # Return false to stop further handling 198 | if listeners[i].listener.apply(@, listenerArgs) is false 199 | return false 200 | i-- 201 | true 202 | 203 | SQL.Collection::reactive = -> 204 | @depend() 205 | @ 206 | 207 | -------------------------------------------------------------------------------- /packages/meteor-postgres/lib/sql.coffee: -------------------------------------------------------------------------------- 1 | SQL.Sql = -> 2 | 3 | 4 | ###* 5 | # Data Types 6 | # @type {{$number: string, $string: string, $json: string, $datetime: string, $float: string, $seq: string, $bool: string}} 7 | # @private 8 | ### 9 | 10 | SQL.Sql::_DataTypes = 11 | $number: 'integer' 12 | $string: 'varchar(255)' 13 | $json: 'json' 14 | $datetime: 'date' 15 | $float: 'decimal' 16 | $seq: 'serial' 17 | $bool: 'boolean' 18 | $timestamp: 'timestamp' 19 | 20 | ###* 21 | # Table Constraints 22 | # @type {{$unique: string, $check: string, $exclude: string, $notnull: string, $default: string, $primary: string}} 23 | # @private 24 | ### 25 | 26 | SQL.Sql::_TableConstraints = 27 | $unique: 'unique' 28 | $check: 'check ' 29 | $exclude: 'exclude' 30 | $notnull: 'not null' 31 | $default: 'default ' 32 | $primary: 'primary key' 33 | 34 | ###* 35 | # Notes: Deletes cascade 36 | # SQL: DROP TABLE 37 | ### 38 | 39 | SQL.Sql::dropTable = -> 40 | @inputString = "DROP TABLE IF EXISTS #{@table} CASCADE; DROP FUNCTION IF EXISTS notify_trigger_#{@table}() CASCADE;" 41 | @prevFunc = 'DROP TABLE' 42 | @ 43 | 44 | ###* 45 | # SQL: INSERT INTO
() VALUES () 46 | # Type: Query 47 | # @param insertObj 48 | ### 49 | 50 | SQL.Sql::insert = (insertObj) -> 51 | valueString = ') VALUES (' 52 | insertObj.id ||= Random.id(19) 53 | 54 | keys = Object.keys insertObj 55 | insertString = "INSERT INTO #{@table} (" 56 | @dataArray = [] 57 | 58 | # iterate through array arguments to populate input string parts 59 | for i in [0..keys.length-1] 60 | insertString += "#{keys[i]}, " 61 | @dataArray.push insertObj[keys[i]] 62 | valueString += "$#{i+1}, " if Meteor.isServer 63 | valueString += '?, ' if Meteor.isClient 64 | 65 | @inputString = "#{insertString.substring(0, insertString.length - 2)}#{valueString.substring(0, valueString.length - 2)});" 66 | @prevFunc = 'INSERT' 67 | @ 68 | 69 | ###* 70 | # SQL: UPDATE
SET () = () 71 | # Type: Statement Starter 72 | # @param {object} updatesObj 73 | # @param {string} updatesObj Key (Field) 74 | # @param {string} updatesObj Value (Data) 75 | ### 76 | 77 | SQL.Sql::update = (updatesObj) -> 78 | updateField = '' 79 | keys = Object.keys updatesObj 80 | 81 | for i in [0..keys.length-1] 82 | updateField += "#{keys[i]} = #{if _.isString(updatesObj[keys[i]]) then "'#{updatesObj[keys[i]]}'" else updatesObj[keys[i]] }, " 83 | 84 | 85 | @updateString = "UPDATE #{@table} SET #{updateField[0..-3]}" 86 | @prevFunc = 'UPDATE' 87 | @ 88 | 89 | ###* 90 | # SQL: DELETE FROM table 91 | # Type: Statement Starter 92 | # Notes: If not chained with where it will remove all rows 93 | ### 94 | 95 | SQL.Sql::remove = -> 96 | @deleteString = "DELETE FROM #{@table}" 97 | @prevFunc = 'DELETE' 98 | @ 99 | 100 | ###* 101 | # Parameters: fields (arguments, optional) 102 | # SQL: SELECT fields FROM table, SELECT * FROM table 103 | # Special: May pass table, distinct, field to obtain a single record per unique value 104 | # STATEMENT STARTER/SELECT STRING 105 | # 106 | # SQL: SELECT fields FROM table, SELECT * FROM table 107 | # Type: Statement Starter 108 | # Notes: May pass distinct, field (two separate arguments) to obtain a single record per unique value 109 | # @param {string} [arguments] 110 | # fields to select 111 | ### 112 | 113 | SQL.Sql::select = -> 114 | args = '' 115 | if arguments.length >= 1 116 | for i in [0..arguments.length-1] 117 | args += 'DISTINCT ' if arguments[i] is 'distinct' 118 | args += "#{arguments[i]}, " unless arguments[i] is 'distinct' 119 | args = args.substring(0, args.length - 2) 120 | else 121 | args += '*' 122 | 123 | @selectString = "SELECT #{args} FROM #{@table} " 124 | @prevFunc = 'SELECT' 125 | @ 126 | 127 | ###* 128 | # SQL: SELECT * FROM table WHERE table.id = id LIMIT 1; SELECT * FROM table LIMIT 1; 129 | # Notes: If no id is passed will return random 130 | # Type: Query 131 | # @param {number} [id] 132 | ### 133 | 134 | SQL.Sql::findOne = -> 135 | if arguments.length is 1 136 | @inputString = "SELECT * FROM #{@table} WHERE #{@table}.id = $1 LIMIT 1;" if Meteor.isServer 137 | @inputString = "SELECT * FROM #{@table} WHERE #{@table}.id = ? LIMIT 1;" if Meteor.isClient 138 | @dataArray.push arguments[0] 139 | else 140 | @inputString = "SELECT * FROM #{@table} LIMIT 1;" 141 | 142 | @prevFunc = 'FIND ONE' 143 | @ 144 | 145 | ###* 146 | # SQL: JOIN joinTable ON field = field 147 | # Type: Statement 148 | # Notes: Parameters can also be all arrays 149 | # @param {String} joinType 150 | # @param {String} fields 151 | # @param {String} joinTable 152 | ### 153 | 154 | SQL.Sql::join = (joinType, fields, joinTable) -> 155 | if _.isArray(joinType) 156 | for i in [0..fields.length-1] 157 | @joinString = " #{joinType[i]} #{joinTable[i][0]} ON #{@table}.#{fields[i]} = #{joinTable[i][0]}.#{joinTable[i][1]}" 158 | else 159 | @joinString = " #{joinType} #{joinTable} ON #{@table}.#{fields} = #{joinTable}.#{joinTable}" 160 | 161 | @prevFunc = 'JOIN' 162 | @ 163 | 164 | ###* 165 | # SQL: WHERE field operator comparator, WHERE field1 operator1 comparator1 AND/OR field2 operator2 comparator2, WHERE field IN (x, y) 166 | # Type: Statement 167 | # Notes: 168 | # @param {string} directions 169 | # condition with ?'s for values 170 | # @param {string} values 171 | # values to be used 172 | ### 173 | 174 | SQL.Sql::where = -> 175 | @dataArray = [] 176 | where = '' 177 | redux = undefined 178 | substring1 = undefined 179 | substring2 = undefined 180 | where += arguments[0] 181 | for i in [1..arguments.length-1] 182 | redux = where.indexOf '?' 183 | substring1 = where.substring 0, redux 184 | substring2 = where.substring redux + 1, where.length 185 | 186 | if _.isArray(arguments[i]) 187 | throw new Error('Invalid input: array is empty') if arguments[i].length is 0 188 | if Meteor.isServer 189 | where = "#{substring1}ANY($#{i})#{substring2}" 190 | @dataArray.push arguments[i] 191 | if Meteor.isClient 192 | where = "#{substring1}ANY(#{_.map(arguments[i], (value) -> if _.isNumber(value) then "#{value}, " else "'#{value}', ").join('')[0..-3]})#{substring2}" 193 | else 194 | where = "#{substring1}$#{i}#{substring2}" if Meteor.isServer 195 | where = "#{substring1}?#{substring2}" if Meteor.isClient 196 | @dataArray.push arguments[i] 197 | 198 | @whereString = " WHERE #{where}" 199 | @ 200 | 201 | 202 | ###* 203 | # SQL: ORDER BY fields 204 | # Notes: ASC is default, add DESC after the field name to reverse 205 | # Type: Caboose 206 | # @param {string} fields 207 | ### 208 | 209 | SQL.Sql::order = -> 210 | args = '' 211 | if arguments.length > 1 212 | for i in [0..arguments.length-1] 213 | args += "#{arguments[i]}, " 214 | args = args.substring 0, args.length - 2 215 | else 216 | args = arguments[0] 217 | 218 | @orderString = " ORDER BY #{args}" 219 | @ 220 | 221 | ###* 222 | # SQL: LIMIT number 223 | # Type: Caboose 224 | # @param {number} limit 225 | ### 226 | 227 | SQL.Sql::limit = (limit) -> 228 | @limitString = " LIMIT #{limit}" 229 | @ 230 | 231 | 232 | ###* 233 | # SQL: OFFSET number 234 | # Type: Caboose 235 | # @param {number} offset 236 | ### 237 | 238 | SQL.Sql::offset = (offset) -> 239 | @offsetString = " OFFSET #{offset}" 240 | @ 241 | 242 | ###* 243 | # SQL: GROUP BY field 244 | # Type: Caboose 245 | # @param {string} group 246 | ### 247 | 248 | SQL.Sql::group = (group) -> 249 | @groupString = "GROUP BY #{group}" 250 | @ 251 | 252 | 253 | ###* 254 | # SQL: SELECT * FROM table ORDER BY table.id ASC LIMIT 1, SELECT * FROM table ORDER BY table.id ASC LIMIT limit 255 | # Type: Query 256 | # @param limit 257 | ### 258 | 259 | SQL.Sql::first = (limit) -> 260 | limit = limit or 1 261 | @clearAll() 262 | @inputString += "SELECT * FROM #{@table} ORDER BY #{@table}.id ASC LIMIT #{limit};" 263 | @prevFunc = 'FIRST' 264 | @ 265 | 266 | ###* 267 | # SQL: SELECT * FROM table ORDER BY table.id DESC LIMIT 1, SELECT * FROM table ORDER BY table.id DESC LIMIT limit 268 | # Type: Query 269 | # @param {number} limit 270 | ### 271 | 272 | SQL.Sql::last = (limit) -> 273 | limit = limit or 1 274 | @clearAll() 275 | @inputString += "SELECT * FROM #{@table} ORDER BY #{@table}.id DESC LIMIT #{limit};" 276 | @prevFunc = 'LAST' 277 | @ 278 | 279 | ###* 280 | # SQL: SELECT * FROM table LIMIT 1, SELECT * FROM table LIMIT limit 281 | # Type: Query 282 | # @param {number} limit 283 | # Defaults to 1 284 | ### 285 | 286 | SQL.Sql::take = (limit) -> 287 | limit = limit or 1 288 | @clearAll() 289 | @inputString += "SELECT * FROM #{@table} LIMIT #{limit};" 290 | @prevFunc = 'TAKE' 291 | @ 292 | 293 | ###* 294 | # Type: Maintenance 295 | ### 296 | 297 | SQL.Sql::clearAll = -> 298 | @inputString = '' 299 | @autoSelectData = '' 300 | @autoSelectInput = '' 301 | # statement starters 302 | @selectString = '' 303 | @updateString = '' 304 | @deleteString = '' 305 | # chaining statements 306 | @joinString = '' 307 | @whereString = '' 308 | # caboose statements 309 | @orderString = '' 310 | @limitString = '' 311 | @offsetString = '' 312 | @groupString = '' 313 | @havingString = '' 314 | @dataArray = [] 315 | # error logging 316 | @prevFunc = '' 317 | return 318 | -------------------------------------------------------------------------------- /packages/meteor-postgres/tests/jasmine/server/serverSpec.coffee: -------------------------------------------------------------------------------- 1 | describe 'SQL.Server', -> 2 | 3 | tableTestTasks = 4 | text: ['$string', '$notnull'] 5 | 6 | tableTestUsers = 7 | username: ['$string', '$notnull'] 8 | age: ['$number'] 9 | 10 | sqlStub = (name) -> 11 | stub = SQL.Server() 12 | stub.table = name 13 | stub 14 | 15 | testTasks = sqlStub 'test_tasks' 16 | testUsers = sqlStub 'test_users' 17 | 18 | beforeEach (done) -> 19 | try 20 | testTasks.dropTable().save() 21 | testUsers.dropTable().save() 22 | catch e 23 | testTasks.createTable(tableTestTasks) 24 | _(3).times (n) -> testTasks.insert({ id: "#{n+1}", text: "testing#{n + 1}" }).save() 25 | _(5).times (n) -> testTasks.insert({ id: "#{n+1+3}", text: "testing1" }).save() 26 | 27 | 28 | testUsers.createTable(tableTestUsers) 29 | _(3).times (n) -> 30 | testUsers.insert({ id: "#{n*2+1}", username: "eddie#{n + 1}", age: 2 * n }).save() 31 | testUsers.insert({ id: "#{n*2+2}", username: "paulo", age: 27 }).save() 32 | done() 33 | 34 | describe 'exceptions', -> 35 | 36 | it 'throws no error if an existing table gets created again', -> 37 | expect( -> testTasks.createTable(tableTestTasks)).not.toThrow() 38 | 39 | it 'throws no error if an unknown table gets removed', -> 40 | expect( -> testTasks.dropTable('unknownTable').save()).not.toThrow() 41 | 42 | it 'throws no error if an id gets specifically specified on table creation', -> 43 | expect( -> sqlStub('test_table').createTable({id: ['$string', '$primary'], text: ['$string', '$notnull']})).not.toThrow() 44 | 45 | describe 'fetch', -> 46 | 47 | describe 'findOne', -> 48 | 49 | it 'returns first object without argument', -> 50 | result = testTasks.findOne().fetch()?.rows 51 | expect(result).toEqual(jasmine.any(Array)) 52 | expect(result.length).toBe(1) 53 | expect(result[0]).toEqual(jasmine.any(Object)) 54 | expect(result[0].text).toEqual('testing1') 55 | 56 | it 'returns object with id as argument', -> 57 | result = testTasks.findOne(3).fetch()?.rows 58 | expect(result).toEqual(jasmine.any(Array)) 59 | expect(result.length).toBe(1) 60 | expect(result[0]).toEqual(jasmine.any(Object)) 61 | expect(result[0].text).toEqual('testing3') 62 | 63 | describe 'where', -> 64 | 65 | it 'works with basic where', -> 66 | string_where = testTasks.select().where('text = ?', 'testing1').fetch()?.rows 67 | expect(string_where?.length).toBe(6) 68 | _.each string_where, (row) -> expect(row?.text).toBe('testing1') 69 | 70 | array_where = testTasks.select().where('text = ?', ['testing1']).fetch()?.rows 71 | expect(JSON.stringify(array_where)).toEqual(JSON.stringify(string_where)) 72 | 73 | it 'works with basic where and limit', -> 74 | result = testTasks.select().where('text = ?', 'testing1').limit(3).fetch()?.rows 75 | expect(result.length).toBe(3) 76 | _.each result, (row) -> expect(row?.text).toBe('testing1') 77 | 78 | it 'works with basic where and limit and offset', -> 79 | result = testTasks.select().where('text = ?', 'testing1').limit(3).offset(2).fetch()?.rows 80 | expect(result.length).toBe(3) 81 | _.each result, (row) -> expect(row?.text).toBe('testing1') 82 | 83 | it 'works with basic where and offset', -> 84 | result = testTasks.select().where('text = ?', 'testing1').offset(2).fetch()?.rows 85 | expect(result.length).toBe(4) 86 | _.each result, (row) -> expect(row?.text).toBe('testing1') 87 | 88 | result = testTasks.select().where('text = ?', 'testing1').offset(6).fetch()?.rows 89 | expect(result.length).toBe(0) 90 | 91 | result = testTasks.select().where('text = ?', 'testing1').offset(8).fetch()?.rows 92 | expect(result.length).toBe(0) 93 | 94 | it 'works with array where', -> 95 | result = testTasks.select().where('text = ?', ['testing1', 'testing2']).fetch()?.rows 96 | expect(result.length).toBe(7) 97 | expect(result[1].text).toBe('testing2') 98 | 99 | it 'works with multiple placeholder', -> 100 | result = testTasks.select().where('id = ? AND text = ?', 2, 'testing2').fetch()?.rows 101 | expect(result.length).toBe(1) 102 | 103 | it 'works with multiple placeholders and array wheres', -> 104 | result = testTasks.select().where('id = ? AND text = ?', [1, 2, 3], ['testing1', 'testing2']).fetch()?.rows 105 | expect(result.length).toBe(2) 106 | expect(result[0].id).toBe('1') 107 | expect(result[1].id).toBe('2') 108 | expect(result[0].text).toBe('testing1') 109 | expect(result[1].text).toBe('testing2') 110 | 111 | describe 'order', -> 112 | 113 | it 'orders correct and ASC by default', -> 114 | asc_default = testTasks.select().order('text').fetch()?.rows 115 | asc = testTasks.select().order('text ASC').fetch()?.rows 116 | desc = testTasks.select().order('text DESC').fetch()?.rows 117 | expect(JSON.stringify(asc_default)).toEqual(JSON.stringify(asc)) 118 | expect(JSON.stringify(asc_default)).not.toEqual(JSON.stringify(desc)) 119 | expect(JSON.stringify(asc[6])).toEqual(JSON.stringify(desc[1])) 120 | 121 | it 'orders correct on chains', -> 122 | first = testTasks.select().where('text = ?', 'testing1').order('id DESC').offset(2).limit(3).fetch()?.rows 123 | second = testTasks.select().where('text = ?', 'testing1').offset(2).order('id DESC').limit(3).fetch()?.rows 124 | expect(JSON.stringify(first)).toEqual(JSON.stringify(second)) 125 | 126 | describe 'first', -> 127 | 128 | it 'picks the right `first`', -> 129 | first = testTasks.select().offset(2).where('text = ?', 'testing1').order('id DESC').limit(3).first().fetch()?.rows 130 | second = testTasks.select().first(2).fetch()?.rows 131 | expect(JSON.stringify(first[0])).toEqual(JSON.stringify(second[0])) 132 | 133 | describe 'last', -> 134 | 135 | it 'picks the right `last`', -> 136 | first = testTasks.select().last(4).fetch()?.rows 137 | expect(first[1].id).toEqual('7') 138 | second = testTasks.select().offset(2).where('text = ?', 'testing1').order('id DESC').limit(3).last().fetch()?.rows 139 | expect(JSON.stringify(first[0])).toEqual(JSON.stringify(second[0])) 140 | 141 | describe 'take', -> 142 | 143 | it 'picks the right with `take`', -> 144 | first = testTasks.select().offset(2).order('id DESC').limit(3).take().fetch()?.rows 145 | second = testTasks.select().take().fetch()?.rows 146 | expect(JSON.stringify(first)).toEqual(JSON.stringify(second)) 147 | 148 | describe 'save', -> 149 | 150 | it 'makes an synchronous call when no arguments are specified', -> 151 | result = testTasks.update({ text: 'testing1' }).where('text = ?', 'testing1').save() 152 | expect(result).toEqual(jasmine.any(Object)) 153 | expect(result.command).toBe('UPDATE') 154 | 155 | it 'makes an asynchronous call when a callback is given', (done) -> 156 | testTasks.update({ text: 'testing1' }).where('text = ?', 'testing1').save Meteor.bindEnvironment (error, result) -> 157 | expect(error).toBe(null) 158 | expect(result).toEqual(jasmine.any(Object)) 159 | expect(result.command).toBe('UPDATE') 160 | done() 161 | 162 | it 'makes an asynchronous call with input and data when all three arguments are given', (done) -> 163 | testTasks.save 'UPDATE test_tasks SET (text) = (\'testing1\') WHERE text = $1', ['testing1'], Meteor.bindEnvironment (error, result) -> 164 | expect(error).toBe(null) 165 | expect(result).toEqual(jasmine.any(Object)) 166 | expect(result.command).toBe('UPDATE') 167 | done() 168 | 169 | describe 'update', -> 170 | 171 | it 'updates correctly with single argument', -> 172 | before = testTasks.select().where('text = ?', 'testing1').fetch()?.rows 173 | testTasks.update({ text: 'testing1' }).where('text = ?', 'testing2').save() 174 | after = testTasks.select().where('text = ?', 'testing1').fetch()?.rows 175 | expect(before.length + 1).toEqual(after.length) 176 | 177 | it 'updates correctly with multiple arguments', -> 178 | testUsers.update({username: 'PaulOS', age: 100}).where('username = ?', 'paulo').save() 179 | result = testUsers.select().where('username = ?', 'PaulOS').fetch()?.rows 180 | expect(result.length).toBe(3) 181 | _.each result, (item) -> 182 | expect(item?.username).toEqual('PaulOS') 183 | expect(item?.age).toBe(100) 184 | 185 | it 'updates not when where does not find entries', -> 186 | testUsers.update({username: 'PaulOS', age: 100}).where('username = ?', 'notexist').save() 187 | result = testUsers.select().where('username = ?', 'PaulOS').fetch()?.rows 188 | expect(result.length).toBe(0) 189 | 190 | it 'updates all', -> 191 | first = testTasks.select().where('text = ?', 'testing1').fetch()?.rows 192 | testTasks.update( {text: 'testing3'} ).save() 193 | second = testTasks.select().where('text = ?', 'testing3').fetch()?.rows 194 | expect(first.length).toBe(6) 195 | expect(second.length).toBe(8) 196 | 197 | describe 'remove', -> 198 | 199 | it 'removes correctly', -> 200 | testTasks.where('id = ?', '2').remove().save() 201 | result = testTasks.select().fetch()?.rows 202 | expect(result.length).toBe(7) 203 | 204 | it 'removes all', -> 205 | testTasks.remove().save() 206 | result = testTasks.select().fetch()?.rows 207 | expect(result.length).toBe(0) 208 | -------------------------------------------------------------------------------- /packages/meteor-postgres/lib/server.coffee: -------------------------------------------------------------------------------- 1 | pg = Npm.require('pg') 2 | clientHolder = {} 3 | 4 | removeListeningConnections = -> 5 | for key of clientHolder 6 | clientHolder[key].end() 7 | return 8 | 9 | process.on 'exit', removeListeningConnections 10 | 11 | _.each ['SIGINT', 'SIGHUP', 'SIGTERM'], (sig) -> 12 | process.once sig, -> 13 | removeListeningConnections() 14 | process.kill process.pid, sig 15 | 16 | ###* 17 | # @param Collection 18 | # @constructor 19 | ### 20 | 21 | SQL.Server = (Collection) -> 22 | Collection = Collection or Object.create SQL.Server:: 23 | Collection.table = Collection.tableName 24 | Collection.conString = process.env.MP_POSTGRES or process.env.DATABASE_URL 25 | 26 | SQL.Server::clearAll() 27 | Collection 28 | 29 | 30 | # Load all the shared SQL methods 31 | _.extend SQL.Server::, SQL.Sql:: 32 | 33 | ###* 34 | # SQL: CREATE TABLE field data_type constraint 35 | # Notes: Required for all SQL Collections, must use prescribed data types and table constraints 36 | # Type: Query 37 | # @param tableObj 38 | ### 39 | 40 | SQL.Server::createTable = (tableObj) -> 41 | startString = "CREATE TABLE IF NOT EXISTS \"#{@table}\" (" 42 | item = undefined 43 | subKey = undefined 44 | valOperator = undefined 45 | inputString = '' 46 | 47 | for key of tableObj 48 | inputString += " #{key} " 49 | inputString += @_DataTypes[tableObj[key][0]] 50 | if _.isArray(tableObj[key]) && tableObj[key].length > 1 51 | for i in [1..(tableObj[key].length-1)] 52 | item = tableObj[key][i] 53 | if _.isObject(item) 54 | subKey = Object.keys item 55 | valOperator = @_TableConstraints[subKey] 56 | inputString += " #{valOperator}#{item[subKey]}" 57 | else 58 | inputString += " #{@_TableConstraints[item]}" 59 | inputString += ', ' 60 | 61 | startString += 'id varchar(255) primary key,' if inputString.indexOf(' id') is -1 62 | 63 | watchTrigger = 'watched_table_trigger' 64 | @inputString = """ 65 | #{startString}#{inputString} created_at TIMESTAMP default now()); 66 | 67 | CREATE OR REPLACE FUNCTION notify_trigger_#{@table}() RETURNS trigger AS $$ 68 | BEGIN 69 | IF (TG_OP = 'DELETE') THEN 70 | PERFORM pg_notify('notify_trigger_#{@table}', '[{"' || TG_TABLE_NAME || '":"' || OLD.id || '"}, { "operation": "' || TG_OP || '"}]'); 71 | RETURN old; 72 | ELSIF (TG_OP = 'INSERT') THEN 73 | PERFORM pg_notify('notify_trigger_#{@table}', '[{"' || TG_TABLE_NAME || '":"' || NEW.id || '"}, { "operation": "' || TG_OP || '"}]'); 74 | RETURN new; 75 | ELSIF (TG_OP = 'UPDATE') THEN 76 | PERFORM pg_notify('notify_trigger_#{@table}', '[{"' || TG_TABLE_NAME || '":"' || NEW.id || '"}, { "operation": "' || TG_OP || '"}]'); 77 | RETURN new; 78 | END IF; 79 | END; 80 | $$ LANGUAGE plpgsql; 81 | """ 82 | 83 | 84 | @prevFunc = 'CREATE TABLE' 85 | 86 | executeQuery = Meteor.wrapAsync(@exec, @) 87 | executeQuery @inputString, [] 88 | executeQuery "DROP TRIGGER IF EXISTS #{watchTrigger} ON #{@table};", [] 89 | executeQuery "CREATE TRIGGER #{watchTrigger} AFTER INSERT OR DELETE OR UPDATE ON #{@table} FOR EACH ROW EXECUTE PROCEDURE notify_trigger_#{@table}();", [] 90 | 91 | @clearAll() 92 | return 93 | 94 | ###* 95 | # Make a synchronous or asynchronous select on the table. 96 | # 97 | # This makes an synchronous call and returns the result. Otherwise throws and 98 | # error 99 | # query.fetch() 100 | # 101 | # This makes an asynchronous call and runs the callback after the query has 102 | # executed and returns (error, result) 103 | # query.fetch(function(error, result) { ... }) 104 | # 105 | # This makes an asynchronous call but uses the provided input and data for the 106 | # query 107 | # query.fetch('SOME QUERY', [data as array], function(error, result) { ... }) 108 | # 109 | # Type: Data method 110 | # @param {string} input 111 | # @param {array} data 112 | # @param {function} cb 113 | ### 114 | 115 | SQL.Server::fetch = -> 116 | callback = _.last arguments 117 | input = if arguments.length >= 3 then arguments[0] else undefined 118 | data = if arguments.length >= 3 then arguments[1] else undefined 119 | 120 | data = @dataArray unless data 121 | unless input 122 | starter = @updateString or @deleteString or @selectString 123 | input = if @inputString.length > 0 then @inputString else starter + @joinString + @whereString + @orderString + @limitString + @offsetString + @groupString + @havingString + ';' 124 | 125 | if arguments.length is 0 126 | executeQuery = Meteor.wrapAsync(@exec, @) 127 | result = executeQuery(input, data, callback) 128 | return result 129 | else 130 | @exec input, data, callback 131 | return 132 | 133 | SQL.Server::pg = pg 134 | 135 | SQL.Server::exec = (input, data, cb) -> 136 | pg.connect @conString, (err, client, done) -> 137 | if err and cb 138 | cb err, null 139 | console.log(err) if err 140 | 141 | client.query input, data, (error, results) -> 142 | done() 143 | if cb 144 | cb error, results 145 | @clearAll() 146 | 147 | ###* 148 | # Make a synchronous or asynchronous insert/update/delete on the table. 149 | # 150 | # This makes an synchronous call and returns the result. Otherwise throws and 151 | # error 152 | # query.save() 153 | # 154 | # This makes an asynchronous call and runs the callback after the query has 155 | # executed and returns (error, result) 156 | # query.save(function(error, result) { ... }) 157 | # 158 | # This makes an asynchronous call but uses the provided input and data for the 159 | # query 160 | # query.save('SOME QUERY', [data as array], function(error, result) { ... }) 161 | # 162 | # Type: Data method 163 | # @param {string} input 164 | # @param {array} data 165 | # @param {function} cb 166 | ### 167 | 168 | SQL.Server::save = -> 169 | callback = _.last arguments 170 | input = if arguments.length >= 3 then arguments[0] else undefined 171 | data = if arguments.length >= 3 then arguments[1] else undefined 172 | 173 | data = @dataArray unless data 174 | unless input 175 | starter = @updateString or @deleteString or @selectString 176 | input = if @inputString.length > 0 then @inputString else starter + @joinString + @whereString + ';' 177 | 178 | if arguments.length is 0 179 | executeQuery = Meteor.wrapAsync(@exec, @) 180 | try 181 | result = executeQuery(input, data, callback) 182 | return result 183 | catch e 184 | console.error e.message 185 | return e 186 | else 187 | @exec input, data, callback 188 | return 189 | 190 | ###* 191 | # 192 | # @param sub 193 | ### 194 | 195 | SQL.Server::autoSelect = (sub) -> 196 | # We need a dedicated client to watch for changes on each table. We store these clients in 197 | # our clientHolder and only create a new one if one does not already exist 198 | self = @ 199 | table = @table 200 | strings = {} 201 | strings.select = strings.select or @selectString 202 | strings.join = strings.join or @joinString 203 | strings.prevFunc = @prevFunc 204 | 205 | @autoSelectInput = if @autoSelectInput != '' then @autoSelectInput else @selectString + @joinString + @whereString + @orderString + @limitString + ';' 206 | 207 | @autoSelectData = if @autoSelectData != '' then @autoSelectData else @dataArray 208 | value = @autoSelectInput 209 | @clearAll() 210 | 211 | loadAutoSelectClient = (name, cb) -> 212 | # Function to load a new client, store it, and then send it to the function to add the watcher 213 | client = new pg.Client(process.env.MP_POSTGRES) 214 | client.on 'notification', (msg) -> self._notificationsDDP(sub, strings, msg) 215 | client.connect() 216 | clientHolder[name] = client 217 | cb client 218 | 219 | autoSelectHelper = (client) -> 220 | # Selecting all from the table 221 | client.query value, (error, results) -> 222 | if error 223 | console.error "#{error.message} in autoSelect top" 224 | else 225 | sub._session.send 226 | msg: 'added' 227 | collection: sub._name 228 | id: sub._subscriptionId 229 | fields: 230 | reset: false 231 | results: results.rows 232 | 233 | # Adding notification triggers 234 | query = client.query "LISTEN notify_trigger_#{table}" 235 | client.on 'notification', (msg) -> self._notificationsDDP(sub, strings, msg) 236 | 237 | # Checking to see if this table already has a dedicated client before adding the listener 238 | if clientHolder[table] 239 | autoSelectHelper clientHolder[table] 240 | else 241 | loadAutoSelectClient table, autoSelectHelper 242 | return 243 | 244 | SQL.Server::_notificationsDDP = (sub, strings, msg) -> 245 | message = JSON.parse msg.payload 246 | k = sub._name 247 | if message[1].operation is 'DELETE' 248 | tableId = message[0][k] 249 | sub._session.send 250 | msg: 'changed' 251 | collection: sub._name 252 | id: sub._subscriptionId 253 | index: tableId 254 | fields: 255 | removed: true 256 | reset: false 257 | tableId: tableId 258 | 259 | else if message[1].operation is 'UPDATE' 260 | selectString = "#{strings.select + strings.join} WHERE #{@table}.id = '#{message[0][@table]}'" 261 | pg.connect process.env.MP_POSTGRES, (err, clientSub, done) -> 262 | if err 263 | console.log(err, "in #{prevFunc} #{@table}") 264 | 265 | clientSub.query selectString, @autoSelectData, (error, results) -> 266 | if error 267 | console.error error.message, selectString 268 | else 269 | done() 270 | sub._session.send 271 | msg: 'changed' 272 | collection: sub._name 273 | id: sub._subscriptionId 274 | index: tableId 275 | fields: 276 | modified: true 277 | removed: false 278 | reset: false 279 | results: results.rows[0] 280 | 281 | else if message[1].operation is 'INSERT' 282 | selectString = "#{strings.select + strings.join} WHERE #{@table}.id = '#{message[0][@table]}'" 283 | pg.connect process.env.MP_POSTGRES, (err, clientSub, done) -> 284 | if err 285 | console.log(err, "in #{prevFunc} #{@table}") 286 | 287 | clientSub.query selectString, @autoSelectData, (error, results) -> 288 | if error 289 | console.error error.message, selectString 290 | else 291 | done() 292 | ddpPayload = 293 | msg: 'changed' 294 | collection: sub._name 295 | id: sub._subscriptionId 296 | fields: 297 | removed: false 298 | reset: false 299 | results: results.rows[0] 300 | sub._session.send ddpPayload 301 | --------------------------------------------------------------------------------