dm-sql-finders 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,4 @@
1
+ *.gem
2
+ .bundle
3
+ Gemfile.lock
4
+ pkg/*
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --colour
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "http://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in dm-sql-finders.gemspec
4
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2011 Chris Corbyn
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,240 @@
1
+ # DataMapper SQL Finders
2
+
3
+ DataMapper SQL Finders adds `#by_sql` to your models, as a compliment to the standard pure-ruby
4
+ query system used by DataMapper. The SQL you write will be executed directly against the adapter,
5
+ but you do not need to lose the benefit of the field and table name abstraction offered by DM.
6
+ When you invoke `#by_sql`, one or more table representations are yielded into a block, which you
7
+ provide. These objects are interpolated into your SQL, such that you use DataMapper properties in
8
+ the SQL and make no direct reference to the real field names in the database schema.
9
+
10
+ I wrote this gem because my original reason for using DataMapper was that I needed to work with a
11
+ large, existing database schema belonging to an old PHP application, subsequently ported to Rails.
12
+ We need the naming abstraction offered by DataMapper, but we also prefer its design. That said, we
13
+ run some queries that are less than trivial, mixing LEFT JOINs and inline derived tables…
14
+ something which DataMapper does not currently handle at all. `#by_sql` allows us to drop down to
15
+ SQL in places where we need it, but where we don't want to by-pass DataMapper entirely.
16
+
17
+ The `#by_sql` method returns a DataMapper `Collection` wrapping a `Query`, in just the same way
18
+ `#all` does. Just like using `#all`, no SQL is executed until a kicker triggers its execution
19
+ (e.g. beginning to iterate over a Collection).
20
+
21
+ Because the implementation is based around a real DataMapper Query object, you can chain other
22
+ DataMapper methods, such as `#all`, or methods or relationships defined on your Model.
23
+
24
+ It looks like this:
25
+
26
+ ``` ruby
27
+ class Post
28
+ include DataMapper::Resource
29
+
30
+ property :id, Serial
31
+ property :title, String
32
+
33
+ belongs_to :user
34
+ end
35
+
36
+ class User
37
+ include DataMapper::Resource
38
+
39
+ property :id, Serial
40
+ property :username, String
41
+
42
+ has n, :posts
43
+
44
+
45
+ def self.never_posted
46
+ by_sql(Post) { |u, p| "SELECT #{u.*} FROM #{u} LEFT JOIN #{p} ON #{p.user_id} = #{u.id} WHERE #{p.id} IS NULL" }
47
+ end
48
+ end
49
+
50
+ User.never_posted.each do |user|
51
+ puts "#{user.username} has never created a Post"
52
+ end
53
+ ```
54
+
55
+ The first block argument is always the current Model. You can optionally pass additional models to `#by_sql` and have
56
+ them yielded into the block if you need to join.
57
+
58
+ You may chain regular DataMapper finders onto the result (the original SQL is modified with the additions):
59
+
60
+ ``` ruby
61
+ User.never_posted.all(:username.like => "%bob%")
62
+ ```
63
+
64
+ ## A note about DataMapper 2.0
65
+
66
+ The DataMapper guys are hard at work creating DataMapper 2.0, which involves a lot of under-the-surface changes, most
67
+ notably building DM's query interface atop [Veritas](https://github.com/dkubb/veritas), with the adapter layer generating
68
+ SQL by walking a Veritas relation (an AST - abstract syntax tree). Because of the way DM 1 handles queries, it is not
69
+ trivial to support SQL provided by the user (except for the trival case of it being in the WHERE clause). With any hope,
70
+ gems like this will either not be needed in DM 2.0, or at least will be easy to implement cleanly.
71
+
72
+ ## Installation
73
+
74
+ Via rubygems:
75
+
76
+ gem install dm-sql-finders
77
+
78
+ Note that while the gem is functional, it has several known limitations, which I aim to work about by improving the
79
+ parsing and generating logic. It is unlikely you will hit the limitations unless you extensively use `#by_sql` in
80
+ conjunction with options such as `:links`.
81
+
82
+ ## Detailed Usage
83
+
84
+ Note that in the following examples, you are not forced to use the table representations yielded into the block, but you
85
+ are encouraged to. They respond to the following methods:
86
+
87
+ - `tbl.*`: expands the splat to only the known fields defined in your model. Other fields in the database are excluded.
88
+ - `tbl.to_s`: represents the name of the table in the database. `#to_s` is invoked implcitly in String context. Note
89
+ that if you join to the same table multiple times, DataMapper SQL Finders will alias them accordingly.
90
+ - `tbl.property_name`: represents the field name in the database mapping to `property_name` in your model.
91
+
92
+ Writing the field/table names directly, while it will work, is not advised, since it will significantly hamper any future
93
+ efforts to chain onto the query (and it reads just like SQL, right?).
94
+
95
+ ### Basic SELECT statements
96
+
97
+ Returning a String from the block executes the SQL when a kicker is invoked (e.g. iterating the Collection).
98
+
99
+ ``` ruby
100
+ def self.basically_everything
101
+ by_sql { |m| "SELECT #{m.*} FROM #{m}" }
102
+ end
103
+ ```
104
+
105
+ ### Passing in variables
106
+
107
+ The block may return an Array, with the first element as the SQL and the following elements as the bind values.
108
+
109
+ ``` ruby
110
+ def self.created_after(time)
111
+ by_sql { |m| ["SELECT #{m.*} FROM #{m} WHERE #{m.created_at} > ?", time] }
112
+ end
113
+ ```
114
+
115
+ ### Ordering
116
+
117
+ DataMapper always adds an ORDER BY to your queries if you don't specify one. DataMapper SQL Finders behaves no differently.
118
+ The default ordering is always ascending by primary key. You can override it in the SQL:
119
+
120
+ ``` ruby
121
+ def self.backwards
122
+ by_sql { |m| "SELECT #{m.*} FROM #{m} ORDER BY #{m.id} DESC" }
123
+ end
124
+ ```
125
+
126
+ Or you can provide it as a regular option to `#by_sql`, just like you can with `#all`:
127
+
128
+ ``` ruby
129
+ def self.backwards
130
+ by_sql(:order => [:id.desc]) { |m| "SELECT #{m.*} FROM #{m}" }
131
+ end
132
+ ```
133
+
134
+ Note that the `:order` option take precendence over anything specified in the SQL. This allows method chains to override it.
135
+
136
+ ### Joins
137
+
138
+ The additional models are passed to `#by_sql`, then you use them to construct the join.
139
+
140
+ ``` ruby
141
+ class User
142
+ ... snip ...
143
+
144
+ def self.posted_today
145
+ by_sql(Post) { |u, p| ["SELECT #{u.*} FROM #{u} INNER JOIN #{p} ON #{p.user_id} = #{u.id} WHERE #{p.created_at} > ?", Date.today - 1] }
146
+ end
147
+ end
148
+ ```
149
+
150
+ The `:links` option will also be interpreted and added to the `FROM` clause in the SQL. This is useful if you need to override the SQL.
151
+
152
+ ### Limits and offsets
153
+
154
+ These can be specified in the SQL:
155
+
156
+ ``` ruby
157
+ def self.penultimate_five
158
+ by_sql { |m| "SELECT #{m.*} FROM #{m} ORDER BY #{m.id} DESC LIMIT 5 OFFSET 5" }
159
+ end
160
+ ```
161
+
162
+ Or they can be provided as options to `#by_sql`:
163
+
164
+ ``` ruby
165
+ def self.penultimate_five
166
+ by_sql(:limit => 5, :offset => 5) { |m| "SELECT #{m.*} FROM #{m}" }
167
+ end
168
+ ```
169
+
170
+ If `:limit` and/or `:offset` are passed to `#by_sql`, they take precedence over anything written in the SQL itself.
171
+
172
+ ### Method chaining
173
+
174
+ Method chaining with `#by_sql`, for the most part, works just like with `#all`. There are some current limitations,
175
+ such as reversing the order of a query that used `ORDER BY` in the SQL, rather than via an `:order` option.
176
+
177
+ Also note the you may not currently chain `#by_sql` calls together. `#by_sql` must, logically, always be the first
178
+ call in the chain.
179
+
180
+ ``` ruby
181
+ User.by_sql{ |u| ["SELECT #{u.*} FROM #{u} WHERE #{u.role} = ?", "Manager"] }.all(:username.like => "%bob%", :order => [:username.desc])
182
+ ```
183
+
184
+ ### Unions, Intersections and Differences
185
+
186
+ Unfortunately this is not currently supported, and will likely only be added after the other limitations are worked out.
187
+
188
+ Specifically, queries like this:
189
+
190
+ ``` ruby
191
+ User.by_sql { |u| ["SELECT #{u.*} FROM #{u} WHERE #{u.created_at} < ?", Date.today - 365] } | User.all(:admin => true)
192
+ ```
193
+
194
+ Should really produce SQL of the nature:
195
+
196
+ ``` sql
197
+ SELECT "users"."id", "users"."username", "users"."admin" FROM "users" WHERE ("created_at" < ?) OR (admin = TRUE)
198
+ ```
199
+
200
+ I have no idea what will happen if it is attempted, but it almost certainly will not work ;)
201
+
202
+ ## Will it interfere with DataMapper?
203
+
204
+ Almost all of the implementation is unintrusive, but unfortunately, because DataMapper's DataObjects Adapter does not provide
205
+ a great deal of flexibility when it comes to SQL generation, the entire `#select_statement` method has been overridden. For
206
+ non-`#by_sql` queries everything follows the original code pathways, and during a `#by_sql` query, the SQL is re-built using
207
+ a combination of the original logic and some custom logic to include your SQL. In short, yes, it does interfere, but I don't
208
+ believe there are any alternatives without extensive work on DataMapper's Query interface and the DataObjects adapter itself.
209
+
210
+ DataMapper 2.0 *should* fix this.
211
+
212
+ ## Contributors
213
+
214
+ DataMapper SQL Finders is currently written by Chris Corbyn, but I'm extremely open to contributors which can make the
215
+ extension feel as natural and robust as possible. It should be developed such that other DataMapper gems (such as
216
+ dm-aggregates and dm-pager) still function without caring that raw SQL is being used in the queries.
217
+
218
+ ## TODO
219
+
220
+ - Support overriding `:fields` in a `#by_sql` query (complex if the query depends on RDBMS native functions)
221
+ - Reverse the order when invoking `#reverse` in a `#by_sql` query that used `ORDER BY` in the SQL (note this will work just fine if
222
+ you use the `:order` option)
223
+ - Better support for `?` replacements in places other than the `WHERE` clause
224
+ - Support set operations (union, intersection, difference)
225
+
226
+ ## Future Plans
227
+
228
+ Before I started writing this, I wanted to implement something similar to Doctrine's (PHP) DQL, probably called DMQL.
229
+ This is basically a strict superset of SQL that is pre-processed with DataMapper, having knowledge of your schema,
230
+ therefore allowing you to simplify the query and let DMQL hande things like JOIN logic. Say, for example:
231
+
232
+ ``` ruby
233
+ Post.by_dmql("JOIN User u WHERE u.username = ?", "Bob")
234
+ ```
235
+
236
+ Which would INNER JOIN posts with users and map u.username with the real field name of the `User#username` property.
237
+
238
+ This gem would be a pre-requisite for that.
239
+
240
+ Copyright (c) 2011 Chris Corbyn.
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
@@ -0,0 +1,34 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.push File.expand_path("../lib", __FILE__)
3
+ require "data_mapper/sql_finders/version"
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = "dm-sql-finders"
7
+ s.version = DataMapper::SQLFinders::VERSION
8
+ s.authors = ["d11wtq"]
9
+ s.email = ["chris@w3style.co.uk"]
10
+ s.homepage = "https://github.com/d11wtq/dm-sql-finders"
11
+ s.summary = %q{Query DataMapper models using raw SQL, without sacrificing property and table name abstraction}
12
+ s.description = %q{dm-sql-finders add #by_sql to your DataMapper models and provides a clean mechanism for using
13
+ the names of the properties in your model, instead of the actual fields in the database. Any SQL
14
+ is supported and actual DataMapper Query objects wrap the SQL, thus delaying its execution until
15
+ a kicker method materializes the records for the query. You can also chain standard DataMapper
16
+ query methods onto the #by_sql call to refine the query.}
17
+
18
+ s.rubyforge_project = "dm-sql-finders"
19
+
20
+ s.files = `git ls-files`.split("\n")
21
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
22
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
23
+ s.require_paths = ["lib"]
24
+
25
+ DM_VERSION ||= ">= 1.2.0"
26
+
27
+ s.add_runtime_dependency "dm-core", DM_VERSION
28
+ s.add_runtime_dependency "dm-do-adapter", DM_VERSION
29
+
30
+ s.add_development_dependency "rspec", "~> 2.6"
31
+ s.add_development_dependency "dm-migrations", DM_VERSION
32
+ s.add_development_dependency "dm-aggregates", DM_VERSION
33
+ s.add_development_dependency "dm-sqlite-adapter", DM_VERSION
34
+ end
@@ -0,0 +1,28 @@
1
+ module DataMapper
2
+ module SQLFinders
3
+ def sql(*models)
4
+ raise ArgumentError, "Block required" unless block_given?
5
+ yield *TableRepresentation.from_models(*models)
6
+ end
7
+
8
+ def by_sql(*additional_models, &block)
9
+ options = if additional_models.last.kind_of?(Hash)
10
+ additional_models.pop
11
+ else
12
+ {}
13
+ end
14
+
15
+ sql, *bind_values = sql(self, *additional_models, &block)
16
+ parts = SQLParser.new(sql).parse
17
+
18
+ options[:limit] ||= parts[:limit] if parts[:limit]
19
+ options[:offset] ||= parts[:offset] if parts[:offset]
20
+
21
+ Collection.new(Query.new(repository, self, options).tap { |q| q.send(:sql=, parts, bind_values) })
22
+ end
23
+
24
+ def default_order(repository_name = default_repository_name)
25
+ Array(super).map { |d| Query::DefaultDirection.new(d) }.freeze
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,7 @@
1
+ module DataMapper
2
+ class Adapters::DataObjectsAdapter
3
+ def select_statement(query)
4
+ SQLFinders::SQLBuilder.new(self, query).select_statement
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,70 @@
1
+ require "forwardable"
2
+
3
+ module DataMapper
4
+ module SQLFinders
5
+ class Query < DataMapper::Query
6
+ def sql=(parts, bind_values)
7
+ @sql_parts = parts
8
+ @sql_values = bind_values
9
+ end
10
+
11
+ def sql
12
+ @sql_parts ||= {}
13
+ @sql_values ||= []
14
+ return @sql_parts, @sql_values
15
+ end
16
+
17
+ def fields
18
+ return super if super.any? { |f| f.kind_of?(Operator) }
19
+ return super unless @sql_parts && @sql_parts.has_key?(:fields)
20
+
21
+ @sql_parts[:fields].map do |field|
22
+ if property = model.properties.detect { |p| p.field == field }
23
+ property
24
+ else
25
+ DataMapper::Property::String.new(model, field)
26
+ end
27
+ end
28
+ end
29
+
30
+ class DefaultDirection < Direction
31
+ extend Forwardable
32
+
33
+ def_delegators :@delegate, :target, :operator, :reverse!, :get
34
+
35
+ def initialize(delegate)
36
+ @delegate = delegate
37
+ end
38
+ end
39
+ end
40
+ end
41
+
42
+ class Query
43
+ def normalize_order # temporary (will be removed in DM 1.3)
44
+ return if @order.nil?
45
+
46
+ @order = Array(@order)
47
+ @order = @order.map do |order|
48
+ case order
49
+ when Direction
50
+ order.dup
51
+
52
+ when Operator
53
+ target = order.target
54
+ property = target.kind_of?(Property) ? target : @properties[target]
55
+
56
+ Direction.new(property, order.operator)
57
+
58
+ when Symbol, String
59
+ Direction.new(@properties[order])
60
+
61
+ when Property
62
+ Direction.new(order)
63
+
64
+ when Path
65
+ Direction.new(order.property)
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,84 @@
1
+ module DataMapper
2
+ module SQLFinders
3
+ class SQLBuilder
4
+ def initialize(adapter, query)
5
+ @adapter = adapter
6
+ @query = query
7
+ @model = @query.model
8
+ @parts, @sql_values = @query.respond_to?(:sql) ? @query.sql : [{}, []]
9
+ @fields = @query.fields
10
+ @conditions = @query.conditions
11
+ @qualify = @query.links.any? || !@parts[:from].nil?
12
+ @conditions_stmt, @qry_values = @adapter.send(:conditions_statement, @conditions, @qualify)
13
+ @order_by = @query.order
14
+ @limit = @query.limit
15
+ @offset = @query.offset
16
+ @bind_values = @sql_values + @qry_values
17
+ @group_by = if @query.unique?
18
+ @fields.select { |property| property.kind_of?(Property) && @model.properties[property.name] }
19
+ end
20
+ end
21
+
22
+ def select_statement
23
+ statement = [
24
+ columns_fragment,
25
+ from_fragment,
26
+ join_fragment,
27
+ where_fragment,
28
+ group_fragment,
29
+ order_fragment
30
+ ].compact.join(" ")
31
+
32
+ @adapter.send(:add_limit_offset!, statement, @limit, @offset, @bind_values)
33
+
34
+ return statement, @bind_values
35
+ end
36
+
37
+ private
38
+
39
+ def columns_fragment
40
+ if @parts[:select] && @fields.none? { |f| f.kind_of?(DataMapper::Query::Operator) }
41
+ @parts[:select].strip
42
+ else
43
+ "SELECT #{@adapter.send(:columns_statement, @fields, @qualify)}"
44
+ end
45
+ end
46
+
47
+ def from_fragment
48
+ if @parts[:from]
49
+ @parts[:from].strip
50
+ else
51
+ "FROM #{@adapter.send(:quote_name, @model.storage_name(@adapter.name))}"
52
+ end
53
+ end
54
+
55
+ def join_fragment
56
+ @adapter.send(:join_statement, @query, @bind_values, @qualify) if @query.links.any?
57
+ end
58
+
59
+ def where_fragment
60
+ if @parts[:where]
61
+ [@parts[:where].strip, @conditions_stmt].reject{ |c| DataMapper::Ext.blank?(c) }.join(" AND ")
62
+ else
63
+ "WHERE #{@conditions_stmt}" unless DataMapper::Ext.blank?(@conditions_stmt)
64
+ end
65
+ end
66
+
67
+ def group_fragment
68
+ if @parts[:group_by]
69
+ @parts[:group_by].strip
70
+ else
71
+ "GROUP BY #{@adapter.send(:columns_statement, @group_by, @qualify)}" if @group_by && @group_by.any?
72
+ end
73
+ end
74
+
75
+ def order_fragment
76
+ if @parts[:order_by] && (@order_by.nil? || @order_by.all? { |o| o.kind_of?(Query::DefaultDirection) })
77
+ @parts[:order_by].strip
78
+ else
79
+ "ORDER BY #{@adapter.send(:order_statement, @order_by, @qualify)}" if @order_by && @order_by.any?
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,110 @@
1
+ module DataMapper
2
+ module SQLFinders
3
+ class SQLParser
4
+ def initialize(sql)
5
+ @sql = sql.dup.to_s
6
+ end
7
+
8
+ def parse
9
+ tokens = {
10
+ :select => "SELECT",
11
+ :from => "FROM",
12
+ :where => "WHERE",
13
+ :group_by => "GROUP BY",
14
+ :having => "HAVING",
15
+ :order_by => "ORDER BY",
16
+ :limit_offset => "LIMIT"
17
+ }
18
+
19
+ parts = {}
20
+
21
+ tokens.each_with_index do |(key, initial), index|
22
+ parts[key] = scan_chunk(initial, tokens.values[(index + 1)..-1])
23
+ end
24
+
25
+ parse_fields!(parts)
26
+ parse_limit_offset!(parts)
27
+
28
+ parts
29
+ end
30
+
31
+ private
32
+
33
+ def scan_chunk(start_token, end_tokens)
34
+ scan_until(@sql, end_tokens) if @sql =~ /^\s*#{start_token}\b/i
35
+ end
36
+
37
+ def scan_until(str, tokens, include_delimiters = true)
38
+ delimiters = include_delimiters ? { "[" => "]", "(" => ")", "`" => "`", "'" => "'", '"' => '"', "--" => "\n", "/*" => "*/" } : { }
39
+ alternates = ["\\"]
40
+ alternates += delimiters.keys
41
+ alternates += tokens.dup
42
+ regex_body = alternates.map{ |v| v.kind_of?(Regexp) ? v.to_s : Regexp.escape(v) }.join("|")
43
+ pattern = /^(?:#{regex_body}|.)/i
44
+
45
+ chunk = ""
46
+
47
+ while result = pattern.match(str)
48
+ token = result.to_s
49
+ case token
50
+ when "\\"
51
+ chunk << str.slice!(0, 2) # escape consumes following character, always
52
+ when *delimiters.keys
53
+ chunk << str.slice!(0, token.length) << scan_until(str, [delimiters[token]], false)
54
+ when *tokens
55
+ if include_delimiters
56
+ return chunk
57
+ else
58
+ return chunk << str.slice!(0, token.length)
59
+ end
60
+ else
61
+ chunk << str.slice!(0, token.length)
62
+ end
63
+ end
64
+
65
+ chunk
66
+ end
67
+
68
+ def parse_fields!(parts)
69
+ return unless fragment = parts[:select]
70
+
71
+ if m = /^\s*SELECT(?:\s+DISTINCT)?\s+(.*)/is.match(fragment)
72
+ full_fields_str = m[1].dup
73
+ fields_with_aliases = []
74
+ while full_fields_str.length > 0
75
+ fields_with_aliases << scan_until(full_fields_str, [","]).strip
76
+ full_fields_str.slice!(0, 1) if full_fields_str.length > 0
77
+ end
78
+ parts[:fields] = fields_with_aliases.collect { |f| extract_field_name(f) }
79
+ end
80
+ end
81
+
82
+ def extract_field_name(field)
83
+ # simple hack: the last token in a SELECT expression is always (conveniently) the alias, regardless of whether an AS is used, or an alias even given at all
84
+ full_str_rtl = field.dup.reverse
85
+ qualified_alias_str_rtl = scan_until(full_str_rtl, [/\s+/])
86
+ alias_str = scan_until(qualified_alias_str_rtl, ["."]).reverse
87
+ case alias_str[0]
88
+ when '"', '"', "`"
89
+ alias_str[1...-1].gsub(/\\(.)/, "\\1")
90
+ else
91
+ alias_str
92
+ end
93
+ end
94
+
95
+ def parse_limit_offset!(parts)
96
+ return unless fragment = parts[:limit_offset]
97
+
98
+ if m = /^\s*LIMIT\s+(\d+)\s*,\s*(\d+)/i.match(fragment)
99
+ parts[:limit] = m[2].to_i
100
+ parts[:offset] = m[1].to_i
101
+ elsif m = /^\s*LIMIT\s+(\d+)\s+OFFSET\s+(\d+)/i.match(fragment)
102
+ parts[:limit] = m[1].to_i
103
+ parts[:offset] = m[2].to_i
104
+ elsif m = /^\s*LIMIT\s+(\d+)/i.match(fragment)
105
+ parts[:limit] = m[1].to_i
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,49 @@
1
+ module DataMapper
2
+ module SQLFinders
3
+ class TableRepresentation
4
+ class << self
5
+ def from_models(*models)
6
+ seen = {}
7
+ models.map do |model|
8
+ seen[model] ||= -1
9
+ new(model, seen[model] += 1)
10
+ end
11
+ end
12
+ end
13
+
14
+ def initialize(model, idx = 0)
15
+ @model = model
16
+ @idx = idx
17
+ end
18
+
19
+ def to_s
20
+ @model.repository.adapter.send(:quote_name, @model.storage_name).dup.tap do |tbl|
21
+ tbl << " AS #{storage_alias}" unless tbl == storage_alias
22
+ end
23
+ end
24
+
25
+ def storage_alias
26
+ name = @model.storage_name.dup.tap do |name|
27
+ name << "_#{@idx}" if @idx > 0
28
+ end
29
+ @model.repository.adapter.send(:quote_name, name)
30
+ end
31
+
32
+ def *
33
+ @model.properties.map { |p| "#{storage_alias}.#{@model.repository.adapter.send(:quote_name, p.field)}" }.join(", ")
34
+ end
35
+
36
+ def method_missing(name, *args, &block)
37
+ return super unless args.size == 0 && !block_given?
38
+
39
+ if property = @model.properties[name]
40
+ "#{storage_alias}.#{@model.repository.adapter.send(:quote_name, property.field)}"
41
+ elsif @model.method_defined?(name)
42
+ @model.repository.adapter.send(:quote_name, name)
43
+ else
44
+ super
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,5 @@
1
+ module DataMapper
2
+ module SQLFinders
3
+ VERSION = "0.0.1"
4
+ end
5
+ end
@@ -0,0 +1,11 @@
1
+ require "dm-core"
2
+ require "dm-do-adapter"
3
+ require "data_mapper/sql_finders/sql_builder"
4
+ require "data_mapper/sql_finders/sql_parser"
5
+ require "data_mapper/sql_finders/table_representation"
6
+ require "data_mapper/sql_finders/adapter"
7
+ require "data_mapper/sql_finders/query"
8
+ require "data_mapper/sql_finders/version"
9
+ require "data_mapper/sql_finders"
10
+
11
+ DataMapper::Model.append_extensions(DataMapper::SQLFinders)
@@ -0,0 +1,309 @@
1
+ require "spec_helper"
2
+
3
+ describe DataMapper::Adapters::DataObjectsAdapter do
4
+ context "querying by SQL" do
5
+ before(:each) do
6
+ @bob = User.create(:username => "Bob", :role => "Manager")
7
+ @fred = User.create(:username => "Fred", :role => "Tea Boy")
8
+ end
9
+
10
+ context "with a basic SELECT statement" do
11
+ before(:each) do
12
+ @users = User.by_sql { |u| ["SELECT #{u.*} FROM #{u} WHERE #{u.role} = ?", "Manager"] }
13
+ @sql, @bind_values = User.repository.adapter.send(:select_statement, @users.query)
14
+ end
15
+
16
+ it "executes the original query" do
17
+ @sql.should == %q{SELECT "users"."id", "users"."username", "users"."role" FROM "users" WHERE "users"."role" = ? ORDER BY "users"."id"}
18
+ @bind_values.should == ["Manager"]
19
+ end
20
+
21
+ it "finds the matching resources" do
22
+ @users.should include(@bob)
23
+ end
24
+
25
+ it "does not find incorrect resources" do
26
+ @users.should_not include(@fred)
27
+ end
28
+
29
+ describe "chaining" do
30
+ describe "to #all" do
31
+ before(:each) do
32
+ @jim = User.create(:username => "Jim", :role => "Manager")
33
+ @users = @users.all(:username => "Jim")
34
+ @sql, @bind_values = User.repository.adapter.send(:select_statement, @users.query)
35
+ end
36
+
37
+ it "merges the conditions with the original SQL" do
38
+ @sql.should == %q{SELECT "users"."id", "users"."username", "users"."role" FROM "users" WHERE "users"."role" = ? AND "users"."username" = ? ORDER BY "users"."id"}
39
+ @bind_values.should == ["Manager", "Jim"]
40
+ end
41
+
42
+ it "finds the matching resources" do
43
+ @users.should include(@jim)
44
+ end
45
+
46
+ it "does not find incorrect resources" do
47
+ @users.should_not include(@bob)
48
+ end
49
+ end
50
+ end
51
+ end
52
+
53
+ describe "ordering" do
54
+ context "with an ORDER BY clause" do
55
+ before(:each) do
56
+ @users = User.by_sql { |u| "SELECT #{u.*} FROM #{u} ORDER BY #{u.username} DESC" }
57
+ @sql, @bind_values = User.repository.adapter.send(:select_statement, @users.query)
58
+ end
59
+
60
+ it "uses the order from the SQL" do
61
+ @sql.should == %q{SELECT "users"."id", "users"."username", "users"."role" FROM "users" ORDER BY "users"."username" DESC}
62
+ end
63
+
64
+ it "loads the resources in the correct order" do
65
+ @users.to_a.first.should == @fred
66
+ @users.to_a.last.should == @bob
67
+ end
68
+ end
69
+
70
+ context "with :order specified on the query" do
71
+ before(:each) do
72
+ @users = User.by_sql(:order => [:role.asc]) { |u| "SELECT #{u.*} FROM #{u}" }
73
+ @sql, @bind_values = User.repository.adapter.send(:select_statement, @users.query)
74
+ end
75
+
76
+ it "uses the order from the options" do
77
+ @sql.should == %q{SELECT "users"."id", "users"."username", "users"."role" FROM "users" ORDER BY "users"."role"}
78
+ end
79
+
80
+ it "loads the resources in the correct order" do
81
+ @users.to_a.first.should == @bob
82
+ @users.to_a.last.should == @fred
83
+ end
84
+ end
85
+
86
+ context "with both :order and an ORDER BY clause" do
87
+ before(:each) do
88
+ @users = User.by_sql(:order => [:role.desc]) { |u| "SELECT #{u.*} FROM #{u} ORDER BY #{u.username} ASC" }
89
+ @sql, @bind_values = User.repository.adapter.send(:select_statement, @users.query)
90
+ end
91
+
92
+ it "gives the :order option precendence" do
93
+ @sql.should == %q{SELECT "users"."id", "users"."username", "users"."role" FROM "users" ORDER BY "users"."role" DESC}
94
+ end
95
+ end
96
+
97
+ describe "chaining" do
98
+ describe "overriding a previous :order option" do
99
+ before(:each) do
100
+ @users = User.by_sql(:order => [:role.desc]) { |u| "SELECT #{u.*} FROM #{u}" }.all(:order => [:id.asc])
101
+ @sql, @bind_values = User.repository.adapter.send(:select_statement, @users.query)
102
+ end
103
+
104
+ specify "the last :order specified is used" do
105
+ @sql.should == %q{SELECT "users"."id", "users"."username", "users"."role" FROM "users" ORDER BY "users"."id"}
106
+ end
107
+ end
108
+
109
+ describe "overriding the order specified in the SQL" do
110
+ before(:each) do
111
+ @users = User.by_sql { |u| "SELECT #{u.*} FROM #{u} ORDER BY #{u.role} DESC" }.all(:order => [:id.asc])
112
+ @sql, @bind_values = User.repository.adapter.send(:select_statement, @users.query)
113
+ end
114
+
115
+ specify "the last :order specified is used" do
116
+ @sql.should == %q{SELECT "users"."id", "users"."username", "users"."role" FROM "users" ORDER BY "users"."id"}
117
+ end
118
+ end
119
+ end
120
+ end
121
+
122
+ describe "limits" do
123
+ context "with a limit specified by the SQL" do
124
+ before(:each) do
125
+ @users = User.by_sql { |u| "SELECT #{u.*} FROM #{u} LIMIT 1" }
126
+ @sql, @bind_values = User.repository.adapter.send(:select_statement, @users.query)
127
+ end
128
+
129
+ it "uses the limit from the SQL" do
130
+ @sql.should == %q{SELECT "users"."id", "users"."username", "users"."role" FROM "users" ORDER BY "users"."id" LIMIT ?}
131
+ @bind_values.should == [1]
132
+ end
133
+
134
+ it "finds the matching resources" do
135
+ @users.to_a.should have(1).items
136
+ @users.to_a.first.should == @bob
137
+ end
138
+ end
139
+
140
+ context "with a :limit option to #by_sql" do
141
+ before(:each) do
142
+ @users = User.by_sql(:limit => 1) { |u| "SELECT #{u.*} FROM #{u}" }
143
+ @sql, @bind_values = User.repository.adapter.send(:select_statement, @users.query)
144
+ end
145
+
146
+ it "uses the :limit option" do
147
+ @sql.should == %q{SELECT "users"."id", "users"."username", "users"."role" FROM "users" ORDER BY "users"."id" LIMIT ?}
148
+ @bind_values.should == [1]
149
+ end
150
+
151
+ it "finds the matching resources" do
152
+ @users.to_a.should have(1).items
153
+ @users.to_a.first.should == @bob
154
+ end
155
+ end
156
+
157
+ context "with both a :limit option and a LIMIT in the SQL" do
158
+ before(:each) do
159
+ @users = User.by_sql(:limit => 1) { |u| "SELECT #{u.*} FROM #{u} LIMIT 2" }
160
+ @sql, @bind_values = User.repository.adapter.send(:select_statement, @users.query)
161
+ end
162
+
163
+ it "the :limit option takes precedence" do
164
+ @sql.should == %q{SELECT "users"."id", "users"."username", "users"."role" FROM "users" ORDER BY "users"."id" LIMIT ?}
165
+ @bind_values.should == [1]
166
+ end
167
+ end
168
+
169
+ context "with an OFFSET in the SQL" do
170
+ before(:each) do
171
+ @users = User.by_sql { |u| "SELECT #{u.*} FROM #{u} LIMIT 1 OFFSET 1" }
172
+ @sql, @bind_values = User.repository.adapter.send(:select_statement, @users.query)
173
+ end
174
+
175
+ it "uses the offset from the SQL" do
176
+ @sql.should == %q{SELECT "users"."id", "users"."username", "users"."role" FROM "users" ORDER BY "users"."id" LIMIT ? OFFSET ?}
177
+ @bind_values.should == [1, 1]
178
+ end
179
+
180
+ it "finds the matching resources" do
181
+ @users.to_a.should have(1).items
182
+ @users.to_a.first.should == @fred
183
+ end
184
+ end
185
+
186
+ context "with an argument to LIMIT in the SQL" do
187
+ before(:each) do
188
+ @users = User.by_sql { |u| "SELECT #{u.*} FROM #{u} LIMIT 1, 2" }
189
+ @sql, @bind_values = User.repository.adapter.send(:select_statement, @users.query)
190
+ end
191
+
192
+ it "interprets the offset in the SQL" do
193
+ @sql.should == %q{SELECT "users"."id", "users"."username", "users"."role" FROM "users" ORDER BY "users"."id" LIMIT ? OFFSET ?}
194
+ @bind_values.should == [2, 1]
195
+ end
196
+ end
197
+
198
+ context "with an :offset option to #by_sql" do
199
+ before(:each) do
200
+ @users = User.by_sql(:offset => 1) { |u| "SELECT #{u.*} FROM #{u} LIMIT 1" }
201
+ @sql, @bind_values = User.repository.adapter.send(:select_statement, @users.query)
202
+ end
203
+
204
+ it "uses the offset from the options hash" do
205
+ @sql.should == %q{SELECT "users"."id", "users"."username", "users"."role" FROM "users" ORDER BY "users"."id" LIMIT ? OFFSET ?}
206
+ @bind_values.should == [1, 1]
207
+ end
208
+
209
+ it "finds the matching resources" do
210
+ @users.to_a.should have(1).items
211
+ @users.to_a.first.should == @fred
212
+ end
213
+ end
214
+
215
+ context "with both an OFFSET in the SQL and an :offset option" do
216
+ before(:each) do
217
+ @users = User.by_sql(:offset => 1) { |u| "SELECT #{u.*} FROM #{u} LIMIT 1 OFFSET 0" }
218
+ @sql, @bind_values = User.repository.adapter.send(:select_statement, @users.query)
219
+ end
220
+
221
+ it "the :offset in the options takes precendence" do
222
+ @sql.should == %q{SELECT "users"."id", "users"."username", "users"."role" FROM "users" ORDER BY "users"."id" LIMIT ? OFFSET ?}
223
+ @bind_values.should == [1, 1]
224
+ end
225
+ end
226
+
227
+ describe "chaining" do
228
+ describe "to override a previous :limit option" do
229
+ before(:each) do
230
+ @users = User.by_sql(:limit => 2) { |u| "SELECT #{u.*} FROM #{u}" }.all(:limit => 1)
231
+ @sql, @bind_values = User.repository.adapter.send(:select_statement, @users.query)
232
+ end
233
+
234
+ it "the last :limit option takes precedence" do
235
+ @sql.should == %q{SELECT "users"."id", "users"."username", "users"."role" FROM "users" ORDER BY "users"."id" LIMIT ?}
236
+ @bind_values.should == [1]
237
+ end
238
+ end
239
+
240
+ describe "to override a limit applied in SQL" do
241
+ before(:each) do
242
+ @users = User.by_sql { |u| "SELECT #{u.*} FROM #{u} LIMIT 1" }.all(:limit => 2)
243
+ @sql, @bind_values = User.repository.adapter.send(:select_statement, @users.query)
244
+ end
245
+
246
+ it "the last :limit option takes precedence" do
247
+ @sql.should == %q{SELECT "users"."id", "users"."username", "users"."role" FROM "users" ORDER BY "users"."id" LIMIT ?}
248
+ @bind_values.should == [2]
249
+ end
250
+ end
251
+ end
252
+ end
253
+
254
+ context "with an INNER JOIN" do
255
+ before(:each) do
256
+ @bobs_post = @bob.posts.create(:title => "Bob can write posts")
257
+ @freds_post = @fred.posts.create(:title => "Fred likes to write too")
258
+
259
+ @posts = Post.by_sql(User) { |p, u| ["SELECT #{p.*} FROM #{p} INNER JOIN #{u} ON #{p.user_id} = #{u.id} WHERE #{u.id} = ?", @bob.id] }
260
+ @sql, @bind_values = Post.repository.adapter.send(:select_statement, @posts.query)
261
+ end
262
+
263
+ it "executes the original query" do
264
+ @sql.should == %q{SELECT "posts"."id", "posts"."title", "posts"."user_id" FROM "posts" INNER JOIN "users" ON "posts"."user_id" = "users"."id" WHERE "users"."id" = ? ORDER BY "posts"."id"}
265
+ @bind_values.should == [@bob.id]
266
+ end
267
+
268
+ it "finds the matching resources" do
269
+ @posts.should include(@bobs_post)
270
+ end
271
+
272
+ it "does not find incorrect resources" do
273
+ @posts.should_not include(@freds_post)
274
+ end
275
+
276
+ context "to the same table" do
277
+ before(:each) do
278
+ @posts = Post.by_sql(Post) { |p1, p2| "SELECT #{p1.*} FROM #{p1} INNER JOIN #{p2} ON #{p1.id} = #{p2.id}" }
279
+ @sql, @bind_values = Post.repository.adapter.send(:select_statement, @posts.query)
280
+ end
281
+
282
+ it "creates alises for the subsequent tables" do
283
+ @sql.should == %q{SELECT "posts"."id", "posts"."title", "posts"."user_id" FROM "posts" INNER JOIN "posts" AS "posts_1" ON "posts"."id" = "posts_1"."id" ORDER BY "posts"."id"}
284
+ end
285
+ end
286
+ end
287
+
288
+ describe "aggregate queries" do
289
+ it "supports calculating counts" do
290
+ User.by_sql { |u| "SELECT #{u.*} FROM #{u}" }.count(:id).should == 2
291
+ end
292
+
293
+ it "supports multiple aggregates" do
294
+ User.by_sql { |u| "SELECT #{u.*} FROM #{u}" }.aggregate(:id.count, :id.min, :username.max).should == [2, 1, "Fred"]
295
+ end
296
+ end
297
+
298
+ describe "with virtual attributes" do
299
+ before(:each) do
300
+ @bob.posts.create(:title => "Test")
301
+ @users = User.by_sql(Post) { |u, p| "SELECT #{u.*}, COUNT(#{p.id}) AS #{u.post_count} FROM #{u} INNER JOIN #{p} ON #{p.user_id} = #{u.id}" }
302
+ end
303
+
304
+ it "loads the virtual attributes" do
305
+ @users.to_a.first.post_count.should == 1
306
+ end
307
+ end
308
+ end
309
+ end
@@ -0,0 +1,22 @@
1
+ require "dm-migrations"
2
+ require "dm-aggregates"
3
+ require "dm-sql-finders"
4
+
5
+ DataMapper.setup(:default, "sqlite::memory:")
6
+
7
+ Dir[File.join(File.dirname(__FILE__), "support/**/*.rb")].each { |file| require file }
8
+
9
+ RSpec.configure do |config|
10
+ config.mock_with :rspec
11
+
12
+ config.before(:suite) do
13
+ DataMapper.finalize
14
+ end
15
+
16
+ config.before(:each) do
17
+ DataMapper.auto_migrate!
18
+ end
19
+
20
+ config.after(:each) do
21
+ end
22
+ end
@@ -0,0 +1,8 @@
1
+ class Post
2
+ include DataMapper::Resource
3
+
4
+ property :id, Serial
5
+ property :title, String
6
+
7
+ belongs_to :user
8
+ end
@@ -0,0 +1,13 @@
1
+ class User
2
+ include DataMapper::Resource
3
+
4
+ property :id, Serial
5
+ property :username, String
6
+ property :role, String
7
+
8
+ has n, :posts
9
+
10
+ def post_count
11
+ @post_count.to_i
12
+ end
13
+ end
metadata ADDED
@@ -0,0 +1,138 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: dm-sql-finders
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - d11wtq
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2011-10-17 00:00:00.000000000Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: dm-core
16
+ requirement: &6758340 !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: 1.2.0
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: *6758340
25
+ - !ruby/object:Gem::Dependency
26
+ name: dm-do-adapter
27
+ requirement: &6757000 !ruby/object:Gem::Requirement
28
+ none: false
29
+ requirements:
30
+ - - ! '>='
31
+ - !ruby/object:Gem::Version
32
+ version: 1.2.0
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: *6757000
36
+ - !ruby/object:Gem::Dependency
37
+ name: rspec
38
+ requirement: &6755440 !ruby/object:Gem::Requirement
39
+ none: false
40
+ requirements:
41
+ - - ~>
42
+ - !ruby/object:Gem::Version
43
+ version: '2.6'
44
+ type: :development
45
+ prerelease: false
46
+ version_requirements: *6755440
47
+ - !ruby/object:Gem::Dependency
48
+ name: dm-migrations
49
+ requirement: &6754500 !ruby/object:Gem::Requirement
50
+ none: false
51
+ requirements:
52
+ - - ! '>='
53
+ - !ruby/object:Gem::Version
54
+ version: 1.2.0
55
+ type: :development
56
+ prerelease: false
57
+ version_requirements: *6754500
58
+ - !ruby/object:Gem::Dependency
59
+ name: dm-aggregates
60
+ requirement: &6753640 !ruby/object:Gem::Requirement
61
+ none: false
62
+ requirements:
63
+ - - ! '>='
64
+ - !ruby/object:Gem::Version
65
+ version: 1.2.0
66
+ type: :development
67
+ prerelease: false
68
+ version_requirements: *6753640
69
+ - !ruby/object:Gem::Dependency
70
+ name: dm-sqlite-adapter
71
+ requirement: &6752280 !ruby/object:Gem::Requirement
72
+ none: false
73
+ requirements:
74
+ - - ! '>='
75
+ - !ruby/object:Gem::Version
76
+ version: 1.2.0
77
+ type: :development
78
+ prerelease: false
79
+ version_requirements: *6752280
80
+ description: ! "dm-sql-finders add #by_sql to your DataMapper models and provides
81
+ a clean mechanism for using\n the names of the properties in
82
+ your model, instead of the actual fields in the database. Any SQL\n is
83
+ supported and actual DataMapper Query objects wrap the SQL, thus delaying its execution
84
+ until\n a kicker method materializes the records for the query.
85
+ \ You can also chain standard DataMapper\n query methods onto
86
+ the #by_sql call to refine the query."
87
+ email:
88
+ - chris@w3style.co.uk
89
+ executables: []
90
+ extensions: []
91
+ extra_rdoc_files: []
92
+ files:
93
+ - .gitignore
94
+ - .rspec
95
+ - Gemfile
96
+ - LICENSE
97
+ - README.md
98
+ - Rakefile
99
+ - dm-sql-finders.gemspec
100
+ - lib/data_mapper/sql_finders.rb
101
+ - lib/data_mapper/sql_finders/adapter.rb
102
+ - lib/data_mapper/sql_finders/query.rb
103
+ - lib/data_mapper/sql_finders/sql_builder.rb
104
+ - lib/data_mapper/sql_finders/sql_parser.rb
105
+ - lib/data_mapper/sql_finders/table_representation.rb
106
+ - lib/data_mapper/sql_finders/version.rb
107
+ - lib/dm-sql-finders.rb
108
+ - spec/public/adapter_spec.rb
109
+ - spec/spec_helper.rb
110
+ - spec/support/fixtures/post.rb
111
+ - spec/support/fixtures/user.rb
112
+ homepage: https://github.com/d11wtq/dm-sql-finders
113
+ licenses: []
114
+ post_install_message:
115
+ rdoc_options: []
116
+ require_paths:
117
+ - lib
118
+ required_ruby_version: !ruby/object:Gem::Requirement
119
+ none: false
120
+ requirements:
121
+ - - ! '>='
122
+ - !ruby/object:Gem::Version
123
+ version: '0'
124
+ required_rubygems_version: !ruby/object:Gem::Requirement
125
+ none: false
126
+ requirements:
127
+ - - ! '>='
128
+ - !ruby/object:Gem::Version
129
+ version: '0'
130
+ requirements: []
131
+ rubyforge_project: dm-sql-finders
132
+ rubygems_version: 1.8.10
133
+ signing_key:
134
+ specification_version: 3
135
+ summary: Query DataMapper models using raw SQL, without sacrificing property and table
136
+ name abstraction
137
+ test_files: []
138
+ has_rdoc: