mochigome 0.0.3 → 0.0.4
Sign up to get free protection for your applications and to get access to all the features.
- 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
|