dm-sql-finders 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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: