Module: Scryer::QueryExtractor

Defined in:
lib/scryer/query_extractor.rb

Overview

Extracts each Active Record query chain rooted at a bare Model constant (User.where(...).order(...), Order.joins(:items).count, ...) as a MethodInfo — same shape MethodExtractor produces for defs, so it can feed the same DuplicateDetector — to catch copy-pasted query logic that never got extracted into a scope/service, even when the surrounding methods themselves don't look alike.

A "chain" is captured at its outermost call (the last method in the chain), and the walk does not recurse into a chain it just captured — so User.where(...).order(...) is one unit, not also a nested where unit. This means a subquery buried inside a captured chain's arguments (e.g. Foo.where(id: Bar.select(:id))) isn't separately extracted — an accepted simplification, not a correctness bug: it would still be found if the same subquery shape appears somewhere else outside a captured chain.

Constant Summary collapse

QUERY_METHODS =

Mirrors the query-method lists in NPlusOneQueryRule/UnboundedTableScanRule but broader, since here we're looking for "this is recognizably a query" rather than one specific anti-pattern.

%w[
  where find_by find_by! find_or_create_by find_or_initialize_by
  order reorder limit offset joins left_joins includes preload eager_load
  references group having select distinct pluck exists? count sum average
  minimum maximum find first last not or none unscope ids find_each
  find_in_batches in_batches lock readonly
].freeze
MIN_TOKENS =

shorter than MethodExtractor's threshold — queries are terser than whole methods

8

Class Method Summary collapse

Class Method Details

.build_info(node, file:, source:) ⇒ Object



78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
# File 'lib/scryer/query_extractor.rb', line 78

def build_info(node, file:, source:)
  start_line, end_line = Ast.line_range_of(node)
  return nil unless start_line

  tokens = Ast.normalized_tokens(node)
  return nil if tokens.size < MIN_TOKENS

  const_name = Ast.ident_text(root_of(node)[1]) || "?"

  MethodInfo.new(
    name: "#{const_name}.#{chain_label(node)}",
    file: file,
    start_line: start_line,
    end_line: end_line,
    token_stream: tokens,
    source_snippet: Ast.source_text(source, node)
  )
end

.chain_label(node) ⇒ Object

Best-effort ".method1.method2" label (outermost-first) for display — not used for comparison, just so a finding reads as "User.where.order" instead of a bare line number. Each iteration unwraps one call link (method_add_arg -> its :call, or a bare :call) and steps the cursor to that call's receiver, so a chain link is never counted twice.



103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/scryer/query_extractor.rb', line 103

def chain_label(node)
  names = []
  cursor = node

  loop do
    break unless cursor.is_a?(Array)

    inner = Ast.tagged?(cursor, :method_add_arg) ? cursor[1] : cursor
    break unless Ast.tagged?(inner, :call, :vcall, :fcall, :command)

    name = Ast.call_name(inner)&.last
    names.unshift(name) if name
    break unless Ast.tagged?(inner, :call) # vcall/fcall/command have no receiver to descend into

    cursor = inner[1]
  end

  names.join(".")
end

.chain_root?(node) ⇒ Boolean

Returns:

  • (Boolean)


55
56
57
58
59
60
61
62
63
# File 'lib/scryer/query_extractor.rb', line 55

def chain_root?(node)
  inner = Ast.tagged?(node, :method_add_arg) ? node[1] : node
  name = Ast.call_name(inner)&.last
  return false unless name && QUERY_METHODS.include?(name)

  root = root_of(node)
  Ast.tagged?(root, :var_ref, :vcall) &&
    root[1].is_a?(Array) && root[1][0] == :@const
end

.extract(file:, source:, sexp:) ⇒ Object



35
36
37
38
39
40
41
42
# File 'lib/scryer/query_extractor.rb', line 35

def extract(file:, source:, sexp:)
  queries = []
  walk(sexp) do |node|
    info = build_info(node, file: file, source: source)
    queries << info if info
  end
  queries
end

.root_of(node) ⇒ Object

Walks down the leftmost receiver chain to the constant the whole chain is called on (same logic as NPlusOneQueryRule#root_of).



67
68
69
70
71
72
73
74
75
76
# File 'lib/scryer/query_extractor.rb', line 67

def root_of(node)
  return node unless node.is_a?(Array)

  case node[0]
  when :method_add_arg, :call, :command_call
    root_of(node[1])
  else
    node
  end
end

.walk(node, &block) ⇒ Object



44
45
46
47
48
49
50
51
52
53
# File 'lib/scryer/query_extractor.rb', line 44

def walk(node, &block)
  return unless node.is_a?(Array)

  if chain_root?(node)
    block.call(node)
    return # don't descend into a chain we just captured — see module doc
  end

  node.each { |child| walk(child, &block) }
end