Class: Exwiw::QueryAstBuilder
- Inherits:
-
Object
- Object
- Exwiw::QueryAstBuilder
- Defined in:
- lib/exwiw/query_ast_builder.rb
Defined Under Namespace
Classes: ScopedArm
Instance Attribute Summary collapse
-
#dump_target ⇒ Object
readonly
Returns the value of attribute dump_target.
-
#table_by_name ⇒ Object
readonly
Returns the value of attribute table_by_name.
-
#table_name ⇒ Object
readonly
Returns the value of attribute table_name.
Class Method Summary collapse
- .run(table_name, table_by_name, dump_target, logger, allow_reverse: true, forward_path: [], batch_ids: nil) ⇒ Object
-
.scope_category(table_name, table_by_name, dump_target, logger) ⇒ Object
Scope-column mode classification for a single table.
-
.scope_mode?(table_by_name, dump_target) ⇒ Boolean
Scope-column mode is active when EITHER the named
--target-tabledeclares a per-tablescope_column(the preferred trigger: the target is then scoped like any other table — its--idsare scope-column values, not primary keys), OR the deprecated--scope-columnflag is set (a global column with no target). -
.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_scopeits scoping shape cannot be sliced by (both modes) — before any output is written.
Instance Method Summary collapse
-
#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. -
#initialize(table_name, table_by_name, dump_target, logger, allow_reverse: true, forward_path: [], batch_ids: nil) ⇒ QueryAstBuilder
constructor
A new instance of QueryAstBuilder.
- #run ⇒ Object
-
#scope_category ⇒ Object
Classifier used by validate_scope! and mirrored by build_scoped below.
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_target ⇒ Object (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_name ⇒ Object (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_name ⇒ Object (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.
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 |
#run ⇒ Object
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_category ⇒ Object
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 |