Completed
Push — mongo-graph ( 87a116...a5e079 )
by Michael
03:26
created

Network.add_link()   A

Complexity

Conditions 1

Size

Total Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 1
CRAP Score 1.2963

Importance

Changes 0
Metric Value
cc 1
dl 0
loc 4
ccs 1
cts 3
cp 0.3333
crap 1.2963
rs 10
c 0
b 0
f 0
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
0 ignored issues
show
Coding Style introduced by
The Assignment, Branch, Condition size for add_foreign_keys is considered too high. [23.43/20]. The ABC size is based on assignments, branches (method calls), and conditions.
Loading history...
Coding Style introduced by
This method is 24 lines long. Your coding style permits a maximum length of 20.
Loading history...
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,
0 ignored issues
show
Coding Style introduced by
The Assignment, Branch, Condition size for random_query is considered too high. [31.97/20]. The ABC size is based on assignments, branches (method calls), and conditions.
Loading history...
Coding Style introduced by
This method is 27 lines long. Your coding style permits a maximum length of 20.
Loading history...
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)
0 ignored issues
show
Coding Style introduced by
The Assignment, Branch, Condition size for random_path is considered too high. [23.43/20]. The ABC size is based on assignments, branches (method calls), and conditions.
Loading history...
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)
0 ignored issues
show
Coding Style introduced by
The Assignment, Branch, Condition size for random_graph is considered too high. [31.54/20]. The ABC size is based on assignments, branches (method calls), and conditions.
Loading history...
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