mongery 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 8360d8979a97ea0236f28a3aa49bcc35cd9f3861
4
+ data.tar.gz: 6d63c13b93d91147b1e18956dc7e53ebd9555368
5
+ SHA512:
6
+ metadata.gz: 394339097c78801844b502505add3cb95881871b51eca88edda565d56333034893d7e092a28e31a286e1f1c5f59b66cea80db55a51fe850df3aa28464e1ffa99
7
+ data.tar.gz: f9a1ee7321cf2e71f0bc308f0caaa5755016a68ad4be1409b789c5712289331cf36df4df2c097978872def591d91782f4a2b3bfd41e0b8c9e355bed281981e99
data/.gitignore ADDED
@@ -0,0 +1,14 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+ *.bundle
11
+ *.so
12
+ *.o
13
+ *.a
14
+ mkmf.log
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in mongery.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2014 Tatsuhiko Miyagawa
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # Mongery
2
+
3
+ Mongery helps you to migrate off of MongoDB object storage to PostgreSQL with JSON column by translating Mongo query into Arel object, ready to use with ActiveRecord connection.
4
+
5
+ ## Limitation
6
+
7
+ MOngery currently supports the limited set of queries to use with PostgreSQL 9.3. Most query on JSON data will end up with the full table scan. Index using hstore columns and/or 9.4 `jsonb` types are planned but not implemented.
8
+
9
+ See the spec file in `spec` directory for the supported conversion.
10
+
11
+ ## Usage
12
+
13
+ Create a table with the following structure. The name of the table could be anything.
14
+
15
+ ```
16
+ CREATE TABLE objects (
17
+ id varchar(32) not null primary key,
18
+ data json not null,
19
+ updated_at timestamp DEFAULT current_timestamp
20
+ )
21
+ ```
22
+
23
+ Currently Mongery assumes the `id` values are stored as `_id` key duplicated in the json data as well. This can be customized in the future updates.
24
+
25
+ ```ruby
26
+ builder = Mongery::Builder.new(:objects)
27
+
28
+ builder.find({ _id: 'abcd' }).limit(1).to_sql
29
+ # => SELECT data FROM objects WHERE id = 'abcd' LIMIT 1
30
+
31
+ builder.find({ age: {"$gte" => 21 } }).sort({ name: -1 }).to_sql
32
+ # => SELECT data FROM objects WHERE (data->>'age')::integer >= 21 ORDER BY data->>'name' DESC
33
+ ```
34
+
35
+ ## License
36
+
37
+ MIT
38
+
39
+ ## Copyright
40
+
41
+ Tatsuhiko Miyagawa
42
+
data/Rakefile ADDED
@@ -0,0 +1,2 @@
1
+ require "bundler/gem_tasks"
2
+
data/lib/mongery.rb ADDED
@@ -0,0 +1,177 @@
1
+ require "mongery/version"
2
+ require "arel"
3
+
4
+ module Mongery
5
+ # Translate Mongo query to Arel AST
6
+
7
+ class Builder
8
+ attr_reader :model, :table
9
+
10
+ def initialize(model, engine = ActiveRecord::Base)
11
+ @model = model
12
+ @table = Arel::Table.new(model, engine)
13
+ end
14
+
15
+ def find(args)
16
+ Query.new(table).where(args)
17
+ end
18
+ end
19
+
20
+ class Query
21
+ attr_reader :table
22
+
23
+ def initialize(table)
24
+ @table = table
25
+ end
26
+
27
+ def where(args)
28
+ condition = translate(args)
29
+ arel.where(condition) if condition
30
+ self
31
+ end
32
+
33
+ def arel
34
+ @arel ||= table.project(table[:data])
35
+ end
36
+
37
+ def to_arel
38
+ arel
39
+ end
40
+
41
+ def to_sql
42
+ to_arel.to_sql
43
+ end
44
+
45
+ def limit(number)
46
+ arel.take(number)
47
+ self
48
+ end
49
+
50
+ def skip(number)
51
+ arel.skip(number)
52
+ self
53
+ end
54
+
55
+ def sort(params)
56
+ params.each do |col, val|
57
+ order = val > 0 ? :asc : :desc
58
+ case col.to_s
59
+ when "_id"
60
+ arel.order(table[:id].send(order))
61
+ else
62
+ arel.order(sql_json_path(col).send(order))
63
+ end
64
+ end
65
+ self
66
+ end
67
+
68
+ def translate(query)
69
+ chain(:and, query.map { |col, value| translate_cv(col, value) })
70
+ end
71
+
72
+ def translate_cv(col, value)
73
+ case col.to_s
74
+ when "_id"
75
+ translate_value(table[:id], value)
76
+ when "$or"
77
+ chain(:or, value.map {|q| translate(q) })
78
+ when "$and"
79
+ chain(:and, value.map { |q| translate(q) })
80
+ when /^\$/
81
+ raise UnsupportedQuery, "Unsupported operator #{col}"
82
+ else
83
+ translate_value_json(sql_json_path(col), value)
84
+ end
85
+ end
86
+
87
+ def translate_value(col, value, json = false)
88
+ case value
89
+ when Hash
90
+ ops = value.keys
91
+ if ops.size > 1
92
+ raise UnsupportedQuery, "Multiple operator supported: #{ops.join(", ")}"
93
+ end
94
+
95
+ val = value[ops.first]
96
+ case ops.first
97
+ when "$in"
98
+ col.in(val)
99
+ when "$gt", "$gte", "$lt", "$lte"
100
+ col.send(COMPARE_MAPS[ops.first], val)
101
+ when "$eq"
102
+ col.eq(val)
103
+ when "$ne"
104
+ col.not_eq(val)
105
+ when /^\$/
106
+ raise UnsupportedQuery, "Unknown operator #{ops.first}"
107
+ end
108
+ else
109
+ col.eq(value)
110
+ end
111
+ end
112
+
113
+ COMPARE_MAPS = { "$gt" => :gt, "$gte" => :gteq, "$lt" => :lt, "$lte" => :lteq }
114
+
115
+ def translate_value_json(col, value, json = false)
116
+ case value
117
+ when String, Numeric, TrueClass, FalseClass
118
+ # in Postgres 9.3, you can't compare numeric
119
+ col.eq(value.to_s)
120
+ when NilClass
121
+ # You can't use IS NULL
122
+ col.eq('')
123
+ when Hash
124
+ ops = value.keys
125
+ if ops.size > 1
126
+ raise UnsupportedQuery, "Multiple operator supported: #{ops.join(", ")}"
127
+ end
128
+
129
+ val = value[ops.first]
130
+ case ops.first
131
+ when "$in"
132
+ chain(:or, val.map { |val| col.matches(%Q[%"#{val}"%]) })
133
+ when "$gt", "$gte", "$lt", "$lte"
134
+ wrap_numeric(col, val).send(COMPARE_MAPS[ops.first], val)
135
+ when "$eq"
136
+ wrap_numeric(col, val).eq(val)
137
+ when "$ne"
138
+ wrap_numeric(col, val).not_eq(val)
139
+ when /^\$/
140
+ raise UnsupportedQuery, "Unknown operator #{ops.first}"
141
+ end
142
+ end
143
+ end
144
+
145
+ def wrap_numeric(col, val)
146
+ case val
147
+ when Float
148
+ Arel.sql("(#{col})::float")
149
+ when Integer
150
+ Arel.sql("(#{col})::integer")
151
+ else
152
+ col
153
+ end
154
+ end
155
+
156
+ def sql_json_path(col)
157
+ path = "data"
158
+ parts = col.to_s.split('.')
159
+ parts.each_with_index do |part, index|
160
+ sep = index == parts.size - 1 ? "->>" : "->"
161
+ path += "#{sep}'#{part}'"
162
+ end
163
+ Arel.sql(path)
164
+ end
165
+
166
+ def chain(op, conditions)
167
+ result = nil
168
+ conditions.each do |cond|
169
+ result = result ? result.send(op, cond) : cond
170
+ end
171
+ result
172
+ end
173
+ end
174
+
175
+ class UnsupportedQuery < StandardError
176
+ end
177
+ end
@@ -0,0 +1,3 @@
1
+ module Mongery
2
+ VERSION = "0.0.1"
3
+ end
data/mongery.gemspec ADDED
@@ -0,0 +1,28 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'mongery/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "mongery"
8
+ spec.version = Mongery::VERSION
9
+ spec.authors = ["Tatsuhiko Miyagawa"]
10
+ spec.email = ["miyagawa@bulknews.net"]
11
+ spec.summary = %q{Convert MongoDB query to Arel for PostgreSQL + JSON}
12
+ spec.description = %q{}
13
+ spec.homepage = ""
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0")
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_dependency "arel", ">= 4.0.2"
22
+
23
+ spec.add_development_dependency "bundler", "~> 1.7"
24
+ spec.add_development_dependency "rake", "~> 10.0"
25
+ spec.add_development_dependency "rspec"
26
+ spec.add_development_dependency "activerecord"
27
+ spec.add_development_dependency "pg"
28
+ end
@@ -0,0 +1,73 @@
1
+ require 'spec_helper'
2
+
3
+ describe Mongery::Builder do
4
+ tests = [
5
+ [ { }, { },
6
+ /^SELECT "test"\."data" FROM "test"$/ ],
7
+ [ { _id: "foo" }, { },
8
+ /WHERE "test"\."id" = 'foo'$/ ],
9
+ [ { _id: { "$in" => ["foo", "bar"] } }, { },
10
+ /WHERE "test"\."id" IN \('foo', 'bar'\)$/ ],
11
+ [ { _id: "foo" }, { limit: 1 },
12
+ /WHERE "test"\."id" = 'foo' LIMIT 1$/ ],
13
+ [ { _id: "foo" }, { limit: 1, skip: 10 },
14
+ /WHERE "test"\."id" = 'foo' LIMIT 1 OFFSET 10$/ ],
15
+ [ { _id: "foo" }, { skip: 10, sort: { _id: 1 } },
16
+ /WHERE "test"\."id" = 'foo' ORDER BY "test"\."id" ASC OFFSET 10$/ ],
17
+ [ { _id: "foo" }, { sort: { name: -1, email: 1 } },
18
+ /WHERE "test"\."id" = 'foo' ORDER BY data->>'name' DESC, data->>'email' ASC$/ ],
19
+ [ { "_id" => "foo" }, { },
20
+ /WHERE "test"\."id" = 'foo'$/ ],
21
+ [ { name: "foo" }, { },
22
+ /WHERE data->>'name' = 'foo'$/ ],
23
+ [ { name: nil }, { },
24
+ /WHERE data->>'name' = ''$/ ],
25
+ [ { name: "foo", other: "bar" }, { },
26
+ /WHERE data->>'name' = 'foo' AND data->>'other' = 'bar'/ ],
27
+ [ { weight: 66 }, { },
28
+ /WHERE data->>'weight' = '66'$/ ],
29
+ [ { weight: { "$gt" => 66 } }, { },
30
+ /WHERE \(data->>'weight'\)::integer > 66$/ ],
31
+ [ { weight: { "$gt" => 66.0 } }, { },
32
+ /WHERE \(data->>'weight'\)::float > 66\.0$/ ],
33
+ [ { weight: { "$lte" => 66 } }, { },
34
+ /WHERE \(data->>'weight'\)::integer <= 66$/ ],
35
+ [ { age: { '$eq' => 10 }}, { },
36
+ /WHERE \(data->>'age'\)::integer = 10/ ],
37
+ [ { age: { '$ne' => 10 }}, { },
38
+ /WHERE \(data->>'age'\)::integer != 10/ ],
39
+ [ { bool: true }, { },
40
+ /WHERE data->>'bool' = 'true'$/ ],
41
+ [ { 'email.address' => 'john@example.com' }, { },
42
+ /WHERE data->'email'->>'address' = 'john@example.com'$/ ],
43
+ [ { type: "food", "$or" => [{name: "miso"}, {name: "tofu"}]}, { },
44
+ /WHERE data->>'type' = 'food' AND \(data->>'name' = 'miso' OR data->>'name' = 'tofu'\)$/ ],
45
+ [ { "$or" => [{ _id: "foo" }, { _id: "bar" }] }, { },
46
+ /WHERE \("test"\."id" = 'foo' OR "test"\."id" = 'bar'\)$/ ],
47
+ [ { "$or" => [{ name: "John" }, { weight: 120 }] }, { },
48
+ /WHERE \(data->>'name' = 'John' OR data->>'weight' = '120'\)$/ ],
49
+ [ { "$and" => [{ _id: "foo" }, { name: "bar" }] }, { },
50
+ /WHERE "test"\."id" = 'foo' AND data->>'name' = 'bar'$/ ],
51
+ [ { "$and" => [{ name: "John" }, { weight: 120 }] }, { },
52
+ /WHERE data->>'name' = 'John' AND data->>'weight' = '120'$/ ],
53
+ [ { "$and" => [{ "$or" => [{name: "John"}, {email: "john"}] }, {_id: "Bob"}] }, { },
54
+ /WHERE \(data->>'name' = 'John' OR data->>'email' = 'john'\) AND "test"\."id" = 'Bob'$/ ],
55
+ [ { ids: {"$in" => [ "foo" ]} }, { },
56
+ /WHERE data->>'ids' ILIKE '%"foo"%'$/ ],
57
+ [ { ids: {"$in" => [ "foo", "bar" ]} }, { },
58
+ /WHERE \(data->>'ids' ILIKE '%"foo"%' OR data->>'ids' ILIKE '%"bar"%'\)$/ ],
59
+ ]
60
+
61
+ tests.each do |query, condition, sql|
62
+ context "with query #{query}" do
63
+ subject do
64
+ Mongery::Builder.new(:test).find(query).tap { |q|
65
+ condition.each do |method, value|
66
+ q.send(method, value)
67
+ end
68
+ }.to_sql
69
+ end
70
+ it { should match sql }
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,13 @@
1
+ require 'mongery'
2
+ require 'rspec'
3
+ require 'active_record'
4
+
5
+ RSpec.configure do |config|
6
+ ActiveRecord::Base.establish_connection(
7
+ adapter: 'postgresql',
8
+ encoding: 'unicode',
9
+ database: ENV['PG_DATABASE'],
10
+ username: ENV['USER'],
11
+ password: nil,
12
+ )
13
+ end
metadata ADDED
@@ -0,0 +1,141 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mongery
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Tatsuhiko Miyagawa
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2014-10-22 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: arel
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 4.0.2
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 4.0.2
27
+ - !ruby/object:Gem::Dependency
28
+ name: bundler
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.7'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.7'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '10.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '10.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: activerecord
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: pg
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ description: ''
98
+ email:
99
+ - miyagawa@bulknews.net
100
+ executables: []
101
+ extensions: []
102
+ extra_rdoc_files: []
103
+ files:
104
+ - ".gitignore"
105
+ - Gemfile
106
+ - LICENSE.txt
107
+ - README.md
108
+ - Rakefile
109
+ - lib/mongery.rb
110
+ - lib/mongery/version.rb
111
+ - mongery.gemspec
112
+ - spec/mongery/builder_spec.rb
113
+ - spec/spec_helper.rb
114
+ homepage: ''
115
+ licenses:
116
+ - MIT
117
+ metadata: {}
118
+ post_install_message:
119
+ rdoc_options: []
120
+ require_paths:
121
+ - lib
122
+ required_ruby_version: !ruby/object:Gem::Requirement
123
+ requirements:
124
+ - - ">="
125
+ - !ruby/object:Gem::Version
126
+ version: '0'
127
+ required_rubygems_version: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ requirements: []
133
+ rubyforge_project:
134
+ rubygems_version: 2.2.2
135
+ signing_key:
136
+ specification_version: 4
137
+ summary: Convert MongoDB query to Arel for PostgreSQL + JSON
138
+ test_files:
139
+ - spec/mongery/builder_spec.rb
140
+ - spec/spec_helper.rb
141
+ has_rdoc: