sbf-dm-sql-finders 0.0.4
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.
- checksums.yaml +7 -0
- data/.gitignore +4 -0
- data/.rspec +1 -0
- data/Gemfile +4 -0
- data/LICENSE +20 -0
- data/README.md +271 -0
- data/Rakefile +1 -0
- data/dm-sql-finders.gemspec +35 -0
- data/lib/data_mapper/sql_finders/adapter.rb +10 -0
- data/lib/data_mapper/sql_finders/query.rb +74 -0
- data/lib/data_mapper/sql_finders/sql_builder.rb +86 -0
- data/lib/data_mapper/sql_finders/sql_parser.rb +110 -0
- data/lib/data_mapper/sql_finders/table_representation.rb +49 -0
- data/lib/data_mapper/sql_finders/version.rb +5 -0
- data/lib/data_mapper/sql_finders.rb +28 -0
- data/lib/dm-sql-finders.rb +11 -0
- data/spec/public/adapter_spec.rb +331 -0
- data/spec/spec_helper.rb +22 -0
- data/spec/support/fixtures/post.rb +8 -0
- data/spec/support/fixtures/user.rb +13 -0
- metadata +156 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 665a67daa8f15e23b7aab76eba4254701a2cfc1a48a7a713eec4416802f7e22e
|
4
|
+
data.tar.gz: d25d3a27c1887c90a0c619d9e97c856de90ca41477406af55ee1de7a118baf9a
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 8209b337ad35dd5f9a5cfc827cc48873488da5b2946b229e20c925e2485c099150f86424820618e41b83f87230f4c4efd0e9832b981563da23e76bef3278c0a2
|
7
|
+
data.tar.gz: 0efb3a89f66debd66f878807e227c4ed894d011d0ffb2654f486205f59a15ae000040e9eb7ab56a3ebb2fe50cc7569f4978a082d85279f5647f46eda5c17f834
|
data/.gitignore
ADDED
data/.rspec
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
--colour
|
data/Gemfile
ADDED
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,271 @@
|
|
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, since SQL and Veritas
|
71
|
+
play nicely with each other.
|
72
|
+
|
73
|
+
## Installation
|
74
|
+
|
75
|
+
Via rubygems:
|
76
|
+
|
77
|
+
gem install dm-sql-finders
|
78
|
+
|
79
|
+
## Detailed Usage
|
80
|
+
|
81
|
+
Note that in the following examples, you are not forced to use the table representations yielded into the block, but you
|
82
|
+
are encouraged to. They respond to the following methods:
|
83
|
+
|
84
|
+
- `tbl.*`: expands the splat to only the known fields defined in your model. Other fields in the database are excluded.
|
85
|
+
- `tbl.to_s`: represents the name of the table in the database. `#to_s` is invoked implcitly in String context. Note
|
86
|
+
that if you join to the same table multiple times, DataMapper SQL Finders will alias them accordingly.
|
87
|
+
- `tbl.property_name`: represents the field name in the database mapping to `property_name` in your model.
|
88
|
+
|
89
|
+
Writing the field/table names directly, while it will work, is not advised, since it will significantly hamper any future
|
90
|
+
efforts to chain onto the query (and it reads just like SQL anyway, right?).
|
91
|
+
|
92
|
+
### Basic SELECT statements
|
93
|
+
|
94
|
+
Returning a String from the block executes the SQL when a kicker is invoked (e.g. iterating the Collection).
|
95
|
+
|
96
|
+
``` ruby
|
97
|
+
def self.basically_everything
|
98
|
+
by_sql { |m| "SELECT #{m.*} FROM #{m}" }
|
99
|
+
end
|
100
|
+
```
|
101
|
+
|
102
|
+
### Passing in variables
|
103
|
+
|
104
|
+
The block may return an Array, with the first element as the SQL and the following elements as the bind values.
|
105
|
+
|
106
|
+
``` ruby
|
107
|
+
def self.created_after(time)
|
108
|
+
by_sql { |m| ["SELECT #{m.*} FROM #{m} WHERE #{m.created_at} > ?", time] }
|
109
|
+
end
|
110
|
+
```
|
111
|
+
|
112
|
+
### Selecting less than all fields
|
113
|
+
|
114
|
+
Just specify individual fields in the SQL. The regular DM semantics apply (i.e. the rest will be lazy loaded, and omitting the
|
115
|
+
primary key means your records are immutable).
|
116
|
+
|
117
|
+
``` ruby
|
118
|
+
def self.usernames_only
|
119
|
+
by_sql { |u| "SELECT #{u.username} FROM #{u}" }
|
120
|
+
end
|
121
|
+
```
|
122
|
+
|
123
|
+
### Selecting *more* than all fields (experimental)
|
124
|
+
|
125
|
+
This allows you to pre-load things like aggregate calculations you may otherwise add denormalizations for:
|
126
|
+
|
127
|
+
``` ruby
|
128
|
+
def self.with_post_counts
|
129
|
+
by_sql(Post) { |u, p| "SELECT #{u.*}, COUNT(#{p.id}) AS post_count FROM #{u} INNER JOIN #{p} ON #{p.user_id} = #{u.id} GROUP BY #{u.id}" }
|
130
|
+
end
|
131
|
+
```
|
132
|
+
|
133
|
+
A `@post_count` instnace variable is set on all resources. Currently this is always a String. You will need to typecast manually.
|
134
|
+
|
135
|
+
See the section on "Joins" for details on the join syntax.
|
136
|
+
|
137
|
+
You should consider this feature experimental. It takes advantage of the fact DM Property instances can be created and thrown-away
|
138
|
+
on-the-fly.
|
139
|
+
|
140
|
+
### Ordering
|
141
|
+
|
142
|
+
DataMapper always adds an ORDER BY to your queries if you don't specify one. DataMapper SQL Finders behaves no differently.
|
143
|
+
The default ordering is always ascending by primary key. You can override it in the SQL:
|
144
|
+
|
145
|
+
``` ruby
|
146
|
+
def self.backwards
|
147
|
+
by_sql { |m| "SELECT #{m.*} FROM #{m} ORDER BY #{m.id} DESC" }
|
148
|
+
end
|
149
|
+
```
|
150
|
+
|
151
|
+
Or you can provide it as a regular option to `#by_sql`, just like you can with `#all`:
|
152
|
+
|
153
|
+
``` ruby
|
154
|
+
def self.backwards
|
155
|
+
by_sql(:order => [:id.desc]) { |m| "SELECT #{m.*} FROM #{m}" }
|
156
|
+
end
|
157
|
+
```
|
158
|
+
|
159
|
+
Note that the `:order` option take precendence over anything specified in the SQL. This allows method chains to override it.
|
160
|
+
|
161
|
+
### Joins
|
162
|
+
|
163
|
+
The additional models are passed to `#by_sql`, then you use them to construct the join.
|
164
|
+
|
165
|
+
``` ruby
|
166
|
+
class User
|
167
|
+
... snip ...
|
168
|
+
|
169
|
+
def self.posted_today
|
170
|
+
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] }
|
171
|
+
end
|
172
|
+
end
|
173
|
+
```
|
174
|
+
|
175
|
+
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.
|
176
|
+
|
177
|
+
### Limits and offsets
|
178
|
+
|
179
|
+
These can be specified in the SQL:
|
180
|
+
|
181
|
+
``` ruby
|
182
|
+
def self.penultimate_five
|
183
|
+
by_sql { |m| "SELECT #{m.*} FROM #{m} ORDER BY #{m.id} DESC LIMIT 5 OFFSET 5" }
|
184
|
+
end
|
185
|
+
```
|
186
|
+
|
187
|
+
Or they can be provided as options to `#by_sql`:
|
188
|
+
|
189
|
+
``` ruby
|
190
|
+
def self.penultimate_five
|
191
|
+
by_sql(:limit => 5, :offset => 5) { |m| "SELECT #{m.*} FROM #{m}" }
|
192
|
+
end
|
193
|
+
```
|
194
|
+
|
195
|
+
If `:limit` and/or `:offset` are passed to `#by_sql`, they take precedence over anything written in the SQL itself.
|
196
|
+
|
197
|
+
### Method chaining
|
198
|
+
|
199
|
+
Method chaining with `#by_sql`, for the most part, works just like with `#all`. There are some current limitations,
|
200
|
+
such as reversing the order of a query that used `ORDER BY` in the SQL, rather than via an `:order` option.
|
201
|
+
|
202
|
+
Also note the you may not currently chain `#by_sql` calls together. `#by_sql` must, logically, always be the first
|
203
|
+
call in the chain.
|
204
|
+
|
205
|
+
``` ruby
|
206
|
+
User.by_sql{ |u| ["SELECT #{u.*} FROM #{u} WHERE #{u.role} = ?", "Manager"] }.all(:username.like => "%bob%", :order => [:username.desc])
|
207
|
+
```
|
208
|
+
|
209
|
+
### Unions, Intersections and Differences
|
210
|
+
|
211
|
+
Unfortunately this is not currently supported, and will likely only be added after the other limitations are worked out.
|
212
|
+
|
213
|
+
Specifically, queries like this:
|
214
|
+
|
215
|
+
``` ruby
|
216
|
+
User.by_sql { |u| ["SELECT #{u.*} FROM #{u} WHERE #{u.created_at} < ?", Date.today - 365] } | User.all(:admin => true)
|
217
|
+
```
|
218
|
+
|
219
|
+
Should really produce SQL of the nature:
|
220
|
+
|
221
|
+
``` sql
|
222
|
+
SELECT "users"."id", "users"."username", "users"."admin" FROM "users" WHERE ("created_at" < ?) OR (admin = TRUE)
|
223
|
+
```
|
224
|
+
|
225
|
+
I have no idea what will happen if it is attempted, but it almost certainly will not work ;)
|
226
|
+
|
227
|
+
## Will it interfere with DataMapper?
|
228
|
+
|
229
|
+
`#select_statement` on the adapter is overridden such that, when you use `#by_sql` query, code in the gem is executed, and
|
230
|
+
when you execute a regular query, the original code pathways are followed. I'd prefer some sort of extension API in
|
231
|
+
DataObjectsAdapter to allow hooks into its SQL generation logic, but for now, this is how it works.
|
232
|
+
|
233
|
+
DataMapper 2.0 *should* fix this.
|
234
|
+
|
235
|
+
## Contributors
|
236
|
+
|
237
|
+
DataMapper SQL Finders is currently written by [Chris Corbyn](https://github.com/d11wtq)
|
238
|
+
|
239
|
+
Contributions are more than gladly accepted. The primary goal is to support SQL in a way that does not break gems like dm-aggregates
|
240
|
+
and dm-pager. The more the SQL can be interpreted and turned into a native Query, the better.
|
241
|
+
|
242
|
+
## TODO
|
243
|
+
|
244
|
+
There are some known limitations, that are mostly edge-cases. You will only run into them if you try to get too crazy combining regular
|
245
|
+
DM queries with SQL (e.g. adding `:links` to a hand-written SQL query works, unless you have used bind values somewhere other than the
|
246
|
+
WHERE clause *and if*, and *only if* DataMapper needs to use a bind value in the join, such as for special join conditions). Real
|
247
|
+
edge-cases.
|
248
|
+
|
249
|
+
- Support overriding `:fields` in a `#by_sql` query (complex if the query depends on RDBMS native functions in both the WHERE and the SELECT)
|
250
|
+
- 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
|
251
|
+
you use the `:order` option)
|
252
|
+
- Better support for `?` replacements in places other than the `WHERE` clause
|
253
|
+
- Support set operations (union, intersection, difference)
|
254
|
+
- Possibly (?) support crazy complex mass-updates (seems a little DB-specific though):
|
255
|
+
`User.by_sql { ... something with join conditions ... }.update!(:banned => true)` (MySQL, for one, can do `UPDATE ... INNER JOIN ...`)
|
256
|
+
|
257
|
+
## Future Plans
|
258
|
+
|
259
|
+
Before I started writing this, I wanted to implement something similar to Doctrine's (PHP) DQL, probably called DMQL.
|
260
|
+
This is basically a strict superset of SQL that is pre-processed with DataMapper, having knowledge of your schema,
|
261
|
+
therefore allowing you to simplify the query and let DMQL hande things like JOIN logic. Say, for example:
|
262
|
+
|
263
|
+
``` ruby
|
264
|
+
Post.by_dmql("JOIN User u WHERE u.username = ?", "Bob")
|
265
|
+
```
|
266
|
+
|
267
|
+
Which would INNER JOIN posts with users and map `u.username` with the real field name of the `User#username` property.
|
268
|
+
|
269
|
+
This gem would be a pre-requisite for that.
|
270
|
+
|
271
|
+
Copyright (c) 2011 Chris Corbyn.
|
data/Rakefile
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require "bundler/gem_tasks"
|
@@ -0,0 +1,35 @@
|
|
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 = "sbf-dm-sql-finders"
|
7
|
+
s.version = DataMapper::SQLFinders::VERSION
|
8
|
+
s.authors = ['opensource_firespring']
|
9
|
+
s.email = ['opensource@firespring.com']
|
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
|
+
s.license = 'Nonstandard'
|
18
|
+
|
19
|
+
s.rubyforge_project = "dm-sql-finders"
|
20
|
+
|
21
|
+
s.files = `git ls-files`.split("\n")
|
22
|
+
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
23
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
24
|
+
s.require_paths = ["lib"]
|
25
|
+
|
26
|
+
DM_VERSION ||= "~> 1.3"
|
27
|
+
|
28
|
+
s.add_runtime_dependency "sbf-dm-core", DM_VERSION
|
29
|
+
s.add_runtime_dependency "sbf-dm-do-adapter", DM_VERSION
|
30
|
+
|
31
|
+
s.add_development_dependency "rspec", "~> 3.13"
|
32
|
+
s.add_development_dependency "sbf-dm-migrations", DM_VERSION
|
33
|
+
s.add_development_dependency "sbf-dm-aggregates", DM_VERSION
|
34
|
+
s.add_development_dependency "sbf-dm-sqlite-adapter", DM_VERSION
|
35
|
+
end
|
@@ -0,0 +1,10 @@
|
|
1
|
+
module DataMapper
|
2
|
+
class Adapters::DataObjectsAdapter
|
3
|
+
def select_statement_with_query(query)
|
4
|
+
SQLFinders::SQLBuilder.new(self, query).select_statement
|
5
|
+
end
|
6
|
+
|
7
|
+
alias_method :select_statement_without_query, :select_statement
|
8
|
+
alias_method :select_statement, :select_statement_with_query
|
9
|
+
end
|
10
|
+
end
|
@@ -0,0 +1,74 @@
|
|
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
|
+
|
39
|
+
def dup
|
40
|
+
self.class.new(@delegate.dup)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
class Query
|
47
|
+
def normalize_order # temporary (will be removed in DM 1.3)
|
48
|
+
return if @order.nil?
|
49
|
+
|
50
|
+
@order = Array(@order)
|
51
|
+
@order = @order.map do |order|
|
52
|
+
case order
|
53
|
+
when Direction
|
54
|
+
order.dup
|
55
|
+
|
56
|
+
when Operator
|
57
|
+
target = order.target
|
58
|
+
property = target.kind_of?(Property) ? target : @properties[target]
|
59
|
+
|
60
|
+
Direction.new(property, order.operator)
|
61
|
+
|
62
|
+
when Symbol, String
|
63
|
+
Direction.new(@properties[order])
|
64
|
+
|
65
|
+
when Property
|
66
|
+
Direction.new(order)
|
67
|
+
|
68
|
+
when Path
|
69
|
+
Direction.new(order.property)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,86 @@
|
|
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
|
+
return @adapter.send(:select_statement_without_query, @query) unless @query.kind_of?(SQLFinders::Query)
|
24
|
+
|
25
|
+
statement = [
|
26
|
+
columns_fragment,
|
27
|
+
from_fragment,
|
28
|
+
join_fragment,
|
29
|
+
where_fragment,
|
30
|
+
group_fragment,
|
31
|
+
order_fragment
|
32
|
+
].compact.join(" ")
|
33
|
+
|
34
|
+
@adapter.send(:add_limit_offset!, statement, @limit, @offset, @bind_values)
|
35
|
+
|
36
|
+
return statement, @bind_values
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
def columns_fragment
|
42
|
+
if @parts[:select] && @fields.none? { |f| f.kind_of?(DataMapper::Query::Operator) }
|
43
|
+
@parts[:select].strip
|
44
|
+
else
|
45
|
+
"SELECT #{@adapter.send(:columns_statement, @fields, @qualify)}"
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def from_fragment
|
50
|
+
if @parts[:from]
|
51
|
+
@parts[:from].strip
|
52
|
+
else
|
53
|
+
"FROM #{@adapter.send(:quote_name, @model.storage_name(@adapter.name))}"
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def join_fragment
|
58
|
+
@adapter.send(:join_statement, @query, @bind_values, @qualify) if @query.links.any?
|
59
|
+
end
|
60
|
+
|
61
|
+
def where_fragment
|
62
|
+
if @parts[:where]
|
63
|
+
[@parts[:where].strip, @conditions_stmt].reject{ |c| DataMapper::Ext.blank?(c) }.join(" AND ")
|
64
|
+
else
|
65
|
+
"WHERE #{@conditions_stmt}" unless DataMapper::Ext.blank?(@conditions_stmt)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def group_fragment
|
70
|
+
if @parts[:group_by]
|
71
|
+
@parts[:group_by].strip
|
72
|
+
else
|
73
|
+
"GROUP BY #{@adapter.send(:columns_statement, @group_by, @qualify)}" if @group_by && @group_by.any?
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def order_fragment
|
78
|
+
if @parts[:order_by] && (@order_by.nil? || @order_by.all? { |o| o.kind_of?(Query::DefaultDirection) })
|
79
|
+
@parts[:order_by].strip
|
80
|
+
else
|
81
|
+
"ORDER BY #{@adapter.send(:order_statement, @order_by, @qualify)}" if @order_by && @order_by.any?
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
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,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,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,331 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe DataMapper::Adapters::DataObjectsAdapter do
|
4
|
+
before(:each) do
|
5
|
+
@bob = User.create(:username => "Bob", :role => "Manager")
|
6
|
+
@fred = User.create(:username => "Fred", :role => "Tea Boy")
|
7
|
+
end
|
8
|
+
|
9
|
+
context "query without SQL" do
|
10
|
+
before(:each) do
|
11
|
+
@users = User.all(:username => "Bob")
|
12
|
+
@sql, @bind_values = User.repository.adapter.send(:select_statement, @users.query)
|
13
|
+
end
|
14
|
+
|
15
|
+
it "behaves unchanged" do
|
16
|
+
@users.to_a.count.should == 1
|
17
|
+
@users.to_a.first.should == @bob
|
18
|
+
@sql.should == %{SELECT "id", "username", "role" FROM "users" WHERE "username" = ? ORDER BY "id"}
|
19
|
+
@bind_values.should == ["Bob"]
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
context "querying by SQL" do
|
24
|
+
context "with a basic SELECT statement" do
|
25
|
+
before(:each) do
|
26
|
+
@users = User.by_sql { |u| ["SELECT #{u.*} FROM #{u} WHERE #{u.role} = ?", "Manager"] }
|
27
|
+
@sql, @bind_values = User.repository.adapter.send(:select_statement, @users.query)
|
28
|
+
end
|
29
|
+
|
30
|
+
it "executes the original query" do
|
31
|
+
@sql.should == %q{SELECT "users"."id", "users"."username", "users"."role" FROM "users" WHERE "users"."role" = ? ORDER BY "users"."id"}
|
32
|
+
@bind_values.should == ["Manager"]
|
33
|
+
end
|
34
|
+
|
35
|
+
it "finds the matching resources" do
|
36
|
+
@users.should include(@bob)
|
37
|
+
end
|
38
|
+
|
39
|
+
it "does not find incorrect resources" do
|
40
|
+
@users.should_not include(@fred)
|
41
|
+
end
|
42
|
+
|
43
|
+
describe "chaining" do
|
44
|
+
describe "to #all" do
|
45
|
+
before(:each) do
|
46
|
+
@jim = User.create(:username => "Jim", :role => "Manager")
|
47
|
+
@users = @users.all(:username => "Jim")
|
48
|
+
@sql, @bind_values = User.repository.adapter.send(:select_statement, @users.query)
|
49
|
+
end
|
50
|
+
|
51
|
+
it "merges the conditions with the original SQL" do
|
52
|
+
@sql.should == %q{SELECT "users"."id", "users"."username", "users"."role" FROM "users" WHERE "users"."role" = ? AND "users"."username" = ? ORDER BY "users"."id"}
|
53
|
+
@bind_values.should == ["Manager", "Jim"]
|
54
|
+
end
|
55
|
+
|
56
|
+
it "finds the matching resources" do
|
57
|
+
@users.should include(@jim)
|
58
|
+
end
|
59
|
+
|
60
|
+
it "does not find incorrect resources" do
|
61
|
+
@users.should_not include(@bob)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
describe "ordering" do
|
68
|
+
context "with an ORDER BY clause" do
|
69
|
+
before(:each) do
|
70
|
+
@users = User.by_sql { |u| "SELECT #{u.*} FROM #{u} ORDER BY #{u.username} DESC" }
|
71
|
+
@sql, @bind_values = User.repository.adapter.send(:select_statement, @users.query)
|
72
|
+
end
|
73
|
+
|
74
|
+
it "uses the order from the SQL" do
|
75
|
+
@sql.should == %q{SELECT "users"."id", "users"."username", "users"."role" FROM "users" ORDER BY "users"."username" DESC}
|
76
|
+
end
|
77
|
+
|
78
|
+
it "loads the resources in the correct order" do
|
79
|
+
@users.to_a.first.should == @fred
|
80
|
+
@users.to_a.last.should == @bob
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
context "with :order specified on the query" do
|
85
|
+
before(:each) do
|
86
|
+
@users = User.by_sql(:order => [:role.asc]) { |u| "SELECT #{u.*} FROM #{u}" }
|
87
|
+
@sql, @bind_values = User.repository.adapter.send(:select_statement, @users.query)
|
88
|
+
end
|
89
|
+
|
90
|
+
it "uses the order from the options" do
|
91
|
+
@sql.should == %q{SELECT "users"."id", "users"."username", "users"."role" FROM "users" ORDER BY "users"."role"}
|
92
|
+
end
|
93
|
+
|
94
|
+
it "loads the resources in the correct order" do
|
95
|
+
@users.to_a.first.should == @bob
|
96
|
+
@users.to_a.last.should == @fred
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
context "with both :order and an ORDER BY clause" do
|
101
|
+
before(:each) do
|
102
|
+
@users = User.by_sql(:order => [:role.desc]) { |u| "SELECT #{u.*} FROM #{u} ORDER BY #{u.username} ASC" }
|
103
|
+
@sql, @bind_values = User.repository.adapter.send(:select_statement, @users.query)
|
104
|
+
end
|
105
|
+
|
106
|
+
it "gives the :order option precendence" do
|
107
|
+
@sql.should == %q{SELECT "users"."id", "users"."username", "users"."role" FROM "users" ORDER BY "users"."role" DESC}
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
describe "chaining" do
|
112
|
+
describe "overriding a previous :order option" do
|
113
|
+
before(:each) do
|
114
|
+
@users = User.by_sql(:order => [:role.desc]) { |u| "SELECT #{u.*} FROM #{u}" }.all(:order => [:id.asc])
|
115
|
+
@sql, @bind_values = User.repository.adapter.send(:select_statement, @users.query)
|
116
|
+
end
|
117
|
+
|
118
|
+
specify "the last :order specified is used" do
|
119
|
+
@sql.should == %q{SELECT "users"."id", "users"."username", "users"."role" FROM "users" ORDER BY "users"."id"}
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
describe "overriding the order specified in the SQL" do
|
124
|
+
before(:each) do
|
125
|
+
@users = User.by_sql { |u| "SELECT #{u.*} FROM #{u} ORDER BY #{u.role} DESC" }.all(:order => [:id.asc])
|
126
|
+
@sql, @bind_values = User.repository.adapter.send(:select_statement, @users.query)
|
127
|
+
end
|
128
|
+
|
129
|
+
specify "the last :order specified is used" do
|
130
|
+
@sql.should == %q{SELECT "users"."id", "users"."username", "users"."role" FROM "users" ORDER BY "users"."id"}
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
describe "limits" do
|
137
|
+
context "with a limit specified by the SQL" do
|
138
|
+
before(:each) do
|
139
|
+
@users = User.by_sql { |u| "SELECT #{u.*} FROM #{u} LIMIT 1" }
|
140
|
+
@sql, @bind_values = User.repository.adapter.send(:select_statement, @users.query)
|
141
|
+
end
|
142
|
+
|
143
|
+
it "uses the limit from the SQL" do
|
144
|
+
@sql.should == %q{SELECT "users"."id", "users"."username", "users"."role" FROM "users" ORDER BY "users"."id" LIMIT ?}
|
145
|
+
@bind_values.should == [1]
|
146
|
+
end
|
147
|
+
|
148
|
+
it "finds the matching resources" do
|
149
|
+
expect(@users.to_a).not_to be_empty
|
150
|
+
@users.to_a.first.should == @bob
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
context "with a :limit option to #by_sql" do
|
155
|
+
before(:each) do
|
156
|
+
@users = User.by_sql(:limit => 1) { |u| "SELECT #{u.*} FROM #{u}" }
|
157
|
+
@sql, @bind_values = User.repository.adapter.send(:select_statement, @users.query)
|
158
|
+
end
|
159
|
+
|
160
|
+
it "uses the :limit option" do
|
161
|
+
@sql.should == %q{SELECT "users"."id", "users"."username", "users"."role" FROM "users" ORDER BY "users"."id" LIMIT ?}
|
162
|
+
@bind_values.should == [1]
|
163
|
+
end
|
164
|
+
|
165
|
+
it "finds the matching resources" do
|
166
|
+
expect(@users.to_a).not_to be_empty
|
167
|
+
@users.to_a.first.should == @bob
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
context "with both a :limit option and a LIMIT in the SQL" do
|
172
|
+
before(:each) do
|
173
|
+
@users = User.by_sql(:limit => 1) { |u| "SELECT #{u.*} FROM #{u} LIMIT 2" }
|
174
|
+
@sql, @bind_values = User.repository.adapter.send(:select_statement, @users.query)
|
175
|
+
end
|
176
|
+
|
177
|
+
it "the :limit option takes precedence" do
|
178
|
+
@sql.should == %q{SELECT "users"."id", "users"."username", "users"."role" FROM "users" ORDER BY "users"."id" LIMIT ?}
|
179
|
+
@bind_values.should == [1]
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
183
|
+
context "with an OFFSET in the SQL" do
|
184
|
+
before(:each) do
|
185
|
+
@users = User.by_sql { |u| "SELECT #{u.*} FROM #{u} LIMIT 1 OFFSET 1" }
|
186
|
+
@sql, @bind_values = User.repository.adapter.send(:select_statement, @users.query)
|
187
|
+
end
|
188
|
+
|
189
|
+
it "uses the offset from the SQL" do
|
190
|
+
@sql.should == %q{SELECT "users"."id", "users"."username", "users"."role" FROM "users" ORDER BY "users"."id" LIMIT ? OFFSET ?}
|
191
|
+
@bind_values.should == [1, 1]
|
192
|
+
end
|
193
|
+
|
194
|
+
it "finds the matching resources" do
|
195
|
+
expect(@users.to_a).not_to be_empty
|
196
|
+
@users.to_a.first.should == @fred
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
context "with an argument to LIMIT in the SQL" do
|
201
|
+
before(:each) do
|
202
|
+
@users = User.by_sql { |u| "SELECT #{u.*} FROM #{u} LIMIT 1, 2" }
|
203
|
+
@sql, @bind_values = User.repository.adapter.send(:select_statement, @users.query)
|
204
|
+
end
|
205
|
+
|
206
|
+
it "interprets the offset in the SQL" do
|
207
|
+
@sql.should == %q{SELECT "users"."id", "users"."username", "users"."role" FROM "users" ORDER BY "users"."id" LIMIT ? OFFSET ?}
|
208
|
+
@bind_values.should == [2, 1]
|
209
|
+
end
|
210
|
+
end
|
211
|
+
|
212
|
+
context "with an :offset option to #by_sql" do
|
213
|
+
before(:each) do
|
214
|
+
@users = User.by_sql(:offset => 1) { |u| "SELECT #{u.*} FROM #{u} LIMIT 1" }
|
215
|
+
@sql, @bind_values = User.repository.adapter.send(:select_statement, @users.query)
|
216
|
+
end
|
217
|
+
|
218
|
+
it "uses the offset from the options hash" do
|
219
|
+
@sql.should == %q{SELECT "users"."id", "users"."username", "users"."role" FROM "users" ORDER BY "users"."id" LIMIT ? OFFSET ?}
|
220
|
+
@bind_values.should == [1, 1]
|
221
|
+
end
|
222
|
+
|
223
|
+
it "finds the matching resources" do
|
224
|
+
expect(@users.to_a).not_to be_empty
|
225
|
+
@users.to_a.first.should == @fred
|
226
|
+
end
|
227
|
+
end
|
228
|
+
|
229
|
+
context "with both an OFFSET in the SQL and an :offset option" do
|
230
|
+
before(:each) do
|
231
|
+
@users = User.by_sql(:offset => 1) { |u| "SELECT #{u.*} FROM #{u} LIMIT 1 OFFSET 0" }
|
232
|
+
@sql, @bind_values = User.repository.adapter.send(:select_statement, @users.query)
|
233
|
+
end
|
234
|
+
|
235
|
+
it "the :offset in the options takes precendence" do
|
236
|
+
@sql.should == %q{SELECT "users"."id", "users"."username", "users"."role" FROM "users" ORDER BY "users"."id" LIMIT ? OFFSET ?}
|
237
|
+
@bind_values.should == [1, 1]
|
238
|
+
end
|
239
|
+
end
|
240
|
+
|
241
|
+
describe "chaining" do
|
242
|
+
describe "to override a previous :limit option" do
|
243
|
+
before(:each) do
|
244
|
+
@users = User.by_sql(:limit => 2) { |u| "SELECT #{u.*} FROM #{u}" }.all(:limit => 1)
|
245
|
+
@sql, @bind_values = User.repository.adapter.send(:select_statement, @users.query)
|
246
|
+
end
|
247
|
+
|
248
|
+
it "the last :limit option takes precedence" do
|
249
|
+
@sql.should == %q{SELECT "users"."id", "users"."username", "users"."role" FROM "users" ORDER BY "users"."id" LIMIT ?}
|
250
|
+
@bind_values.should == [1]
|
251
|
+
end
|
252
|
+
end
|
253
|
+
|
254
|
+
describe "to override a limit applied in SQL" do
|
255
|
+
before(:each) do
|
256
|
+
@users = User.by_sql { |u| "SELECT #{u.*} FROM #{u} LIMIT 1" }.all(:limit => 2)
|
257
|
+
@sql, @bind_values = User.repository.adapter.send(:select_statement, @users.query)
|
258
|
+
end
|
259
|
+
|
260
|
+
it "the last :limit option takes precedence" do
|
261
|
+
@sql.should == %q{SELECT "users"."id", "users"."username", "users"."role" FROM "users" ORDER BY "users"."id" LIMIT ?}
|
262
|
+
@bind_values.should == [2]
|
263
|
+
end
|
264
|
+
end
|
265
|
+
end
|
266
|
+
end
|
267
|
+
|
268
|
+
context "with an INNER JOIN" do
|
269
|
+
before(:each) do
|
270
|
+
@bobs_post = @bob.posts.create(:title => "Bob can write posts")
|
271
|
+
@freds_post = @fred.posts.create(:title => "Fred likes to write too")
|
272
|
+
|
273
|
+
@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] }
|
274
|
+
@sql, @bind_values = Post.repository.adapter.send(:select_statement, @posts.query)
|
275
|
+
end
|
276
|
+
|
277
|
+
it "executes the original query" do
|
278
|
+
@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"}
|
279
|
+
@bind_values.should == [@bob.id]
|
280
|
+
end
|
281
|
+
|
282
|
+
it "finds the matching resources" do
|
283
|
+
@posts.should include(@bobs_post)
|
284
|
+
end
|
285
|
+
|
286
|
+
it "does not find incorrect resources" do
|
287
|
+
@posts.should_not include(@freds_post)
|
288
|
+
end
|
289
|
+
|
290
|
+
context "to the same table" do
|
291
|
+
before(:each) do
|
292
|
+
@posts = Post.by_sql(Post) { |p1, p2| "SELECT #{p1.*} FROM #{p1} INNER JOIN #{p2} ON #{p1.id} = #{p2.id}" }
|
293
|
+
@sql, @bind_values = Post.repository.adapter.send(:select_statement, @posts.query)
|
294
|
+
end
|
295
|
+
|
296
|
+
it "creates alises for the subsequent tables" do
|
297
|
+
@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"}
|
298
|
+
end
|
299
|
+
end
|
300
|
+
end
|
301
|
+
|
302
|
+
describe "aggregate queries" do
|
303
|
+
it "supports calculating counts" do
|
304
|
+
User.by_sql { |u| "SELECT #{u.*} FROM #{u}" }.count(:id).should == 2
|
305
|
+
end
|
306
|
+
|
307
|
+
it "supports multiple aggregates" do
|
308
|
+
User.by_sql { |u| "SELECT #{u.*} FROM #{u}" }.aggregate(:id.count, :id.min, :username.max).should == [2, 1, "Fred"]
|
309
|
+
end
|
310
|
+
end
|
311
|
+
|
312
|
+
describe "with virtual attributes" do
|
313
|
+
before(:each) do
|
314
|
+
@bob.posts.create(:title => "Test")
|
315
|
+
@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}" }
|
316
|
+
end
|
317
|
+
|
318
|
+
it "loads the virtual attributes" do
|
319
|
+
@users.to_a.first.post_count.should == 1
|
320
|
+
end
|
321
|
+
end
|
322
|
+
|
323
|
+
# fixed an obscure bug with state leakage here
|
324
|
+
describe "#reverse!" do
|
325
|
+
it "is consistent between invocations" do
|
326
|
+
User.all.query.reverse.order.first.operator.should == :desc
|
327
|
+
User.all.query.reverse.order.first.operator.should == :desc
|
328
|
+
end
|
329
|
+
end
|
330
|
+
end
|
331
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -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
|
metadata
ADDED
@@ -0,0 +1,156 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: sbf-dm-sql-finders
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.4
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- opensource_firespring
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2025-01-07 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: sbf-dm-core
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1.3'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '1.3'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: sbf-dm-do-adapter
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.3'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '1.3'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rspec
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '3.13'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '3.13'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: sbf-dm-migrations
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '1.3'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '1.3'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: sbf-dm-aggregates
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '1.3'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '1.3'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: sbf-dm-sqlite-adapter
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - "~>"
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '1.3'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - "~>"
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '1.3'
|
97
|
+
description: |-
|
98
|
+
dm-sql-finders add #by_sql to your DataMapper models and provides a clean mechanism for using
|
99
|
+
the names of the properties in your model, instead of the actual fields in the database. Any SQL
|
100
|
+
is supported and actual DataMapper Query objects wrap the SQL, thus delaying its execution until
|
101
|
+
a kicker method materializes the records for the query. You can also chain standard DataMapper
|
102
|
+
query methods onto the #by_sql call to refine the query.
|
103
|
+
email:
|
104
|
+
- opensource@firespring.com
|
105
|
+
executables: []
|
106
|
+
extensions: []
|
107
|
+
extra_rdoc_files: []
|
108
|
+
files:
|
109
|
+
- ".gitignore"
|
110
|
+
- ".rspec"
|
111
|
+
- Gemfile
|
112
|
+
- LICENSE
|
113
|
+
- README.md
|
114
|
+
- Rakefile
|
115
|
+
- dm-sql-finders.gemspec
|
116
|
+
- lib/data_mapper/sql_finders.rb
|
117
|
+
- lib/data_mapper/sql_finders/adapter.rb
|
118
|
+
- lib/data_mapper/sql_finders/query.rb
|
119
|
+
- lib/data_mapper/sql_finders/sql_builder.rb
|
120
|
+
- lib/data_mapper/sql_finders/sql_parser.rb
|
121
|
+
- lib/data_mapper/sql_finders/table_representation.rb
|
122
|
+
- lib/data_mapper/sql_finders/version.rb
|
123
|
+
- lib/dm-sql-finders.rb
|
124
|
+
- spec/public/adapter_spec.rb
|
125
|
+
- spec/spec_helper.rb
|
126
|
+
- spec/support/fixtures/post.rb
|
127
|
+
- spec/support/fixtures/user.rb
|
128
|
+
homepage: https://github.com/d11wtq/dm-sql-finders
|
129
|
+
licenses:
|
130
|
+
- Nonstandard
|
131
|
+
metadata: {}
|
132
|
+
post_install_message:
|
133
|
+
rdoc_options: []
|
134
|
+
require_paths:
|
135
|
+
- lib
|
136
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
137
|
+
requirements:
|
138
|
+
- - ">="
|
139
|
+
- !ruby/object:Gem::Version
|
140
|
+
version: '0'
|
141
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
142
|
+
requirements:
|
143
|
+
- - ">="
|
144
|
+
- !ruby/object:Gem::Version
|
145
|
+
version: '0'
|
146
|
+
requirements: []
|
147
|
+
rubygems_version: 3.4.10
|
148
|
+
signing_key:
|
149
|
+
specification_version: 4
|
150
|
+
summary: Query DataMapper models using raw SQL, without sacrificing property and table
|
151
|
+
name abstraction
|
152
|
+
test_files:
|
153
|
+
- spec/public/adapter_spec.rb
|
154
|
+
- spec/spec_helper.rb
|
155
|
+
- spec/support/fixtures/post.rb
|
156
|
+
- spec/support/fixtures/user.rb
|