mao 0.0.3

Sign up to get free protection for your applications and to get access to all the features.
data/.autotest ADDED
@@ -0,0 +1,2 @@
1
+ require 'autotest/growl'
2
+ require 'autotest/fsevent'
data/.gitignore ADDED
@@ -0,0 +1 @@
1
+ /pkg
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --color
data/.travis.yml ADDED
@@ -0,0 +1,5 @@
1
+ before_script:
2
+ - psql -c 'create database mao_testing;' -U postgres
3
+ rvm:
4
+ - 1.9.2
5
+ - 1.9.3
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+ gemspec
3
+
4
+ # vim: set sw=2 cc=80 et:
data/Gemfile.lock ADDED
@@ -0,0 +1,18 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ norm (0.0.1)
5
+ pg
6
+
7
+ GEM
8
+ remote: https://rubygems.org/
9
+ specs:
10
+ pg (0.14.1)
11
+ rake (0.9.2.2)
12
+
13
+ PLATFORMS
14
+ ruby
15
+
16
+ DEPENDENCIES
17
+ norm!
18
+ rake
data/README.md ADDED
@@ -0,0 +1,14 @@
1
+ # Mao [![Build Status](https://secure.travis-ci.org/unnali/mao.png)](http://travis-ci.org/unnali/mao)
2
+
3
+ **Mao Ain't an ORM.**
4
+
5
+ ## mission/tenets
6
+
7
+ ## authorship
8
+
9
+ * [Timothy Leslie Allen](https://github.com/timothyleslieallen).
10
+ * [Arlen Christian Mart Cuss](https://github.com/unnali).
11
+
12
+ ## copyright and licensing
13
+
14
+ TBD.
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ require 'rspec/core/rake_task'
2
+ require 'bundler/gem_tasks'
3
+
4
+ task :default => :spec
5
+
6
+ RSpec::Core::RakeTask.new
7
+
8
+ # vim: set sw=2 et cc=80:
data/TODO ADDED
@@ -0,0 +1,5 @@
1
+ Specify error handling at all levels.
2
+ Joins/eager loads. What syntax? How do we prevent unexpected blowouts?
3
+ - no auto subqueries, etc. Thinner wrapper over SQL than AR.
4
+
5
+ Do JOINs without all the "c1" "c2" "c3" business; assume (= check we can assume) that the primary table's columns come first, and secondary table's after; then just pick off the first n cols of a result tuple (where n is number of cols in primary table) and assign them to the primary table, and the remaining m (where m is no. of cols in secondary) to secondary.
data/lib/mao/filter.rb ADDED
@@ -0,0 +1,168 @@
1
+ # encoding: utf-8
2
+
3
+ # A mix-in for any kind of filter in a where clause (vis-à-vis
4
+ # Mao::Query#where).
5
+ module Mao::Filter
6
+ # If +obj+ is a Mao::Filter, call #finalize on it; otherwise, use
7
+ # Mao.escape_literal to escape +obj.to_s+.
8
+ def self.finalize_or_literal(obj)
9
+ if obj.is_a? Mao::Filter
10
+ obj.finalize
11
+ else
12
+ Mao.escape_literal(obj)
13
+ end
14
+ end
15
+
16
+ # Generate the SQL for the finalized object +finalized+. If +finalized+ is a
17
+ # String, it's returned without modification.
18
+ def self.sql(finalized)
19
+ if finalized.is_a? String
20
+ finalized
21
+ else
22
+ klass, *args = finalized
23
+ Mao::Filter.const_get(klass).sql(*args)
24
+ end
25
+ end
26
+
27
+ def initialize(options={})
28
+ @options = options.freeze
29
+ end
30
+
31
+ attr_reader :options
32
+
33
+ # Returns an AND filter where the current object is the LHS and +rhs+ is the
34
+ # RHS.
35
+ def and(rhs)
36
+ Mao::Filter::Binary.new(:op => 'AND', :lhs => self, :rhs => rhs).freeze
37
+ end
38
+
39
+ # Returns an OR filter where the current object is the LHS and +rhs+ is the
40
+ # RHS.
41
+ def or(rhs)
42
+ Mao::Filter::Binary.new(:op => 'OR', :lhs => self, :rhs => rhs).freeze
43
+ end
44
+
45
+ # Returns an equality binary filter where the current object is the LHS and
46
+ # +rhs+ is the RHS.
47
+ def ==(rhs)
48
+ Mao::Filter::Binary.new(:op => '=', :lhs => self, :rhs => rhs)
49
+ end
50
+
51
+ # Returns an inequality binary filter where the current object is the LHS and
52
+ # +rhs+ is the RHS.
53
+ def !=(rhs)
54
+ Mao::Filter::Binary.new(:op => '<>', :lhs => self, :rhs => rhs)
55
+ end
56
+
57
+ # Returns a greater-than binary filter where the current object is the LHS
58
+ # and +rhs+ is the RHS.
59
+ def >(rhs)
60
+ Mao::Filter::Binary.new(:op => '>', :lhs => self, :rhs => rhs)
61
+ end
62
+
63
+ # Returns a greater-than-or-equal-to binary filter where the current object
64
+ # is the LHS and +rhs+ is the RHS.
65
+ def >=(rhs)
66
+ Mao::Filter::Binary.new(:op => '>=', :lhs => self, :rhs => rhs)
67
+ end
68
+
69
+ # Returns a less-than binary filter where the current object is the LHS and
70
+ # +rhs+ is the RHS.
71
+ def <(rhs)
72
+ Mao::Filter::Binary.new(:op => '<', :lhs => self, :rhs => rhs)
73
+ end
74
+
75
+ # Returns a less-than-or-equal-to binary filter where the current object is
76
+ # the LHS and +rhs+ is the RHS.
77
+ def <=(rhs)
78
+ Mao::Filter::Binary.new(:op => '<=', :lhs => self, :rhs => rhs)
79
+ end
80
+
81
+ # Returns a filter where the current object is checked if it IS NULL.
82
+ # HACK(arlen): ? Calling this "nil?" results in the world crashing down
83
+ # around us. But it seems a pity to have this be not-quite-like-Ruby. Would
84
+ # it be better to make #==(nil) map to IS NULL instead of = NULL?
85
+ def null?
86
+ Mao::Filter::Binary.new(:op => 'IS', :lhs => self, :rhs => nil)
87
+ end
88
+
89
+ # Returns a filter where the current object is checked if it is IN +rhs+,
90
+ # typically a list.
91
+ def in(rhs)
92
+ Mao::Filter::Binary.new(:op => 'IN', :lhs => self, :rhs => rhs)
93
+ end
94
+
95
+ class Column
96
+ include Mao::Filter
97
+
98
+ def finalize
99
+ if @options[:table]
100
+ [:Column, @options[:table], @options[:name]]
101
+ else
102
+ [:Column, @options[:name]]
103
+ end
104
+ end
105
+
106
+ def self.sql(*opts)
107
+ opts.map {|i| Mao.quote_ident(i.to_s)}.join(".")
108
+ end
109
+ end
110
+
111
+ class Binary
112
+ include Mao::Filter
113
+
114
+ def finalize
115
+ [:Binary,
116
+ @options[:op],
117
+ Mao::Filter.finalize_or_literal(@options[:lhs]),
118
+ Mao::Filter.finalize_or_literal(@options[:rhs])]
119
+ end
120
+
121
+ def self.sql(op, lhs, rhs)
122
+ s = "("
123
+ s << Mao::Filter.sql(lhs)
124
+ s << " "
125
+ s << op
126
+ s << " "
127
+ s << Mao::Filter.sql(rhs)
128
+ s << ")"
129
+ s
130
+ end
131
+ end
132
+
133
+ # A context for the Mao::Query#where DSL, and for Mao::Query#join's table
134
+ # objects. Any non-lexically bound names hit WhereContext#method_missing,
135
+ # which checks if it belongs to a column, and if so, constructs a
136
+ # Mao::Filter::Column.
137
+ class Table
138
+ def initialize(query, explicit)
139
+ @query = query
140
+ @explicit = explicit
141
+ end
142
+
143
+ # Ensure +args+ and +block+ are both empty. Assert that a column for the
144
+ # query this context belongs to by the name +name+ exists, and return a
145
+ # Mao::Filter::Column for that column.
146
+ def method_missing(name, *args, &block)
147
+ if args.length > 0
148
+ raise ArgumentError, "args not expected in #where subclause"
149
+ end
150
+
151
+ if block
152
+ raise ArgumentError, "block not expected in #where subclause"
153
+ end
154
+
155
+ unless @query.col_types[name]
156
+ raise ArgumentError, "unknown column for #{@query.table}: #{name}"
157
+ end
158
+
159
+ if @explicit
160
+ Column.new(:table => @query.table.to_sym, :name => name).freeze
161
+ else
162
+ Column.new(:name => name).freeze
163
+ end
164
+ end
165
+ end
166
+ end
167
+
168
+ # vim: set sw=2 cc=80 et:
data/lib/mao/query.rb ADDED
@@ -0,0 +1,381 @@
1
+ # A persistent query structure which can be manipulated by creating new
2
+ # persistent queries, and eventually executed.
3
+ #
4
+ # All "state" about the query itself is deliberately stored in a simple Hash,
5
+ # @options, to provide transparency and ensure simplicity of the resulting
6
+ # design.
7
+ class Mao::Query
8
+ require 'mao/filter'
9
+
10
+ # A container for text that should be inserted raw into a query.
11
+ class Raw
12
+ def initialize(text)
13
+ @text = text
14
+ end
15
+
16
+ attr_reader :text
17
+ end
18
+
19
+ # Returns a Mao::Query::Raw with +text+.
20
+ def self.raw(text)
21
+ Raw.new(text).freeze
22
+ end
23
+
24
+ def initialize(table, options={}, col_types=nil)
25
+ @table, @options = table.to_sym, options.freeze
26
+
27
+ if !col_types
28
+ col_types = {}
29
+ Mao.sql(
30
+ 'SELECT column_name, data_type FROM information_schema.columns ' \
31
+ 'WHERE table_name=$1',
32
+ [@table.to_s]) do |pg_result|
33
+ if pg_result.num_tuples.zero?
34
+ raise ArgumentError, "invalid or blank table #@table"
35
+ end
36
+
37
+ pg_result.each do |tuple|
38
+ col_types[tuple["column_name"].to_sym] = tuple["data_type"]
39
+ end
40
+ end
41
+ end
42
+
43
+ @col_types = col_types.freeze
44
+ end
45
+
46
+ attr_reader :table
47
+ attr_reader :options
48
+ attr_reader :col_types
49
+
50
+ # Returns a new Mao::Query with +options+ merged into the options of this
51
+ # object.
52
+ def with_options(options)
53
+ self.class.new(@table, @options.merge(options), @col_types).freeze
54
+ end
55
+
56
+ # Restricts the query to at most +n+ results.
57
+ def limit(n)
58
+ unless n.is_a? Integer
59
+ raise ArgumentError, "#{n.inspect} not an Integer"
60
+ end
61
+
62
+ with_options(:limit => n.to_i)
63
+ end
64
+
65
+ # Returns the query's results sorted by +column+ in +direction+, either :asc
66
+ # or :desc.
67
+ def order(column, direction)
68
+ unless column.is_a?(Symbol) and [:asc, :desc].include?(direction)
69
+ raise ArgumentError,
70
+ "#{column.inspect} not a Symbol or " \
71
+ "#{direction.inspect} not :asc or :desc"
72
+ end
73
+
74
+ check_column(column, @table, @col_types)
75
+
76
+ direction = direction == :asc ? "ASC" : "DESC"
77
+ with_options(:order => [column, direction])
78
+ end
79
+
80
+ # Only returns the given +columns+, Symbols (possibly nested in Arrays).
81
+ #
82
+ # If +columns+ is a single argument, and it's a Hash, the keys should be
83
+ # Symbols corresponding to table names, and the values Arrays of Symbol
84
+ # column names. This is only for use with #join, and #only must be called
85
+ # after #join.
86
+ def only(*columns)
87
+ columns = columns.flatten
88
+
89
+ if columns.length == 1 and columns[0].is_a?(Hash)
90
+ unless @options[:join]
91
+ raise ArgumentError, "#only with a Hash must be used only after #join"
92
+ end
93
+
94
+ other = Mao.query(@options[:join][0])
95
+ columns = columns[0]
96
+ columns.each do |table, table_columns|
97
+ unless table_columns.is_a? Array
98
+ raise ArgumentError, "#{table_columns.inspect} is not an Array"
99
+ end
100
+
101
+ if table == @table
102
+ table_columns.each do |column|
103
+ check_column(column, @table, @col_types)
104
+ end
105
+ elsif table == other.table
106
+ table_columns.each do |column|
107
+ check_column(column, other.table, other.col_types)
108
+ end
109
+ else
110
+ raise ArgumentError, "#{table} is not a column in this query"
111
+ end
112
+ end
113
+ else
114
+ columns.each do |column|
115
+ check_column(column, @table, @col_types)
116
+ end
117
+ end
118
+
119
+ with_options(:only => columns)
120
+ end
121
+
122
+ # For INSERTs, returns +columns+ for inserted rows.
123
+ def returning(*columns)
124
+ columns = columns.flatten
125
+
126
+ columns.each do |column|
127
+ check_column(column, @table, @col_types)
128
+ end
129
+
130
+ with_options(:returning => columns)
131
+ end
132
+
133
+ # A context for the #join DSL. Any non-lexically bound names hit
134
+ # JoinContext#method_missing, which constructs a Mao::Filter::Table for the
135
+ # table with that name.
136
+ class JoinContext
137
+ # Ensure +args+ and +block+ are both empty. Creates a Mao::Query for the
138
+ # name invoked, which ensures such a table exists. Assuming it exists, a
139
+ # Mao::Filter::Table for that query is constructed.
140
+ def method_missing(name, *args, &block)
141
+ if args.length > 0
142
+ raise ArgumentError, "args not expected in #where subclause"
143
+ end
144
+
145
+ if block
146
+ raise ArgumentError, "block not expected in #where subclause"
147
+ end
148
+
149
+ Mao::Filter::Table.new(Mao.query(name), true).freeze
150
+ end
151
+ end
152
+
153
+ # Filters results based on the conditions specified in +block+.
154
+ #
155
+ # Depending on if #join has been called, one of two things occur:
156
+ # 1. If #join has not been called, +block+ has available in context the
157
+ # column names of the table being queried; or,
158
+ # 2. If #join has been called, +block+ has available in context the table
159
+ # names of the tables involved in the query. Per #join, those objects
160
+ # will have the columns available as methods.
161
+ #
162
+ # Once you have a column, use regular operators to construct tests, e.g. "x
163
+ # == 3" will filter where the column "x" has value 3.
164
+ #
165
+ # Boolean operations on columns return Mao::Filter objects; use #and and #or
166
+ # to combine them. The return value of the block should be the full desired
167
+ # filter.
168
+ def where(&block)
169
+ if @options[:join]
170
+ context = JoinContext.new.freeze
171
+ else
172
+ context = Mao::Filter::Table.new(self, false).freeze
173
+ end
174
+
175
+ with_options(:where => context.instance_exec(&block).finalize)
176
+ end
177
+
178
+ # Joins the results of this table against another table, +target+. The
179
+ # conditions for joining one row in this table against one in +target+ are
180
+ # specified in +block+.
181
+ #
182
+ # +block+ is per #where's, except the names in context are tables, not
183
+ # columns; the tables returned are the same as the context of #where itself,
184
+ # so for instance, "blah.x == 3" will filter where the column "x" of table
185
+ # "blah" (which should be either this table, or the +target+ table) equals 3.
186
+ #
187
+ # Boolean operations are then all per #where.
188
+ #
189
+ # If +block+ is not specified, +target+ is instead treated as a Hash of the
190
+ # form {foreign_table => {local_key => foreign_key}}.
191
+ def join(target, &block)
192
+ if !block
193
+ local_table = @table
194
+ foreign_table = target.keys[0]
195
+ mapping = target[foreign_table]
196
+ local_key = mapping.keys[0]
197
+ foreign_key = mapping[local_key]
198
+ return join(foreign_table) {
199
+ send(local_table).send(local_key) ==
200
+ send(foreign_table).send(foreign_key)
201
+ }
202
+ end
203
+
204
+ context = JoinContext.new.freeze
205
+
206
+ with_options(:join => [target, context.instance_exec(&block).finalize])
207
+ end
208
+
209
+ # Constructs the SQL for this query.
210
+ def sql
211
+ s = ""
212
+ options = @options.dup
213
+
214
+ if update = options.delete(:update)
215
+ s = "UPDATE "
216
+ s << Mao.quote_ident(@table)
217
+ s << " SET "
218
+
219
+ if update.length == 0
220
+ raise ArgumentError, "invalid update: nothing to set"
221
+ end
222
+
223
+ s << update.map do |column, value|
224
+ check_column(column, @table, @col_types)
225
+
226
+ "#{Mao.quote_ident(column)} = #{Mao.escape_literal(value)}"
227
+ end.join(", ")
228
+
229
+ if where = options.delete(:where)
230
+ s << " WHERE "
231
+ s << Mao::Filter.sql(where)
232
+ end
233
+ elsif insert = options.delete(:insert)
234
+ s = "INSERT INTO "
235
+ s << Mao.quote_ident(@table)
236
+ s << " ("
237
+
238
+ keys = insert.map(&:keys).flatten.uniq.sort
239
+ s << keys.map do |column|
240
+ check_column(column, @table, @col_types)
241
+ Mao.quote_ident(column)
242
+ end.join(", ")
243
+ s << ") VALUES "
244
+
245
+ first = true
246
+ insert.each do |row|
247
+ if first
248
+ first = false
249
+ else
250
+ s << ", "
251
+ end
252
+
253
+ s << "("
254
+ s << keys.map {|k|
255
+ if row.include?(k)
256
+ Mao.escape_literal(row[k])
257
+ else
258
+ "DEFAULT"
259
+ end
260
+ }.join(", ")
261
+ s << ")"
262
+ end
263
+
264
+ if returning = options.delete(:returning)
265
+ s << " RETURNING "
266
+ s << returning.map {|c| Mao.quote_ident(c)}.join(", ")
267
+ end
268
+ else
269
+ s = "SELECT "
270
+
271
+ join = options.delete(:join)
272
+ only = options.delete(:only)
273
+
274
+ if join
275
+ n = 0
276
+ s << (@col_types.keys.sort.map {|c|
277
+ n += 1
278
+ if !only or (only[@table] and only[@table].include?(c))
279
+ "#{Mao.quote_ident(@table)}.#{Mao.quote_ident(c)} " +
280
+ "#{Mao.quote_ident("c#{n}")}"
281
+ end
282
+ } + Mao.query(join[0]).col_types.keys.sort.map {|c|
283
+ n += 1
284
+ if !only or (only[join[0]] and only[join[0]].include?(c))
285
+ "#{Mao.quote_ident(join[0])}.#{Mao.quote_ident(c)} " +
286
+ "#{Mao.quote_ident("c#{n}")}"
287
+ end
288
+ }).reject(&:nil?).join(", ")
289
+ elsif only
290
+ s << only.map {|c| Mao.quote_ident(c)}.join(", ")
291
+ else
292
+ s << "*"
293
+ end
294
+
295
+ s << " FROM #{Mao.quote_ident(@table)}"
296
+
297
+ if join
298
+ s << " INNER JOIN #{Mao.quote_ident(join[0])} ON "
299
+ s << Mao::Filter.sql(join[1])
300
+ end
301
+
302
+ if where = options.delete(:where)
303
+ s << " WHERE "
304
+ s << Mao::Filter.sql(where)
305
+ end
306
+
307
+ if order = options.delete(:order)
308
+ s << " ORDER BY "
309
+ s << Mao.quote_ident(order[0])
310
+ s << " "
311
+ s << order[1]
312
+ end
313
+
314
+ if limit = options.delete(:limit)
315
+ s << " LIMIT #{limit}"
316
+ end
317
+ end
318
+
319
+ if options.length > 0
320
+ raise ArgumentError,
321
+ "invalid options in #sql: #{options.inspect}. " \
322
+ "SQL constructed: #{s}"
323
+ end
324
+
325
+ s
326
+ end
327
+
328
+ # Executes the constructed query and returns an Array of Hashes of results.
329
+ def select!
330
+ # Ensure we can never be destructive by nilifying :update.
331
+ Mao.sql(with_options(:update => nil).sql) do |pg_result|
332
+ if @options[:join]
333
+ other = Mao.query(@options[:join][0])
334
+ pg_result.map {|result|
335
+ Mao.normalize_join_result(result, self, other)
336
+ }
337
+ else
338
+ pg_result.map {|result| Mao.normalize_result(result, @col_types)}
339
+ end
340
+ end
341
+ end
342
+
343
+ # Limits the query to one result, and returns that result.
344
+ def select_first!
345
+ limit(1).select!.first
346
+ end
347
+
348
+ # Executes the changes in Hash +changes+ to the rows matching this object,
349
+ # returning the number of affected rows.
350
+ def update!(changes)
351
+ Mao.sql(with_options(:update => changes).sql) do |pg_result|
352
+ pg_result.cmd_tuples
353
+ end
354
+ end
355
+
356
+ # Inserts +rows+ into the table. No other options should be applied to this
357
+ # query. Returns the number of inserted rows, unless #returning was called,
358
+ # in which case the calculated values from the INSERT are returned.
359
+ def insert!(*rows)
360
+ Mao.sql(with_options(:insert => rows.flatten).sql) do |pg_result|
361
+ if @options[:returning]
362
+ pg_result.map {|result| Mao.normalize_result(result, @col_types)}
363
+ else
364
+ pg_result.cmd_tuples
365
+ end
366
+ end
367
+ end
368
+
369
+ private
370
+
371
+ def check_column column, table, col_types
372
+ unless column.is_a? Symbol
373
+ raise ArgumentError, "#{column.inspect} not a Symbol"
374
+ end
375
+ unless col_types[column]
376
+ raise ArgumentError, "#{column.inspect} is not a column in #{table}"
377
+ end
378
+ end
379
+ end
380
+
381
+ # vim: set sw=2 cc=80 et:
@@ -0,0 +1,5 @@
1
+ module Mao
2
+ VERSION = "0.0.3"
3
+ end
4
+
5
+ # vim: set sw=2 cc=80 et: