mochigome 0.0.3 → 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.
- data/Gemfile +2 -0
- data/Gemfile.lock +4 -0
- data/Rakefile +2 -1
- data/lib/arel_rails2_hacks.rb +49 -0
- data/lib/data_node.rb +4 -0
- data/lib/exceptions.rb +1 -0
- data/lib/mochigome.rb +1 -0
- data/lib/mochigome_ver.rb +1 -1
- data/lib/model_extensions.rb +202 -122
- data/lib/query.rb +295 -148
- data/test/app_root/app/models/category.rb +4 -2
- data/test/app_root/app/models/product.rb +14 -5
- data/test/app_root/app/models/sale.rb +3 -1
- data/test/app_root/config/initializers/arel.rb +2 -0
- data/test/app_root/db/migrate/20110817163830_create_tables.rb +0 -1
- data/test/console.sh +6 -0
- data/test/factories.rb +1 -2
- data/test/test_helper.rb +49 -49
- data/test/unit/data_node_test.rb +7 -0
- data/test/unit/model_extensions_test.rb +110 -93
- data/test/unit/query_test.rb +233 -64
- metadata +32 -14
data/lib/query.rb
CHANGED
@@ -1,199 +1,346 @@
|
|
1
1
|
require 'rgl/adjacency'
|
2
|
+
require 'rgl/traversal'
|
2
3
|
|
3
4
|
module Mochigome
|
4
5
|
class Query
|
5
|
-
def initialize(layer_types,
|
6
|
+
def initialize(layer_types, options = {})
|
6
7
|
# TODO: Validate layer types: not empty, AR, act_as_mochigome_focus, graph correctly, no repeats
|
7
8
|
@layer_types = layer_types
|
8
|
-
@
|
9
|
-
end
|
10
|
-
|
11
|
-
def run(objs)
|
12
|
-
objs = [objs] unless objs.is_a?(Enumerable)
|
13
|
-
return DataNode.new(:report, @name) if objs.size == 0 # Empty DataNode for empty input
|
9
|
+
@layers_path = self.class.path_thru(layer_types)
|
14
10
|
|
15
|
-
|
16
|
-
|
11
|
+
@name = options.delete(:root_name).try(:to_s) || "report"
|
12
|
+
@access_filter = options.delete(:access_filter) || lambda {|cls| {}}
|
13
|
+
aggregate_sources = options.delete(:aggregate_sources) || []
|
14
|
+
unless options.empty?
|
15
|
+
raise QueryError.new("Unknown options: #{options.keys.inspect}")
|
17
16
|
end
|
18
17
|
|
19
|
-
|
20
|
-
|
18
|
+
@ids_rel = self.class.relation_over_path(@layers_path).
|
19
|
+
project(@layers_path.map{|m|
|
20
|
+
Arel::Table.new(m.table_name)[m.primary_key]
|
21
|
+
})
|
22
|
+
@ids_rel = access_filtered_relation(@ids_rel, @layers_path)
|
23
|
+
|
24
|
+
# TODO: Validate that aggregate_sources is in the correct format
|
25
|
+
aggs_by_model = {}
|
26
|
+
aggregate_sources.each do |focus_cls, data_cls|
|
27
|
+
aggs_by_model[focus_cls] ||= []
|
28
|
+
aggs_by_model[focus_cls] << data_cls
|
21
29
|
end
|
22
30
|
|
23
|
-
|
24
|
-
|
31
|
+
@aggregate_rels = {}
|
32
|
+
aggs_by_model.each do |focus_model, data_models|
|
33
|
+
# Need to do a relation over the entire path in case query runs
|
34
|
+
# on something other than the focus model layer.
|
35
|
+
# TODO: Would be better to only do this if necesssitated by the
|
36
|
+
# conditions supplied to the query when it is ran, and/or
|
37
|
+
# the access filter.
|
38
|
+
focus_rel = self.class.relation_over_path(@layers_path)
|
25
39
|
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
assoc_str = "-> #{cls.name} via #{cur_layer.first[:obj].class.name}##{assoc.name}"
|
41
|
-
if assoc.through_reflection
|
42
|
-
assoc_str << " (thru #{assoc.through_reflection.name})"
|
43
|
-
end
|
44
|
-
assoc_path.push assoc_str
|
45
|
-
|
46
|
-
cur_layer.each do |datanode|
|
47
|
-
# FIXME: Don't assume that downwards means plural association
|
48
|
-
# TODO: Are there other ways context could matter besides :through assocs?
|
49
|
-
# i.e. If C belongs_to A and also belongs_to B, and layer_types = [A,B,C]
|
50
|
-
# TODO: What if a through reflection goes through _another_ through reflection?
|
51
|
-
if assoc.through_reflection
|
52
|
-
datanode[:obj].send(assoc.through_reflection.name).each do |through_obj|
|
53
|
-
# TODO: Don't assume that through means singular!
|
54
|
-
obj = through_obj.send(assoc.source_reflection.name)
|
55
|
-
subnode = datanode << DataNode.new(
|
56
|
-
obj.mochigome_focus.type_name,
|
57
|
-
obj.mochigome_focus.name,
|
58
|
-
{:obj => obj, :through_obj => through_obj}
|
59
|
-
)
|
60
|
-
new_layer << subnode
|
61
|
-
end
|
62
|
-
else
|
63
|
-
#FIXME: Not DRY
|
64
|
-
datanode[:obj].send(assoc.name).each do |obj|
|
65
|
-
subnode = datanode << DataNode.new(
|
66
|
-
obj.mochigome_focus.type_name,
|
67
|
-
obj.mochigome_focus.name,
|
68
|
-
[{:obj => obj}]
|
69
|
-
)
|
70
|
-
new_layer << subnode
|
40
|
+
# TODO: Properly handle focus model that is not in layer types list
|
41
|
+
key_path = @layers_path.take_while{|m| m != focus_model} + [focus_model]
|
42
|
+
key_cols = key_path.map{|m|
|
43
|
+
Arel::Table.new(m.table_name)[m.primary_key]
|
44
|
+
}
|
45
|
+
|
46
|
+
@aggregate_rels[focus_model] = {}
|
47
|
+
data_models.each do |data_model|
|
48
|
+
f2d_path = self.class.path_thru([focus_model, data_model]) #TODO: Handle nil here
|
49
|
+
agg_path = nil
|
50
|
+
f2d_path.reverse.each do |link_model|
|
51
|
+
if @layers_path.include?(link_model)
|
52
|
+
agg_path = f2d_path.drop_while{|m| m != link_model}
|
53
|
+
break
|
71
54
|
end
|
72
55
|
end
|
56
|
+
# TODO: Properly handle focus model that is not in layer types list
|
57
|
+
fail unless agg_path
|
58
|
+
|
59
|
+
agg_data_rel = self.class.relation_over_path(agg_path, focus_rel.dup)
|
60
|
+
agg_data_rel = access_filtered_relation(agg_data_rel, @layers_path + agg_path)
|
61
|
+
data_tbl = Arel::Table.new(data_model.table_name)
|
62
|
+
agg_fields = data_model.mochigome_aggregation_settings.options[:fields].reject{|a| a[:in_ruby]}
|
63
|
+
agg_data_rel.project
|
64
|
+
agg_fields.each_with_index do |a, i|
|
65
|
+
agg_data_rel.project(a[:value_proc].call(data_tbl).as("d#{i}"))
|
66
|
+
end
|
67
|
+
|
68
|
+
@aggregate_rels[focus_model][data_model] = (0..key_cols.length).map{|n|
|
69
|
+
lambda {|cond|
|
70
|
+
d_rel = agg_data_rel.dup
|
71
|
+
d_cols = key_cols.take(n) + [Arel::Table.new(data_model.table_name)[data_model.primary_key]]
|
72
|
+
d_cols.each_with_index do |col, i|
|
73
|
+
d_rel.project(col.as("g#{i}")).group(col)
|
74
|
+
end
|
75
|
+
d_rel.where(cond)
|
76
|
+
|
77
|
+
a_rel = Arel::SelectManager.new(
|
78
|
+
Arel::Table.engine,
|
79
|
+
Arel.sql("(#{d_rel.to_sql}) as mochigome_data")
|
80
|
+
)
|
81
|
+
d_tbl = Arel::Table.new("mochigome_data")
|
82
|
+
agg_fields.each_with_index do |a, i|
|
83
|
+
a_rel.project(a[:agg_proc].call(d_tbl["d#{i}"]))
|
84
|
+
end
|
85
|
+
key_cols.take(n).each_with_index do |col, i|
|
86
|
+
outer_name = "og#{i}"
|
87
|
+
a_rel.project(d_tbl["g#{i}"].as(outer_name)).group(outer_name)
|
88
|
+
end
|
89
|
+
a_rel
|
90
|
+
}
|
91
|
+
}
|
73
92
|
end
|
74
|
-
|
93
|
+
|
75
94
|
end
|
95
|
+
end
|
76
96
|
|
77
|
-
|
78
|
-
|
79
|
-
upwards_layers.each do |cls|
|
80
|
-
assoc = Query.edge_assoc(root.children.first[:obj].class, cls)
|
97
|
+
def run(cond = nil)
|
98
|
+
root = DataNode.new(:report, @name)
|
81
99
|
|
82
|
-
|
83
|
-
|
84
|
-
|
100
|
+
if cond.is_a?(ActiveRecord::Base)
|
101
|
+
cond = [cond]
|
102
|
+
end
|
103
|
+
if cond.is_a?(Array)
|
104
|
+
return root if cond.empty?
|
105
|
+
unless cond.all?{|obj| obj.class == cond.first.class}
|
106
|
+
raise QueryError.new("Query target objects must all be the same type")
|
85
107
|
end
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
108
|
+
unless @layer_types.any?{|layer| cond.first.is_a?(layer)}
|
109
|
+
raise QueryError.new("Query target's class must be in layer list")
|
110
|
+
end
|
111
|
+
cls = cond.first.class
|
112
|
+
cond = Arel::Table.new(cls.table_name)[cls.primary_key].in(cond.map(&:id))
|
113
|
+
end
|
114
|
+
|
115
|
+
q = @ids_rel.dup
|
116
|
+
q.where(cond) if cond
|
117
|
+
ids_table = @layer_types.first.connection.select_rows(q.to_sql)
|
118
|
+
ids_table = ids_table.map{|row| row.map{|cell| cell.to_i}}
|
119
|
+
|
120
|
+
fill_layers(ids_table, {:root => root}, @layer_types)
|
121
|
+
|
122
|
+
@aggregate_rels.each do |focus_model, data_model_rels|
|
123
|
+
super_types = @layer_types.take_while{|m| m != focus_model}
|
124
|
+
super_cols = super_types.map{|m| @layers_path.find_index(m)}
|
125
|
+
data_model_rels.each do |data_model, rel_funcs|
|
126
|
+
aggs = data_model.mochigome_aggregation_settings.options[:fields]
|
127
|
+
aggs_count = aggs.reject{|a| a[:in_ruby]}.size
|
128
|
+
rel_funcs.each do |rel_func|
|
129
|
+
q = rel_func.call(cond)
|
130
|
+
data_tree = {}
|
131
|
+
# Each row has aggs_count data fields, followed by the id fields
|
132
|
+
# from least specific to most.
|
133
|
+
@layer_types.first.connection.select_rows(q.to_sql).each do |row|
|
134
|
+
if row.size == aggs_count
|
135
|
+
data_tree = row.take(aggs_count)
|
136
|
+
else
|
137
|
+
c = data_tree
|
138
|
+
super_cols.each_with_index do |sc_num, sc_idx|
|
139
|
+
break if aggs_count+sc_idx >= row.size-1
|
140
|
+
col_num = aggs_count + super_cols[sc_num]
|
141
|
+
c = (c[row[col_num].to_i] ||= {})
|
142
|
+
end
|
143
|
+
c[row.last.to_i] = row.take(aggs_count)
|
118
144
|
end
|
119
|
-
parent_children_map[parent.id] << child.dup
|
120
145
|
end
|
146
|
+
insert_aggregate_data_fields(root, data_tree, data_model)
|
121
147
|
end
|
122
148
|
end
|
123
|
-
|
124
|
-
root = DataNode.new(:report, @name)
|
125
|
-
root << parent_children_map.values
|
126
149
|
end
|
127
150
|
|
128
151
|
root.comment = <<-eos
|
129
152
|
Mochigome Version: #{Mochigome::VERSION}
|
130
|
-
|
153
|
+
Report Generated: #{Time.now}
|
131
154
|
Layers: #{@layer_types.map(&:name).join(" => ")}
|
132
|
-
AR
|
133
|
-
#{assoc_path.map{|s| "* #{s}"}.join("\n")}
|
155
|
+
AR Path: #{@layers_path.map(&:name).join(" => ")}
|
134
156
|
eos
|
135
157
|
root.comment.gsub!(/\n +/, "\n")
|
136
158
|
root.comment.lstrip!
|
137
159
|
|
138
|
-
focus_data_node_objs(root)
|
139
160
|
return root
|
140
161
|
end
|
141
162
|
|
142
163
|
private
|
143
164
|
|
144
|
-
def
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
165
|
+
def access_filtered_relation(r, models)
|
166
|
+
joined = Set.new
|
167
|
+
models.uniq.each do |model|
|
168
|
+
h = @access_filter.call(model)
|
169
|
+
h.delete(:join_paths).try :each do |path|
|
170
|
+
(0..(path.size-2)).each do |i|
|
171
|
+
next if models.include?(path[i+1]) or joined.include?(path[i+1])
|
172
|
+
r = self.class.relation_func(path[i], path[i+1]).call(r)
|
173
|
+
joined.add path[i+1]
|
174
|
+
end
|
150
175
|
end
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
eos
|
157
|
-
node.comment.gsub!(/\n +/, "\n")
|
158
|
-
node.comment.lstrip!
|
176
|
+
if h[:condition]
|
177
|
+
r = r.where(h.delete(:condition))
|
178
|
+
end
|
179
|
+
unless h.empty?
|
180
|
+
raise QueryError.new("Unknown assoc filter keys #{h.keys.inspect}")
|
159
181
|
end
|
160
|
-
node.merge!(obj.mochigome_focus.data(:context => obj_stack))
|
161
|
-
node[:internal_type] = obj.class.name
|
162
|
-
end
|
163
|
-
node.children.each_index do |i|
|
164
|
-
focus_data_node_objs(node.children[i], obj_stack, i == 0 && commenting)
|
165
182
|
end
|
166
|
-
|
183
|
+
r
|
167
184
|
end
|
168
185
|
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
186
|
+
def fill_layers(ids_table, parents, types, parent_col_num = nil)
|
187
|
+
return if types.size == 0
|
188
|
+
|
189
|
+
model = types.first
|
190
|
+
col_num = @layers_path.find_index(model)
|
191
|
+
layer_ids = Set.new
|
192
|
+
cur_to_parent = {}
|
193
|
+
|
194
|
+
ids_table.each do |row|
|
195
|
+
cur_id = row[col_num]
|
196
|
+
layer_ids.add cur_id
|
197
|
+
if parent_col_num
|
198
|
+
cur_to_parent[cur_id] ||= Set.new
|
199
|
+
cur_to_parent[cur_id].add row[parent_col_num]
|
200
|
+
end
|
201
|
+
end
|
202
|
+
|
203
|
+
layer = {}
|
204
|
+
model.all( # TODO: Find a way to do this with data streaming
|
205
|
+
:conditions => {model.primary_key => layer_ids.to_a},
|
206
|
+
:order => model.mochigome_focus_settings.get_ordering
|
207
|
+
).each do |obj|
|
208
|
+
f = obj.mochigome_focus
|
209
|
+
dn = DataNode.new(f.type_name, f.name)
|
210
|
+
dn.merge!(f.field_data)
|
211
|
+
# TODO: Maybe make special fields below part of ModelExtensions?
|
212
|
+
dn[:id] = obj.id
|
213
|
+
dn[:internal_type] = obj.class.name
|
214
|
+
|
215
|
+
if parent_col_num
|
216
|
+
duping = false
|
217
|
+
cur_to_parent.fetch(obj.id).each do |parent_id|
|
218
|
+
parents.fetch(parent_id) << (duping ? dn.dup : dn)
|
219
|
+
duping = true
|
184
220
|
end
|
221
|
+
else
|
222
|
+
parents[:root] << dn
|
185
223
|
end
|
224
|
+
layer[obj.id] = dn
|
225
|
+
end
|
226
|
+
|
227
|
+
fill_layers(ids_table, layer, types.drop(1), col_num)
|
228
|
+
end
|
229
|
+
|
230
|
+
def insert_aggregate_data_fields(node, table, data_model)
|
231
|
+
if table.is_a? Array
|
232
|
+
fields = data_model.mochigome_aggregation_settings.options[:fields]
|
233
|
+
# Pre-fill the node with all fields in the right order
|
234
|
+
fields.each{|agg| node[agg[:name]] = nil unless agg[:hidden] }
|
235
|
+
agg_row = {} # Hold regular aggs here to be used in ruby-based aggs
|
236
|
+
fields.reject{|agg| agg[:in_ruby]}.zip(table).each do |agg, v|
|
237
|
+
agg_row[agg[:name]] = v
|
238
|
+
node[agg[:name]] = v unless agg[:hidden]
|
239
|
+
end
|
240
|
+
fields.select{|agg| agg[:in_ruby]}.each do |agg|
|
241
|
+
node[agg[:name]] = agg[:ruby_proc].call(agg_row)
|
242
|
+
end
|
243
|
+
else
|
244
|
+
node.children.each do |c|
|
245
|
+
subtable = table[c[:id]] or next
|
246
|
+
insert_aggregate_data_fields(c, subtable, data_model)
|
247
|
+
end
|
248
|
+
end
|
249
|
+
end
|
250
|
+
|
251
|
+
# TODO: Move the stuff below into its own module
|
252
|
+
|
253
|
+
@@graphed_models = Set.new
|
254
|
+
@@assoc_graph = RGL::DirectedAdjacencyGraph.new
|
255
|
+
@@edge_relation_funcs = {}
|
256
|
+
@@shortest_paths = {}
|
257
|
+
|
258
|
+
def self.relation_over_path(path, rel = nil)
|
259
|
+
rel ||= Arel::Table.new(path.first.table_name)
|
260
|
+
(0..(path.size-2)).each do |i|
|
261
|
+
rel = relation_func(path[i], path[i+1]).call(rel)
|
186
262
|
end
|
187
|
-
|
263
|
+
rel
|
188
264
|
end
|
189
265
|
|
190
|
-
def self.
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
266
|
+
def self.relation_func(u, v)
|
267
|
+
@@edge_relation_funcs[[u,v]] or
|
268
|
+
raise QueryError.new "No assoc from #{u.name} to #{v.name}"
|
269
|
+
end
|
270
|
+
|
271
|
+
def self.path_thru(models)
|
272
|
+
update_assoc_graph(models)
|
273
|
+
path = [models.first]
|
274
|
+
(0..(models.size-2)).each do |i|
|
275
|
+
u = models[i]
|
276
|
+
v = models[i+1]
|
277
|
+
next if u == v
|
278
|
+
seg = @@shortest_paths[[u,v]]
|
279
|
+
raise QueryError.new("Can't travel from #{u.name} to #{v.name}") unless seg
|
280
|
+
seg.drop(1).each{|step| path << step}
|
281
|
+
end
|
282
|
+
unless path.uniq.size == path.size
|
283
|
+
raise QueryError.new(
|
284
|
+
"Path thru #{models.map(&:name).join('-')} doubles back: " +
|
285
|
+
path.map(&:name).join('-')
|
286
|
+
)
|
287
|
+
end
|
288
|
+
path
|
289
|
+
end
|
290
|
+
|
291
|
+
def self.update_assoc_graph(models)
|
292
|
+
model_queue = models.dup
|
293
|
+
added_models = []
|
294
|
+
until model_queue.empty?
|
295
|
+
model = model_queue.shift
|
296
|
+
next if @@graphed_models.include? model
|
297
|
+
@@graphed_models.add model
|
298
|
+
added_models << model
|
299
|
+
|
300
|
+
model.reflections.
|
301
|
+
reject{|name, assoc| assoc.through_reflection}.
|
302
|
+
each do |name, assoc|
|
303
|
+
# TODO: What about self associations?
|
304
|
+
# TODO: What about associations to the same model on different keys?
|
305
|
+
next if assoc.options[:polymorphic] # TODO How to deal with these? Check for matching has_X assoc?
|
306
|
+
foreign_model = assoc.klass
|
307
|
+
edge = [model, foreign_model]
|
308
|
+
next if @@assoc_graph.has_edge?(*edge) # Ignore duplicate assocs
|
309
|
+
@@assoc_graph.add_edge(*edge)
|
310
|
+
@@edge_relation_funcs[edge] = model.arelified_assoc(name)
|
311
|
+
unless @@graphed_models.include?(foreign_model)
|
312
|
+
model_queue.push(foreign_model)
|
313
|
+
end
|
314
|
+
end
|
315
|
+
end
|
316
|
+
|
317
|
+
added_models.each do |model|
|
318
|
+
next unless @@assoc_graph.has_vertex?(model)
|
319
|
+
path_tree = @@assoc_graph.bfs_search_tree_from(model).reverse
|
320
|
+
path_tree.depth_first_search do |tgt_model|
|
321
|
+
next if tgt_model == model
|
322
|
+
path = [tgt_model]
|
323
|
+
while (parent = path_tree.adjacent_vertices(path.first).first)
|
324
|
+
path.unshift parent
|
325
|
+
end
|
326
|
+
@@shortest_paths[[model,tgt_model]] = path
|
327
|
+
end
|
328
|
+
|
329
|
+
# Use through reflections as a hint for preferred indirect paths
|
330
|
+
model.reflections.
|
331
|
+
select{|name, assoc| assoc.through_reflection}.
|
332
|
+
each do |name, assoc|
|
333
|
+
begin
|
334
|
+
foreign_model = assoc.klass
|
335
|
+
join_model = assoc.through_reflection.klass
|
336
|
+
rescue NameError
|
337
|
+
# FIXME Can't handle polymorphic through reflection
|
338
|
+
end
|
339
|
+
edge = [model,foreign_model]
|
340
|
+
next if @@shortest_paths[edge].try(:size).try(:<, 3)
|
341
|
+
@@shortest_paths[edge] = [model, join_model, foreign_model]
|
342
|
+
end
|
195
343
|
end
|
196
|
-
return assoc
|
197
344
|
end
|
198
345
|
end
|
199
346
|
end
|
@@ -2,11 +2,20 @@ class Product < ActiveRecord::Base
|
|
2
2
|
acts_as_mochigome_focus do |f|
|
3
3
|
f.fields [:price]
|
4
4
|
end
|
5
|
-
has_mochigome_aggregations
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
5
|
+
has_mochigome_aggregations do |a|
|
6
|
+
a.fields [
|
7
|
+
:sum_price,
|
8
|
+
{"Expensive products" => [
|
9
|
+
:count,
|
10
|
+
Mochigome::null_unless(
|
11
|
+
lambda{|v| v.gt(10.00)},
|
12
|
+
lambda{|t| t[:price]}
|
13
|
+
)
|
14
|
+
]}
|
15
|
+
]
|
16
|
+
a.hidden_fields [ {"Secret count" => :count} ]
|
17
|
+
a.fields_in_ruby [ {"Count squared" => lambda{|row| row["Secret count"]**2}} ]
|
18
|
+
end
|
10
19
|
|
11
20
|
belongs_to :category
|
12
21
|
has_many :store_products
|
data/test/console.sh
ADDED
data/test/factories.rb
CHANGED
@@ -4,7 +4,6 @@ FactoryGirl.define do
|
|
4
4
|
factory :product do
|
5
5
|
name 'LG Optimus V'
|
6
6
|
price 19.95
|
7
|
-
categorized true
|
8
7
|
end
|
9
8
|
|
10
9
|
factory :category do
|
@@ -23,7 +22,7 @@ FactoryGirl.define do
|
|
23
22
|
phone_number "800-555-1212"
|
24
23
|
email_address { "#{first_name}.#{last_name}@example.com".downcase }
|
25
24
|
end
|
26
|
-
|
25
|
+
|
27
26
|
factory :store_product do
|
28
27
|
store
|
29
28
|
product
|