1# frozen_string_literal: true
2
3require_relative '../../migration_helpers'
4
5module RuboCop
6  module Cop
7    module Migration
8      # Cop that enforces always adding a limit on text columns
9      #
10      # Text columns starting with `encrypted_` are very likely used
11      # by `attr_encrypted` which controls the text length. Those columns
12      # should not add a text limit.
13      class AddLimitToTextColumns < RuboCop::Cop::Cop
14        include MigrationHelpers
15
16        TEXT_LIMIT_ATTRIBUTE_ALLOWED_SINCE = 2021_09_10_00_00_00
17
18        MSG = 'Text columns should always have a limit set (255 is suggested). ' \
19          'You can add a limit to a `text` column by using `add_text_limit` or by using `.text... limit: 255` inside `create_table`'
20
21        TEXT_LIMIT_ATTRIBUTE_NOT_ALLOWED = 'Text columns should always have a limit set (255 is suggested). Using limit: is not supported in this version. ' \
22        'You can add a limit to a `text` column by using `add_text_limit` or `.text_limit` inside `create_table`'
23
24        def_node_matcher :reverting?, <<~PATTERN
25          (def :down ...)
26        PATTERN
27
28        def_node_matcher :set_text_limit?, <<~PATTERN
29          (send _ :text_limit ...)
30        PATTERN
31
32        def_node_matcher :add_text_limit?, <<~PATTERN
33          (send _ :add_text_limit ...)
34        PATTERN
35
36        def on_def(node)
37          return unless in_migration?(node)
38
39          # Don't enforce the rule when on down to keep consistency with existing schema
40          return if reverting?(node)
41
42          node.each_descendant(:send) do |send_node|
43            next unless text_operation?(send_node)
44
45            if text_operation_with_limit?(send_node)
46              add_offense(send_node, location: :selector, message: TEXT_LIMIT_ATTRIBUTE_NOT_ALLOWED) if version(node) < TEXT_LIMIT_ATTRIBUTE_ALLOWED_SINCE
47            else
48              # We require a limit for the same table and attribute name
49              if text_limit_missing?(node, *table_and_attribute_name(send_node))
50                add_offense(send_node, location: :selector)
51              end
52            end
53          end
54        end
55
56        private
57
58        def text_operation_with_limit?(node)
59          migration_method = node.children[1]
60
61          return unless migration_method == :text
62
63          if attributes = node.children[3]
64            attributes.pairs.find { |pair| pair.key.value == :limit }.present?
65          end
66        end
67
68        def text_operation?(node)
69          # Don't complain about text arrays
70          return false if array_column?(node)
71
72          modifier = node.children[0]
73          migration_method = node.children[1]
74
75          if migration_method == :text
76            modifier.type == :lvar
77          elsif ADD_COLUMN_METHODS.include?(migration_method)
78            modifier.nil? && text_column?(node.children[4])
79          end
80        end
81
82        def text_column?(column_type)
83          column_type.type == :sym && column_type.value == :text
84        end
85
86        # For a given node, find the table and attribute this node is for
87        #
88        # Simple when we have calls to `add_column_XXX` helper methods
89        #
90        # A little bit more tricky when we have attributes defined as part of
91        # a create/change table block:
92        # - The attribute name is available on the node
93        # - Finding the table name requires to:
94        #   * go up
95        #   * find the first block the attribute def is part of
96        #   * go back down to find the create_table node
97        #   * fetch the table name from that node
98        def table_and_attribute_name(node)
99          migration_method = node.children[1]
100          table_name, attribute_name = ''
101
102          if migration_method == :text
103            # We are inside a node in a create/change table block
104            block_node = node.each_ancestor(:block).first
105            create_table_node = block_node
106                                  .children
107                                  .find { |n| TABLE_METHODS.include?(n.children[1])}
108
109            if create_table_node
110              table_name = create_table_node.children[2].value
111            else
112              # Guard against errors when a new table create/change migration
113              # helper is introduced and warn the author so that it can be
114              # added in TABLE_METHODS
115              table_name = 'unknown'
116              add_offense(block_node, message: 'Unknown table create/change helper')
117            end
118
119            attribute_name = node.children[2].value
120          else
121            # We are in a node for one of the ADD_COLUMN_METHODS
122            table_name = node.children[2].value
123            attribute_name = node.children[3].value
124          end
125
126          [table_name, attribute_name]
127        end
128
129        # Check if there is an `add_text_limit` call for the provided
130        # table and attribute name
131        def text_limit_missing?(node, table_name, attribute_name)
132          return false if encrypted_attribute_name?(attribute_name)
133
134          limit_found = false
135
136          node.each_descendant(:send) do |send_node|
137            if set_text_limit?(send_node)
138              limit_found = matching_set_text_limit?(send_node, attribute_name)
139            elsif add_text_limit?(send_node)
140              limit_found = matching_add_text_limit?(send_node, table_name, attribute_name)
141            end
142
143            break if limit_found
144          end
145
146          !limit_found
147        end
148
149        def matching_set_text_limit?(send_node, attribute_name)
150          limit_attribute = send_node.children[2].value
151
152          limit_attribute == attribute_name
153        end
154
155        def matching_add_text_limit?(send_node, table_name, attribute_name)
156          limit_table = send_node.children[2].value
157          limit_attribute = send_node.children[3].value
158
159          limit_table == table_name && limit_attribute == attribute_name
160        end
161
162        def encrypted_attribute_name?(attribute_name)
163          attribute_name.to_s.start_with?('encrypted_')
164        end
165      end
166    end
167  end
168end
169