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
|
|
|
@nodes_nb = params.fetch :nodes_nb, 10 |
13
|
|
|
@field_count = RandomGaussian.new params.fetch(:num_fields, 3), 1 |
14
|
|
|
@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
|
|
|
num_entities = RandomGaussian.new 10_000, 100 |
31
|
|
|
entity = Entity.new('E' + random_name(node)) * num_entities.rand |
32
|
|
|
pick_fields entity |
33
|
|
|
|
34
|
|
|
entity |
35
|
|
|
end |
36
|
|
|
|
37
|
|
|
# Probabilities of selecting various field types |
38
|
1 |
|
FIELD_TYPES = { |
39
|
|
|
Fields::IntegerField => 45, |
40
|
|
|
Fields::StringField => 35, |
41
|
|
|
Fields::DateField => 10, |
42
|
|
|
Fields::FloatField => 10 |
43
|
|
|
}.freeze |
44
|
|
|
|
45
|
|
|
# Select random fields for an entity |
46
|
|
|
# @return [void] |
47
|
1 |
|
def pick_fields(entity) |
48
|
|
|
entity << Fields::IDField.new(entity.name + 'ID') |
49
|
|
|
0.upto(@field_count.rand).each do |field_index| |
50
|
|
|
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
|
|
|
Pickup.new(FIELD_TYPES).pick.send(:new, 'F' + random_name(field_index)) |
58
|
|
|
end |
59
|
|
|
|
60
|
|
|
# Add foreign key relationships for neighbouring nodes |
61
|
|
|
# @return [void] |
62
|
1 |
|
def add_foreign_keys |
|
|
|
|
63
|
|
|
@neighbours.each_with_index do |other_nodes, node| |
64
|
|
|
other_nodes.each do |other_node| |
65
|
|
|
@neighbours[other_node].delete node |
66
|
|
|
|
67
|
|
|
if rand > 0.5 |
68
|
|
|
from_node = node |
69
|
|
|
to_node = other_node |
70
|
|
|
else |
71
|
|
|
from_node = other_node |
72
|
|
|
to_node = node |
73
|
|
|
end |
74
|
|
|
|
75
|
|
|
from_field = Fields::ForeignKeyField.new( |
76
|
|
|
'FK' + @entities[to_node].name + 'ID', |
77
|
|
|
@entities[to_node] |
78
|
|
|
) |
79
|
|
|
to_field = Fields::ForeignKeyField.new( |
80
|
|
|
'FK' + @entities[from_node].name + 'ID', |
81
|
|
|
@entities[from_node] |
82
|
|
|
) |
83
|
|
|
|
84
|
|
|
from_field.reverse = to_field |
85
|
|
|
to_field.reverse = from_field |
86
|
|
|
|
87
|
|
|
@entities[from_node] << from_field |
88
|
|
|
@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
|
|
|
@neighbours[node] << other_node |
97
|
|
|
@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 |
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
|
|
|
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
|
|
|
@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) |
133
|
|
|
entity = @model.entities.values.sample |
134
|
|
|
settings = entity.fields.each_value.map do |field| |
135
|
|
|
"#{field.name}=?" |
136
|
|
|
end.join ', ' |
137
|
|
|
insert = "INSERT INTO #{entity.name} SET #{settings} " |
138
|
|
|
|
139
|
|
|
# Optionally add connections to other entities |
140
|
|
|
insert << random_connection(entity) if connect |
141
|
|
|
|
142
|
|
|
Statement.parse insert, @model |
143
|
|
|
end |
144
|
|
|
|
145
|
|
|
# Generate a random connection for an Insert |
146
|
1 |
|
def random_connection(entity) |
147
|
|
|
connections = entity.foreign_keys.values.sample(2) |
148
|
|
|
'AND CONNECT TO ' + connections.map do |connection| |
149
|
|
|
"#{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
|
|
|
path = random_path(path_length) |
158
|
|
|
settings = random_settings path, updated_fields |
159
|
|
|
from = [path.first.parent.name] + path.entries[1..-1].map(&:name) |
160
|
|
|
update = "UPDATE #{from.first} FROM #{from.join '.'} " \ |
161
|
|
|
"SET #{settings} " + |
162
|
|
|
random_where_clause(path, condition_count) |
163
|
|
|
|
164
|
|
|
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
|
|
|
update_fields = path.entities.first.fields.values |
172
|
|
|
update_fields.reject! { |field| field.is_a? Fields::IDField } |
173
|
|
|
|
174
|
|
|
update_fields.sample(updated_fields).map do |field| |
175
|
|
|
"#{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
|
|
|
path = random_path(1) |
183
|
|
|
|
184
|
|
|
from = [path.first.parent.name] + path.entries[1..-1].map(&:name) |
185
|
|
|
delete = "DELETE #{from.first} FROM #{from.join '.'} " + |
186
|
|
|
random_where_clause(path, 1) |
187
|
|
|
|
188
|
|
|
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, |
|
|
|
|
194
|
|
|
condition_count = 2, order = false) |
195
|
|
|
path = random_path path_length |
196
|
|
|
graph = QueryGraph::Graph.from_path path |
197
|
|
|
|
198
|
|
|
conditions = [ |
199
|
|
|
Condition.new(path.entities.first.fields.values.sample, :'=', nil) |
200
|
|
|
] |
201
|
|
|
condition_count -= 1 |
202
|
|
|
|
203
|
|
|
new_fields = random_where_conditions(path, condition_count, |
204
|
|
|
conditions.map(&:field).to_set) |
205
|
|
|
conditions += new_fields.map do |field| |
206
|
|
|
Condition.new(field, :'>', nil) |
207
|
|
|
end |
208
|
|
|
|
209
|
|
|
conditions = Hash[conditions.map do |condition| |
210
|
|
|
[condition.field.id, condition] |
211
|
|
|
end] |
212
|
|
|
|
213
|
|
|
params = { |
214
|
|
|
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
|
|
|
order: order ? [graph.entities.to_a.sample.fields.values.sample] : [] |
221
|
|
|
} |
222
|
|
|
|
223
|
|
|
query = Query.new params, nil |
224
|
|
|
query.hash |
225
|
|
|
|
226
|
|
|
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
|
|
|
fields = Set.new |
233
|
|
|
while fields.length < selected_fields |
234
|
|
|
fields.add path.entities.sample.fields.values.sample |
235
|
|
|
end |
236
|
|
|
|
237
|
|
|
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) |
247
|
|
|
end |
248
|
|
|
|
249
|
|
|
# Return a random path through the entity graph |
250
|
|
|
# @return [KeyPath] |
251
|
1 |
|
def random_path(max_length) |
|
|
|
|
252
|
|
|
# Pick the start of path weighted based on |
253
|
|
|
# the number of deges from each entity |
254
|
|
|
pick = Pickup.new(Hash[@model.entities.each_value.map do |entity| |
255
|
|
|
[entity, entity.foreign_keys.length] |
256
|
|
|
end]) |
257
|
|
|
path = [pick.pick.id_field] |
258
|
|
|
|
259
|
|
|
while path.length < max_length |
260
|
|
|
# Find a list of keys to entities we have not seen before |
261
|
|
|
last_entity = path.last.entity |
262
|
|
|
keys = last_entity.foreign_keys.values |
263
|
|
|
keys.reject! { |key| path.map(&:entity).include? key.entity } |
264
|
|
|
break if keys.empty? |
265
|
|
|
|
266
|
|
|
# Add a random new key to the path |
267
|
|
|
path << keys.sample |
268
|
|
|
end |
269
|
|
|
|
270
|
|
|
KeyPath.new path |
271
|
|
|
end |
272
|
|
|
|
273
|
|
|
# Produce a random query graph over the entity graph |
274
|
1 |
|
def random_graph(max_nodes) |
|
|
|
|
275
|
|
|
graph = QueryGraph::Graph.new |
276
|
|
|
last_node = graph.add_node @model.entities.values.sample |
277
|
|
|
while graph.size < max_nodes |
278
|
|
|
# Get the possible foreign keys to use |
279
|
|
|
keys = last_node.entity.foreign_keys.values |
280
|
|
|
keys.reject! { |key| graph.nodes.map(&:entity).include? key.entity } |
281
|
|
|
break if keys.empty? |
282
|
|
|
|
283
|
|
|
# Pick a random foreign key to traverse |
284
|
|
|
next_key = keys.sample |
285
|
|
|
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
|
|
|
last_node = graph.nodes.reject do |node| |
290
|
|
|
(node.entity.foreign_keys.each_value.map(&:entity) - |
291
|
|
|
graph.nodes.map(&:entity)).empty? |
292
|
|
|
end.sample |
293
|
|
|
break if last_node.nil? |
294
|
|
|
end |
295
|
|
|
|
296
|
|
|
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
|
|
|
conditions = [path.entities.first.fields.values.sample] |
306
|
|
|
conditions += random_where_conditions path, count - 1 |
307
|
|
|
|
308
|
|
|
return '' if conditions.empty? |
309
|
|
|
"WHERE #{conditions.map do |field| |
310
|
|
|
"#{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
|
|
|
1.upto(count).map do |
318
|
|
|
field = path.entities.sample.fields.values.sample |
319
|
|
|
next nil if field.name == '**' || exclude.include?(field) |
320
|
|
|
|
321
|
|
|
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.values.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) |
347
|
|
|
@mean = mean |
348
|
|
|
@stddev = stddev |
349
|
|
|
@valid = false |
350
|
|
|
@next = 0 |
351
|
|
|
@integer = integer |
352
|
|
|
@min = min |
353
|
|
|
end |
354
|
|
|
|
355
|
|
|
# Return the next valid random number |
356
|
|
|
# @return [Fixnum] |
357
|
1 |
|
def rand |
358
|
|
|
if @valid |
359
|
|
|
@valid = false |
360
|
|
|
clamp @next |
361
|
|
|
else |
362
|
|
|
@valid = true |
363
|
|
|
x, y = self.class.gaussian(@mean, @stddev) |
364
|
|
|
@next = y |
365
|
|
|
clamp x |
366
|
|
|
end |
367
|
|
|
end |
368
|
|
|
|
369
|
|
|
# Return a random number for the given distribution |
370
|
|
|
# @return [Array<Fixnum>] |
371
|
1 |
|
def self.gaussian(mean, stddev) |
372
|
|
|
theta = 2 * Math::PI * rand |
373
|
|
|
rho = Math.sqrt(-2 * Math.log(1 - rand)) |
374
|
|
|
scale = stddev * rho |
375
|
|
|
x = mean + scale * Math.cos(theta) |
376
|
|
|
y = mean + scale * Math.sin(theta) |
377
|
|
|
[x, y] |
378
|
|
|
end |
379
|
|
|
|
380
|
1 |
|
private |
381
|
|
|
|
382
|
|
|
# Clamp the value to the given minimum |
383
|
1 |
|
def clamp(value) |
384
|
|
|
value = value.to_i if @integer |
385
|
|
|
[@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
|
|
|
|