Class: PgQuery

Inherits:
Object
  • Object
show all
Defined in:
lib/pg_query/parse.rb,
lib/pg_query/deparse.rb,
lib/pg_query/version.rb,
lib/pg_query/truncate.rb,
lib/pg_query/param_refs.rb,
lib/pg_query/treewalker.rb,
lib/pg_query/fingerprint.rb,
lib/pg_query/parse_error.rb,
lib/pg_query/filter_columns.rb

Defined Under Namespace

Classes: ParseError, PossibleTruncation

Constant Summary collapse

VERSION =
'0.6.1'

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(query, parsetree, warnings = []) ⇒ PgQuery

Returns a new instance of PgQuery.



26
27
28
29
30
# File 'lib/pg_query/parse.rb', line 26

def initialize(query, parsetree, warnings = [])
  @query = query
  @parsetree = parsetree
  @warnings = warnings
end

Instance Attribute Details

#parsetreeObject (readonly)

Returns the value of attribute parsetree.



23
24
25
# File 'lib/pg_query/parse.rb', line 23

def parsetree
  @parsetree
end

#queryObject (readonly)

Returns the value of attribute query.



22
23
24
# File 'lib/pg_query/parse.rb', line 22

def query
  @query
end

#warningsObject (readonly)

Returns the value of attribute warnings.



24
25
26
# File 'lib/pg_query/parse.rb', line 24

def warnings
  @warnings
end

Class Method Details

._raw_parseObject

.deparse(item) ⇒ Object

Given one element of the PgQuery#parsetree reconstruct it back into the original query.



12
13
14
# File 'lib/pg_query/deparse.rb', line 12

def deparse(item)
  deparse_item(item)
end

.normalizeObject

.parse(query) ⇒ Object



4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# File 'lib/pg_query/parse.rb', line 4

def self.parse(query)
  parsetree, stderr = _raw_parse(query)

  begin
    parsetree = JSON.parse(parsetree, max_nesting: 1000)
  rescue JSON::ParserError
    raise ParseError.new('Failed to parse JSON', -1)
  end

  warnings = []
  stderr.each_line do |line|
    next unless line[/^WARNING/]
    warnings << line.strip
  end

  PgQuery.new(query, parsetree, warnings)
end

Instance Method Details

#aliasesObject



37
38
39
40
# File 'lib/pg_query/parse.rb', line 37

def aliases
  load_tables_and_aliases! if @aliases.nil?
  @aliases
end

#deparse(tree = @parsetree) ⇒ Object

Reconstruct all of the parsed queries into their original form



3
4
5
6
7
# File 'lib/pg_query/deparse.rb', line 3

def deparse(tree = @parsetree)
  tree.map do |item|
    self.class.deparse(item)
  end.join('; ')
end

#filter_columnsObject

Returns a list of columns that the query filters by - this excludes the target list, but includes things like JOIN condition and WHERE clause.

Note: This also traverses into sub-selects.



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
# File 'lib/pg_query/filter_columns.rb', line 6

def filter_columns # rubocop:disable Metrics/CyclomaticComplexity
  load_tables_and_aliases! if @aliases.nil?

  # Get condition items from the parsetree
  statements = @parsetree.dup
  condition_items = []
  filter_columns = []
  loop do
    statement = statements.shift
    if statement
      if statement['SELECT']
        if statement['SELECT']['op'] == 0
          if statement['SELECT']['fromClause']
            # FROM subselects
            statement['SELECT']['fromClause'].each do |item|
              next unless item['RANGESUBSELECT']
              statements << item['RANGESUBSELECT']['subquery']
            end

            # JOIN ON conditions
            condition_items += conditions_from_join_clauses(statement['SELECT']['fromClause'])
          end

          # WHERE clause
          condition_items << statement['SELECT']['whereClause'] if statement['SELECT']['whereClause']

          # CTEs
          if statement['SELECT']['withClause']
            statement['SELECT']['withClause']['WITHCLAUSE']['ctes'].each do |item|
              statements << item['COMMONTABLEEXPR']['ctequery'] if item['COMMONTABLEEXPR']
            end
          end
        elsif statement['SELECT']['op'] == 1
          statements << statement['SELECT']['larg'] if statement['SELECT']['larg']
          statements << statement['SELECT']['rarg'] if statement['SELECT']['rarg']
        end
      elsif statement['UPDATE']
        condition_items << statement['UPDATE']['whereClause'] if statement['UPDATE']['whereClause']
      elsif statement['DELETE FROM']
        condition_items << statement['DELETE FROM']['whereClause'] if statement['DELETE FROM']['whereClause']
      end
    end

    # Process both JOIN and WHERE conditions here
    next_item = condition_items.shift
    if next_item
      if next_item.keys[0].start_with?('AEXPR') || next_item['ANY']
        %w(lexpr rexpr).each do |side|
          expr = next_item.values[0][side]
          next unless expr && expr.is_a?(Hash)
          condition_items << expr
        end
      elsif next_item['ROW']
        condition_items += next_item['ROW']['args']
      elsif next_item['COLUMNREF']
        column, table = next_item['COLUMNREF']['fields'].reverse
        filter_columns << [@aliases[table] || table, column]
      elsif next_item['NULLTEST']
        condition_items << next_item['NULLTEST']['arg']
      elsif next_item['FUNCCALL']
        # FIXME: This should actually be extracted as a funccall and be compared with those indices
        condition_items += next_item['FUNCCALL']['args'] if next_item['FUNCCALL']['args']
      elsif next_item['SUBLINK']
        condition_items << next_item['SUBLINK']['testexpr']
        statements << next_item['SUBLINK']['subselect']
      end
    end

    break if statements.empty? && condition_items.empty?
  end

  filter_columns.uniq
end

#fingerprintObject

rubocop:disable Metrics/CyclomaticComplexity



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
# File 'lib/pg_query/fingerprint.rb', line 4

def fingerprint # rubocop:disable Metrics/CyclomaticComplexity
  normalized_parsetree = deep_dup(parsetree)

  # First delete all simple elements and attributes that can be removed
  treewalker! normalized_parsetree do |expr, k, v|
    if v.is_a?(Hash) && %w(A_CONST ALIAS PARAMREF).include?(v.keys[0])
      # Remove constants, aliases and param references from tree
      expr[k] = nil
    elsif k == 'location'
      # Remove location info in order to ignore whitespace and target list ordering
      expr.delete(k)
    end
  end

  # Now remove all unnecessary info
  treewalker! normalized_parsetree do |expr, k, v|
    if k == 'AEXPR IN' && v.is_a?(Hash) && v['rexpr'].is_a?(Array)
      # Compact identical IN list elements to one
      v['rexpr'].uniq!
    elsif k == 'targetList' && v.is_a?(Array)
      # Remove SELECT target list names & ignore order
      v.each { |v2| v2['RESTARGET']['name'] = nil if v2['RESTARGET'] } # Remove names
      v.sort_by!(&:to_s)
      expr[k] = v
    elsif k == 'cols' && v.is_a?(Array)
      # Ignore INSERT cols order
      v.sort_by!(&:to_s)
      expr[k] = v
    end
  end

  Digest::SHA1.hexdigest(normalized_parsetree.to_s)
end

#param_refsObject

rubocop:disable Metrics/CyclomaticComplexity



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
# File 'lib/pg_query/param_refs.rb', line 2

def param_refs # rubocop:disable Metrics/CyclomaticComplexity
  results = []

  treewalker! parsetree do |_, _, v|
    next unless v.is_a?(Hash)

    if v['PARAMREF']
      results << { 'location' => v['PARAMREF']['location'],
                   'length' => param_ref_length(v['PARAMREF']) }
    elsif v['TYPECAST']
      next unless v['TYPECAST']['arg'] && v['TYPECAST']['typeName']

      p = v['TYPECAST']['arg'].delete('PARAMREF')
      t = v['TYPECAST']['typeName'].delete('TYPENAME')
      next unless p && t

      location = p['location']
      typeloc  = t['location']
      typename = t['names'].join('.')
      length   = param_ref_length(p)

      if typeloc < location
        length += location - typeloc
        location = typeloc
      end

      results << { 'location' => location, 'length' => length, 'typename' => typename }
    end
  end

  results.sort_by! { |r| r['location'] }
  results
end

#tablesObject



32
33
34
35
# File 'lib/pg_query/parse.rb', line 32

def tables
  load_tables_and_aliases! if @tables.nil?
  @tables
end

#truncate(max_length) ⇒ Object

Truncates the query string to be below the specified length, first trying to omit less important parts of the query, and only then cutting off the end.



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
# File 'lib/pg_query/truncate.rb', line 6

def truncate(max_length)
  output = deparse(parsetree)

  # Early exit if we're already below the max length
  return output if output.size <= max_length

  truncations = find_possible_truncations

  # Truncate the deepest possible truncation that is the longest first
  truncations.sort_by! { |t| [-t.location.size, -t.length] }

  tree = deep_dup(parsetree)
  truncations.each do |truncation|
    next if truncation.length < 3

    find_tree_location(tree, truncation.location) do |expr, k|
      expr[k] = { 'A_TRUNCATED' => nil }
      expr[k] = [expr[k]] if truncation.is_array
    end

    output = deparse(tree)
    return output if output.size <= max_length
  end

  # We couldn't do a proper smart truncation, so we need a hard cut-off
  output[0..max_length - 4] + '...'
end