Class: Exwiw::QueryAstBuilder

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

Defined Under Namespace

Classes: ScopedArm

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: [], batch_ids: nil) ⇒ QueryAstBuilder

Returns a new instance of QueryAstBuilder.



61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
# File 'lib/exwiw/query_ast_builder.rb', line 61

def initialize(table_name, table_by_name, dump_target, logger, allow_reverse: true, forward_path: [], batch_ids: nil)
  @table_name = table_name
  @table_by_name = table_by_name
  @dump_target = dump_target
  @logger = logger
  @allow_reverse = allow_reverse
  # One batch's slice of the batch table's in-scope primary keys, set only by
  # BatchedExtraction. Deliberately not threaded into the recursive builds
  # below, which compile other tables' queries.
  @batch_ids = batch_ids
  # @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.



59
60
61
# File 'lib/exwiw/query_ast_builder.rb', line 59

def dump_target
  @dump_target
end

#table_by_nameObject (readonly)

Returns the value of attribute table_by_name.



59
60
61
# File 'lib/exwiw/query_ast_builder.rb', line 59

def table_by_name
  @table_by_name
end

#table_nameObject (readonly)

Returns the value of attribute table_name.



59
60
61
# File 'lib/exwiw/query_ast_builder.rb', line 59

def table_name
  @table_name
end

Class Method Details

.run(table_name, table_by_name, dump_target, logger, allow_reverse: true, forward_path: [], batch_ids: nil) ⇒ 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: [], batch_ids: nil)
  new(table_name, table_by_name, dump_target, logger, allow_reverse: allow_reverse, forward_path: forward_path, batch_ids: batch_ids).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: abort if any extractable table cannot be scoped (scope mode), or declares a batch_scope its scoping shape cannot be sliced by (both modes) — before any output is written. tables is the set of dumpable configs (ignore:true tables are skipped — they are not extracted).



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

def self.validate_scope!(tables, table_by_name, dump_target, logger)
  # Unscopable is reported before a bad batch_scope shape — it is the more
  # fundamental problem.
  if 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

    if unscopable.any?
      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
  end

  tables.reject(&:ignore).each do |table|
    next unless table.respond_to?(:batch_scope) && table.batch_scope

    new(table.name, table_by_name, dump_target, logger).batch_scope_terminus!
  end
end

Instance Method Details

#batch_scope_terminus!Object

The scoped table whose in-scope primary keys slice this table's extraction, or nil when it declares no batch_scope. Shapes are accepted only when every row the table keeps is selected through that table's scope filter — otherwise the unconstrained route would re-emit the same rows in every batch — so each rejection below explains itself to the config author.



658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
# File 'lib/exwiw/query_ast_builder.rb', line 658

def batch_scope_terminus!
  table = table_by_name.fetch(table_name)
  batch_scope = table.batch_scope
  return nil if batch_scope.nil?

  prefix = "Table '#{table.name}': batch_scope"

  unless scope_mode?
    raise ArgumentError,
          "#{prefix} is supported in scope-column mode only. In single `--target-table` mode the " \
          "extraction is already anchored on a caller-supplied id list, which can be batched by " \
          "running exwiw once per slice of `--ids`."
  end

  if scope_exempt?(table)
    raise ArgumentError,
          "#{prefix} cannot apply: the table is exported in full (scope_exempt / rails-managed), " \
          "so there is no scope filter to slice."
  end

  terminus = table_by_name[batch_scope.table]
  if terminus.nil?
    raise ArgumentError, "#{prefix} names table '#{batch_scope.table}', which is not in the schema."
  end
  if terminus.primary_key.nil?
    raise ArgumentError, "#{prefix} table '#{terminus.name}' has no primary_key to slice the extraction by."
  end
  unless directly_scoped?(terminus)
    raise ArgumentError,
          "#{prefix} table '#{terminus.name}' does not carry the scope column " \
          "(#{resolved_scope_column(terminus) || 'none declared'}), so its in-scope ids cannot be " \
          "resolved. Name the scoped table this table joins up to."
  end
  # A scope_exempt terminus carries the column but its own extraction query is
  # unfiltered, so the batches would substitute every tenant's ids for the
  # scope filter the unbatched join still applies.
  if scope_exempt?(terminus)
    raise ArgumentError,
          "#{prefix} table '#{terminus.name}' is exported in full (scope_exempt / rails-managed), " \
          "so its id set is not scoped and every batch would reach outside the scope."
  end

  if directly_scoped?(table)
    return terminus if terminus.name == table.name

    raise ArgumentError,
          "#{prefix} must name '#{table.name}' itself, which carries the scope column and is " \
          "therefore filtered directly rather than through '#{terminus.name}'."
  end

  arms = scoped_arms(table)
  unless arms.size == 1 && arms.first.path
    raise ArgumentError,
          "#{prefix} needs a single belongs_to join path from '#{table.name}' to the scope, but it is " \
          "scoped another way (polymorphic arms / reverse_scope / referenced-by / the parent cascade), " \
          "or not scoped at all. Those other id sets keep rows by routes a batch of '#{terminus.name}' " \
          "ids does not constrain, so every batch would re-emit them."
  end

  path = arms.first.path
  unless path.last == terminus.name
    raise ArgumentError,
          "#{prefix} table '#{terminus.name}' is not where '#{table.name}' reaches the scope " \
          "(#{path.join(' -> ')}); name that path's scoped table, '#{path.last}'."
  end

  terminus
end

#runObject



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

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.



517
518
519
520
521
522
523
524
525
526
# File 'lib/exwiw/query_ast_builder.rb', line 517

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 scoped_arms(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