mao 0.0.3

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/.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: