Class: Exwiw::QueryAstBuilder

Inherits:
Object
  • Object
show all
Defined in:
lib/exwiw/query_ast_builder.rb

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(table_name, table_by_name, dump_target, logger, allow_reverse: true, forward_path: []) ⇒ QueryAstBuilder

Returns a new instance of QueryAstBuilder.



52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# File 'lib/exwiw/query_ast_builder.rb', line 52

def initialize(table_name, table_by_name, dump_target, logger, allow_reverse: true, forward_path: [])
  @table_name = table_name
  @table_by_name = table_by_name
  @dump_target = dump_target
  @logger = logger
  @allow_reverse = allow_reverse
  # @forward_path is the chain of tables currently being forward-resolved by
  # the "scope via an indirectly-scoped belongs_to parent" rescue
  # (build_belongs_to_scoped_clause). Each forward hop appends the table it is
  # descending from, so the rescue recurses N levels (users -> end_users ->
  # end_user_profiles -> ...) and stops only on a real belongs_to cycle: a
  # table already on the path is not re-resolved, falling through to
  # :unscopable instead of looping forever.
  @forward_path = forward_path
end

Instance Attribute Details

#dump_targetObject (readonly)

Returns the value of attribute dump_target.



50
51
52
# File 'lib/exwiw/query_ast_builder.rb', line 50

def dump_target
  @dump_target
end

#table_by_nameObject (readonly)

Returns the value of attribute table_by_name.



50
51
52
# File 'lib/exwiw/query_ast_builder.rb', line 50

def table_by_name
  @table_by_name
end

#table_nameObject (readonly)

Returns the value of attribute table_name.



50
51
52
# File 'lib/exwiw/query_ast_builder.rb', line 50

def table_name
  @table_name
end

Class Method Details

.run(table_name, table_by_name, dump_target, logger, allow_reverse: true, forward_path: []) ⇒ Object



5
6
7
# File 'lib/exwiw/query_ast_builder.rb', line 5

def self.run(table_name, table_by_name, dump_target, logger, allow_reverse: true, forward_path: [])
  new(table_name, table_by_name, dump_target, logger, allow_reverse: allow_reverse, forward_path: forward_path).run
end

.scope_category(table_name, table_by_name, dump_target, logger) ⇒ Object

Scope-column mode classification for a single table. One of :exempt / :direct / :via_path / :referenced_by / :via_scoped_parent / :unscopable.



11
12
13
# File 'lib/exwiw/query_ast_builder.rb', line 11

def self.scope_category(table_name, table_by_name, dump_target, logger)
  new(table_name, table_by_name, dump_target, logger).scope_category
end

.scope_mode?(table_by_name, dump_target) ⇒ Boolean

Scope-column mode is active when EITHER the named --target-table declares a per-table scope_column (the preferred trigger: the target is then scoped like any other table — its --ids are scope-column values, not primary keys), OR the deprecated --scope-column flag is set (a global column with no target). In both cases every table is filtered by a shared column instead of being anchored on one named target's primary key.

Returns:

  • (Boolean)


21
22
23
24
25
26
27
# File 'lib/exwiw/query_ast_builder.rb', line 21

def self.scope_mode?(table_by_name, dump_target)
  return true unless dump_target.scope_column.nil?
  return false if dump_target.table_name.nil?

  target = table_by_name[dump_target.table_name]
  !!(target && target.respond_to?(:scope_column) && target.scope_column)
end

.validate_scope!(tables, table_by_name, dump_target, logger) ⇒ Object

Strict pre-flight for scope-column mode: abort if any extractable table cannot be scoped, so an unscoped (potentially sensitive) table is never silently dumped in full. No-op outside scope mode. tables is the set of dumpable configs (ignore:true tables are skipped — they are not extracted).

Raises:

  • (ArgumentError)


33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# File 'lib/exwiw/query_ast_builder.rb', line 33

def self.validate_scope!(tables, table_by_name, dump_target, logger)
  return unless scope_mode?(table_by_name, dump_target)

  unscopable =
    tables.reject(&:ignore).select do |table|
      scope_category(table.name, table_by_name, dump_target, logger) == :unscopable
    end
  return if unscopable.empty?

  names = unscopable.map(&:name).sort.join(", ")
  raise ArgumentError,
        "scope-column mode: #{unscopable.size} table(s) cannot be scoped: #{names}. " \
        "For each, declare `scope_column: <column>` on the table to filter it directly, " \
        "add a belongs_to path to a table that carries the scope column, mark it " \
        "`scope_exempt: true` to export it in full, or set `ignore: true` to skip it."
end

Instance Method Details

#runObject



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
# File 'lib/exwiw/query_ast_builder.rb', line 68

def run
  table = table_by_name.fetch(table_name)

  return build_scoped(table) if scope_mode?

  where_clauses = build_where_clauses(table, dump_target)
  join_clauses = build_join_clauses(table, table_by_name, dump_target)

  # Reverse / "referenced_by" extraction. A table with no belongs_to path to
  # the dump target produces no where/join clauses and would otherwise dump
  # every row (see the "no relation -> dump all" case). If an extractable
  # child table references it via a foreign key (e.g. active_storage_blobs is
  # referenced by active_storage_attachments.blob_id), constrain it to just
  # the referenced ids instead. Disabled (@allow_reverse=false) while building
  # a child's subquery, so this never recurses.
  if @allow_reverse && table.name != dump_target.table_name &&
     where_clauses.empty? && join_clauses.empty?
    reverse_clause = build_referenced_by_clause(table)
    where_clauses.push(reverse_clause) if reverse_clause
  end

  # Forward cascade. A satellite of a reverse_scope'd (or referenced-by-scoped)
  # hub has no belongs_to path to the dump target, so the clauses above stay
  # empty and it would dump every row. When its belongs_to parent is itself
  # scoped, constrain this table to the parent's in-scope ids — the same
  # multi-hop cascade scope-column mode performs in build_scoped.
  if table.name != dump_target.table_name &&
     where_clauses.empty? && join_clauses.empty? &&
     forward_scope_allowed?(table)
    parent_clause = build_belongs_to_scoped_clause(table)
    if parent_clause
      where_clauses.push(parent_clause)
    elsif @allow_reverse && @forward_path.empty? && !scope_exempt?(table) &&
          scopable_parent_candidates(table).size > 1
      @logger.warn(
        "  #{table.name} belongs_to multiple scopable parents; the cascade cannot " \
        "pick one unambiguously, so it is dumped in full. If this is intended, set " \
        "`scope_exempt: true`. Otherwise, scope it through a single parent (e.g. ignore one belongs_to edge), " \
        "or switch to scope-column mode to scope it directly."
      )
    end
  end

  QueryAst::Select.new.tap do |ast|
    ast.from(table.name)
    if table.rails_managed?
      ast.select_all!
    else
      ast.select(table.columns)
    end
    join_clauses.each { |join_clause| ast.join(join_clause) }
    where_clauses.each { |where_clause| ast.where(where_clause) }
  end
end

#scope_categoryObject

Classifier used by validate_scope! and mirrored by build_scoped below.



504
505
506
507
508
509
510
511
512
513
# File 'lib/exwiw/query_ast_builder.rb', line 504

def scope_category
  table = table_by_name.fetch(table_name)
  return :exempt if scope_exempt?(table)
  return :direct if directly_scoped?(table)
  return :via_path if build_join_clauses_scoped(table).any?
  return :referenced_by if @allow_reverse && build_referenced_by_clause(table)
  return :via_scoped_parent if forward_scope_allowed?(table) && build_belongs_to_scoped_clause(table)

  :unscopable
end