1 | # frozen_string_literal: true |
||
2 | |||
3 | 1 | require 'pickup' |
|
4 | |||
5 | 1 | module NoSE |
|
6 | 1 | module Random |
|
7 | # A simple representation of a random ER diagram |
||
8 | 1 | class Network |
|
9 | 1 | attr_reader :entities |
|
10 | |||
11 | 1 | def initialize(params = {}) |
|
12 | 6 | @nodes_nb = params.fetch :nodes_nb, 10 |
|
13 | 6 | @field_count = RandomGaussian.new params.fetch(:num_fields, 3), 1 |
|
14 | 66 | @neighbours = Array.new(@nodes_nb) { Set.new } |
|
15 | end |
||
16 | |||
17 | # :nocov: |
||
18 | 1 | def inspect |
|
19 | @nodes.map do |node| |
||
20 | @entities[node].inspect |
||
21 | end.join "\n" |
||
22 | end |
||
23 | # :nocov: |
||
24 | |||
25 | 1 | protected |
|
26 | |||
27 | # Create a random entity to use in the model |
||
28 | # @return [Entity] |
||
29 | 1 | def create_entity(node) |
|
30 | 60 | num_entities = RandomGaussian.new 10_000, 100 |
|
31 | 60 | entity = Entity.new('E' + random_name(node)) * num_entities.rand |
|
0 ignored issues
–
show
introduced
by
![]() |
|||
32 | 60 | pick_fields entity |
|
33 | |||
34 | 60 | entity |
|
35 | end |
||
36 | |||
37 | # Probabilities of selecting various field types |
||
38 | FIELD_TYPES = { |
||
39 | 1 | Fields::IntegerField => 45, |
|
40 | Fields::StringField => 35, |
||
0 ignored issues
–
show
|
|||
41 | Fields::DateField => 10, |
||
0 ignored issues
–
show
|
|||
42 | Fields::FloatField => 10 |
||
0 ignored issues
–
show
|
|||
43 | }.freeze |
||
44 | |||
45 | # Select random fields for an entity |
||
46 | # @return [void] |
||
47 | 1 | def pick_fields(entity) |
|
48 | 60 | entity << Fields::IDField.new(entity.name + 'ID') |
|
0 ignored issues
–
show
|
|||
49 | 60 | 0.upto(@field_count.rand).each do |field_index| |
|
50 | 218 | entity << random_field(field_index) |
|
51 | end |
||
52 | end |
||
53 | |||
54 | # Generate a random field to add to an entity |
||
55 | # @return [Fields::Field] |
||
56 | 1 | def random_field(field_index) |
|
57 | 218 | Pickup.new(FIELD_TYPES).pick.send(:new, 'F' + random_name(field_index)) |
|
0 ignored issues
–
show
|
|||
58 | end |
||
59 | |||
60 | # Add foreign key relationships for neighbouring nodes |
||
61 | # @return [void] |
||
62 | 1 | def add_foreign_keys |
|
0 ignored issues
–
show
|
|||
63 | 6 | @neighbours.each_with_index do |other_nodes, node| |
|
64 | 60 | other_nodes.each do |other_node| |
|
65 | 102 | @neighbours[other_node].delete node |
|
66 | |||
67 | 102 | if rand > 0.5 |
|
68 | 48 | from_node = node |
|
69 | 48 | to_node = other_node |
|
70 | else |
||
71 | 54 | from_node = other_node |
|
72 | 54 | to_node = node |
|
73 | end |
||
74 | |||
75 | 102 | from_field = Fields::ForeignKeyField.new( |
|
76 | 'FK' + @entities[to_node].name + 'ID', |
||
0 ignored issues
–
show
|
|||
77 | @entities[to_node] |
||
78 | ) |
||
79 | 102 | to_field = Fields::ForeignKeyField.new( |
|
80 | 'FK' + @entities[from_node].name + 'ID', |
||
0 ignored issues
–
show
|
|||
81 | @entities[from_node] |
||
82 | ) |
||
83 | |||
84 | 102 | from_field.reverse = to_field |
|
85 | 102 | to_field.reverse = from_field |
|
86 | |||
87 | 102 | @entities[from_node] << from_field |
|
88 | 102 | @entities[to_node] << to_field |
|
89 | end |
||
90 | end |
||
91 | end |
||
92 | |||
93 | # Add a new link between two nodes |
||
94 | # @return [void] |
||
95 | 1 | def add_link(node, other_node) |
|
96 | 102 | @neighbours[node] << other_node |
|
97 | 102 | @neighbours[other_node] << node |
|
98 | end |
||
99 | |||
100 | # Remove a link between two nodes |
||
101 | # @return [void] |
||
102 | 1 | def remove_link(node, other_node) |
|
103 | @neighbours[node].delete other_node |
||
104 | @neighbours[other_node].delete node |
||
105 | end |
||
106 | |||
107 | # Find a new neighbour for a node |
||
108 | 1 | def new_neighbour(node, neighbour) |
|
109 | unlinkable_nodes = [node, neighbour] + @neighbours[node].to_a |
||
110 | (@nodes.to_a - unlinkable_nodes).sample |
||
111 | end |
||
112 | |||
113 | # Random names of variables combined to create random names |
||
114 | 1 | VARIABLE_NAMES = %w(Foo Bar Baz Quux Corge Grault |
|
0 ignored issues
–
show
|
|||
115 | Garply Waldo Fred Plugh).freeze |
||
116 | |||
117 | # Generate a random name for an attribute |
||
118 | # @return [String] |
||
119 | 1 | def random_name(index) |
|
120 | 556 | index.to_s.chars.map(&:to_i).map { |digit| VARIABLE_NAMES[digit] }.join |
|
121 | end |
||
122 | end |
||
123 | |||
124 | # Generates random queries over entities in a given model |
||
125 | 1 | class StatementGenerator |
|
126 | 1 | def initialize(model) |
|
127 | 5 | @model = model |
|
128 | end |
||
129 | |||
130 | # Generate a new random insertion to entities in the model |
||
131 | # @return [Insert] |
||
132 | 1 | def random_insert(connect = true) |
|
0 ignored issues
–
show
|
|||
133 | 1 | entity = @model.entities.values.sample |
|
134 | 1 | settings = entity.fields.each_value.map do |field| |
|
135 | 5 | "#{field.name}=?" |
|
136 | end.join ', ' |
||
137 | 1 | insert = "INSERT INTO #{entity.name} SET #{settings} " |
|
138 | |||
139 | # Optionally add connections to other entities |
||
140 | 1 | insert += random_connection(entity) if connect |
|
141 | |||
142 | 1 | Statement.parse insert, @model |
|
143 | end |
||
144 | |||
145 | # Generate a random connection for an Insert |
||
146 | 1 | def random_connection(entity) |
|
147 | 1 | connections = entity.foreign_keys.values.sample(2) |
|
148 | 1 | 'AND CONNECT TO ' + connections.map do |connection| |
|
0 ignored issues
–
show
|
|||
149 | 2 | "#{connection.name}(?)" |
|
150 | end.join(', ') |
||
151 | end |
||
152 | |||
153 | # Generate a new random update of entities in the model |
||
154 | # @return [Update] |
||
155 | 1 | def random_update(path_length = 1, updated_fields = 2, |
|
156 | condition_count = 1) |
||
157 | 1 | path = random_path(path_length) |
|
158 | 1 | settings = random_settings path, updated_fields |
|
159 | 1 | from = [path.first.parent.name] + path.entries[1..-1].map(&:name) |
|
0 ignored issues
–
show
|
|||
160 | 1 | update = "UPDATE #{from.first} FROM #{from.join '.'} " \ |
|
161 | "SET #{settings} " + |
||
162 | random_where_clause(path, condition_count) |
||
163 | |||
164 | 1 | Statement.parse update, @model |
|
165 | end |
||
166 | |||
167 | # Get random settings for an update |
||
168 | # @return [String] |
||
169 | 1 | def random_settings(path, updated_fields) |
|
170 | # Don't update key fields |
||
171 | 1 | update_fields = path.entities.first.fields.values |
|
172 | 5 | update_fields.reject! { |field| field.is_a? Fields::IDField } |
|
173 | |||
174 | 1 | update_fields.sample(updated_fields).map do |field| |
|
175 | 2 | "#{field.name}=?" |
|
176 | end.join ', ' |
||
177 | end |
||
178 | |||
179 | # Generate a new random deletion of entities in the model |
||
180 | # @return [Delete] |
||
181 | 1 | def random_delete |
|
182 | 1 | path = random_path(1) |
|
183 | |||
184 | 1 | from = [path.first.parent.name] + path.entries[1..-1].map(&:name) |
|
0 ignored issues
–
show
|
|||
185 | 1 | delete = "DELETE #{from.first} FROM #{from.join '.'} " + |
|
186 | random_where_clause(path, 1) |
||
187 | |||
188 | 1 | Statement.parse delete, @model |
|
189 | end |
||
190 | |||
191 | # Generate a new random query from entities in the model |
||
192 | # @return [Query] |
||
193 | 1 | def random_query(path_length = 3, selected_fields = 2, |
|
0 ignored issues
–
show
|
|||
194 | condition_count = 2, order = false) |
||
0 ignored issues
–
show
|
|||
195 | 1 | path = random_path path_length |
|
196 | 1 | graph = QueryGraph::Graph.from_path path |
|
197 | |||
198 | conditions = [ |
||
199 | 1 | Condition.new(path.entities.first.fields.values.sample, :'=', nil) |
|
200 | ] |
||
201 | 1 | condition_count -= 1 |
|
202 | |||
203 | 1 | new_fields = random_where_conditions(path, condition_count, |
|
204 | conditions.map(&:field).to_set) |
||
205 | 1 | conditions += new_fields.map do |field| |
|
206 | 1 | Condition.new(field, :'>', nil) |
|
207 | end |
||
208 | |||
209 | 1 | conditions = Hash[conditions.map do |condition| |
|
210 | 2 | [condition.field.id, condition] |
|
211 | end] |
||
212 | |||
213 | params = { |
||
214 | 1 | select: random_select(path, selected_fields), |
|
215 | model: @model, |
||
216 | graph: graph, |
||
217 | key_path: graph.longest_path, |
||
218 | entity: graph.longest_path.first.parent, |
||
219 | conditions: conditions, |
||
220 | 1 | order: order ? [graph.entities.to_a.sample.fields.values.sample] : [] |
|
221 | } |
||
222 | |||
223 | 1 | query = Query.new params, nil |
|
224 | 1 | query.hash |
|
225 | |||
226 | 1 | query |
|
227 | end |
||
228 | |||
229 | # Get random fields to select for a Query |
||
230 | # @return [Set<Fields::Field>] |
||
231 | 1 | def random_select(path, selected_fields) |
|
232 | 1 | fields = Set.new |
|
233 | 1 | while fields.length < selected_fields |
|
0 ignored issues
–
show
|
|||
234 | 2 | fields.add path.entities.sample.fields.values.sample |
|
235 | end |
||
236 | |||
237 | 1 | fields |
|
238 | end |
||
239 | |||
240 | # Produce a random statement according to a given set of weights |
||
241 | # @return [Statement] |
||
242 | 1 | def random_statement(weights = { query: 80, insert: 10, update: 5, |
|
243 | delete: 5 }) |
||
244 | pick = Pickup.new(weights) |
||
245 | type = pick.pick |
||
246 | send(('random_' + type.to_s).to_sym) |
||
0 ignored issues
–
show
|
|||
247 | end |
||
248 | |||
249 | # Return a random path through the entity graph |
||
250 | # @return [KeyPath] |
||
251 | 1 | def random_path(max_length) |
|
0 ignored issues
–
show
|
|||
252 | # Pick the start of path weighted based on |
||
253 | # the number of deges from each entity |
||
254 | 3 | pick = Pickup.new(Hash[@model.entities.each_value.map do |entity| |
|
255 | 9 | [entity, entity.foreign_keys.length] |
|
256 | end]) |
||
257 | 3 | path = [pick.pick.id_field] |
|
258 | |||
259 | 3 | while path.length < max_length |
|
260 | # Find a list of keys to entities we have not seen before |
||
261 | 2 | last_entity = path.last.entity |
|
262 | 2 | keys = last_entity.foreign_keys.values |
|
263 | 7 | keys.reject! { |key| path.map(&:entity).include? key.entity } |
|
264 | 2 | break if keys.empty? |
|
265 | |||
266 | # Add a random new key to the path |
||
267 | 1 | path << keys.sample |
|
268 | end |
||
269 | |||
270 | 3 | KeyPath.new path |
|
271 | end |
||
272 | |||
273 | # Produce a random query graph over the entity graph |
||
274 | 1 | def random_graph(max_nodes) |
|
0 ignored issues
–
show
|
|||
275 | 1 | graph = QueryGraph::Graph.new |
|
276 | 1 | last_node = graph.add_node @model.entities.values.sample |
|
277 | 1 | while graph.size < max_nodes |
|
278 | # Get the possible foreign keys to use |
||
279 | 2 | keys = last_node.entity.foreign_keys.values |
|
280 | 7 | keys.reject! { |key| graph.nodes.map(&:entity).include? key.entity } |
|
281 | 2 | break if keys.empty? |
|
282 | |||
283 | # Pick a random foreign key to traverse |
||
284 | 2 | next_key = keys.sample |
|
285 | 2 | graph.add_edge last_node, next_key.entity, next_key |
|
286 | |||
287 | # Select a new node to start from, making sure we pick one |
||
288 | # that still has valid outgoing edges |
||
289 | 2 | last_node = graph.nodes.reject do |node| |
|
290 | 5 | (node.entity.foreign_keys.each_value.map(&:entity) - |
|
291 | graph.nodes.map(&:entity)).empty? |
||
292 | end.sample |
||
293 | 2 | break if last_node.nil? |
|
294 | end |
||
295 | |||
296 | 1 | graph |
|
297 | end |
||
298 | |||
299 | 1 | private |
|
300 | |||
301 | # Produce a random where clause using fields along a given path |
||
302 | # @return [String] |
||
303 | 1 | def random_where_clause(path, count = 2) |
|
304 | # Ensure we have at least one condition at the beginning of the path |
||
305 | 2 | conditions = [path.entities.first.fields.values.sample] |
|
306 | 2 | conditions += random_where_conditions path, count - 1 |
|
307 | |||
308 | 2 | return '' if conditions.empty? |
|
0 ignored issues
–
show
|
|||
309 | 2 | "WHERE #{conditions.map do |field| |
|
310 | 2 | "#{path.find_field_parent(field).name}.#{field.name} = ?" |
|
311 | end.join ' AND '}" |
||
312 | end |
||
313 | |||
314 | # Produce a random set of fields for a where clause |
||
315 | # @return [Array<Fields::Field>] |
||
316 | 1 | def random_where_conditions(path, count, exclude = Set.new) |
|
317 | 3 | 1.upto(count).map do |
|
318 | 1 | field = path.entities.sample.fields.values.sample |
|
319 | 1 | next nil if field.name == '**' || exclude.include?(field) |
|
320 | |||
321 | 1 | field |
|
322 | end.compact |
||
323 | end |
||
324 | |||
325 | # Get the name to be used in the query for a condition field |
||
326 | # @return [String] |
||
327 | 1 | def condition_field_name(field, path) |
|
328 | field_path = path.first.name |
||
329 | path_end = path.index(field.parent) |
||
330 | last_entity = path.first |
||
331 | path[1..path_end].each do |entity| |
||
332 | fk = last_entity.foreign_keys.each_value.find do |key| |
||
333 | key.entity == entity |
||
334 | end |
||
335 | field_path += '.' << fk.name |
||
336 | last_entity = entity |
||
337 | end |
||
338 | |||
339 | field_path |
||
340 | end |
||
341 | end |
||
342 | end |
||
343 | |||
344 | # Generate random numbers according to a Guassian distribution |
||
345 | 1 | class RandomGaussian |
|
346 | 1 | def initialize(mean, stddev, integer = true, min = 1) |
|
0 ignored issues
–
show
|
|||
347 | 66 | @mean = mean |
|
348 | 66 | @stddev = stddev |
|
349 | 66 | @valid = false |
|
350 | 66 | @next = 0 |
|
351 | 66 | @integer = integer |
|
352 | 66 | @min = min |
|
353 | end |
||
354 | |||
355 | # Return the next valid random number |
||
356 | # @return [Integer] |
||
357 | 1 | def rand |
|
358 | 120 | if @valid |
|
359 | 30 | @valid = false |
|
360 | 30 | clamp @next |
|
361 | else |
||
362 | 90 | @valid = true |
|
363 | 90 | x, y = self.class.gaussian(@mean, @stddev) |
|
364 | 90 | @next = y |
|
365 | 90 | clamp x |
|
366 | end |
||
367 | end |
||
368 | |||
369 | # Return a random number for the given distribution |
||
370 | # @return [Array<Integer>] |
||
371 | 1 | def self.gaussian(mean, stddev) |
|
372 | 90 | theta = 2 * Math::PI * rand |
|
373 | 90 | rho = Math.sqrt(-2 * Math.log(1 - rand)) |
|
374 | 90 | scale = stddev * rho |
|
375 | 90 | x = mean + scale * Math.cos(theta) |
|
376 | 90 | y = mean + scale * Math.sin(theta) |
|
377 | 90 | [x, y] |
|
378 | end |
||
379 | |||
380 | 1 | private |
|
381 | |||
382 | # Clamp the value to the given minimum |
||
383 | 1 | def clamp(value) |
|
384 | 120 | value = value.to_i if @integer |
|
385 | 120 | [@min, value].max unless @min.nil? |
|
386 | end |
||
387 | end |
||
388 | end |
||
389 | |||
390 | 1 | require_relative 'random/barbasi_albert' |
|
391 | require_relative 'random/watts_strogatz' |
||
392 |