summaryrefslogtreecommitdiff
path: root/rubocop/cop/migration/add_limit_to_text_columns.rb
blob: b5780e87c19d5bfb9d14a924c894f61157f3112e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
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
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
# frozen_string_literal: true

require_relative '../../migration_helpers'

module RuboCop
  module Cop
    module Migration
      # Cop that enforces always adding a limit on text columns
      #
      # Text columns starting with `encrypted_` are very likely used
      # by `attr_encrypted` which controls the text length. Those columns
      # should not add a text limit.
      class AddLimitToTextColumns < RuboCop::Cop::Cop
        include MigrationHelpers

        TEXT_LIMIT_ATTRIBUTE_ALLOWED_SINCE = 2021_09_10_00_00_00

        MSG = 'Text columns should always have a limit set (255 is suggested). ' \
          'You can add a limit to a `text` column by using `add_text_limit` or by using `.text... limit: 255` inside `create_table`'

        TEXT_LIMIT_ATTRIBUTE_NOT_ALLOWED = 'Text columns should always have a limit set (255 is suggested). Using limit: is not supported in this version. ' \
        'You can add a limit to a `text` column by using `add_text_limit` or `.text_limit` inside `create_table`'

        def_node_matcher :reverting?, <<~PATTERN
          (def :down ...)
        PATTERN

        def_node_matcher :set_text_limit?, <<~PATTERN
          (send _ :text_limit ...)
        PATTERN

        def_node_matcher :add_text_limit?, <<~PATTERN
          (send _ :add_text_limit ...)
        PATTERN

        def on_def(node)
          return unless in_migration?(node)

          # Don't enforce the rule when on down to keep consistency with existing schema
          return if reverting?(node)

          node.each_descendant(:send) do |send_node|
            next unless text_operation?(send_node)

            if text_operation_with_limit?(send_node)
              add_offense(send_node, location: :selector, message: TEXT_LIMIT_ATTRIBUTE_NOT_ALLOWED) if version(node) < TEXT_LIMIT_ATTRIBUTE_ALLOWED_SINCE
            else
              # We require a limit for the same table and attribute name
              if text_limit_missing?(node, *table_and_attribute_name(send_node))
                add_offense(send_node, location: :selector)
              end
            end
          end
        end

        private

        def text_operation_with_limit?(node)
          migration_method = node.children[1]

          return unless migration_method == :text

          if attributes = node.children[3]
            attributes.pairs.find { |pair| pair.key.value == :limit }.present?
          end
        end

        def text_operation?(node)
          # Don't complain about text arrays
          return false if array_column?(node)

          modifier = node.children[0]
          migration_method = node.children[1]

          if migration_method == :text
            modifier.type == :lvar
          elsif ADD_COLUMN_METHODS.include?(migration_method)
            modifier.nil? && text_column?(node.children[4])
          end
        end

        def text_column?(column_type)
          column_type.type == :sym && column_type.value == :text
        end

        # For a given node, find the table and attribute this node is for
        #
        # Simple when we have calls to `add_column_XXX` helper methods
        #
        # A little bit more tricky when we have attributes defined as part of
        # a create/change table block:
        # - The attribute name is available on the node
        # - Finding the table name requires to:
        #   * go up
        #   * find the first block the attribute def is part of
        #   * go back down to find the create_table node
        #   * fetch the table name from that node
        def table_and_attribute_name(node)
          migration_method = node.children[1]
          table_name, attribute_name = ''

          if migration_method == :text
            # We are inside a node in a create/change table block
            block_node = node.each_ancestor(:block).first
            create_table_node = block_node
                                  .children
                                  .find { |n| TABLE_METHODS.include?(n.children[1])}

            if create_table_node
              table_name = create_table_node.children[2].value
            else
              # Guard against errors when a new table create/change migration
              # helper is introduced and warn the author so that it can be
              # added in TABLE_METHODS
              table_name = 'unknown'
              add_offense(block_node, message: 'Unknown table create/change helper')
            end

            attribute_name = node.children[2].value
          else
            # We are in a node for one of the ADD_COLUMN_METHODS
            table_name = node.children[2].value
            attribute_name = node.children[3].value
          end

          [table_name, attribute_name]
        end

        # Check if there is an `add_text_limit` call for the provided
        # table and attribute name
        def text_limit_missing?(node, table_name, attribute_name)
          return false if encrypted_attribute_name?(attribute_name)

          limit_found = false

          node.each_descendant(:send) do |send_node|
            if set_text_limit?(send_node)
              limit_found = matching_set_text_limit?(send_node, attribute_name)
            elsif add_text_limit?(send_node)
              limit_found = matching_add_text_limit?(send_node, table_name, attribute_name)
            end

            break if limit_found
          end

          !limit_found
        end

        def matching_set_text_limit?(send_node, attribute_name)
          limit_attribute = send_node.children[2].value

          limit_attribute == attribute_name
        end

        def matching_add_text_limit?(send_node, table_name, attribute_name)
          limit_table = send_node.children[2].value
          limit_attribute = send_node.children[3].value

          limit_table == table_name && limit_attribute == attribute_name
        end

        def encrypted_attribute_name?(attribute_name)
          attribute_name.to_s.start_with?('encrypted_')
        end
      end
    end
  end
end