Skip to content

fix(templates): harden legacy-migration job per Sourcery review (EVO-1719)#149

Merged
dpaes merged 1 commit into
developfrom
danilocarneiro/evo-1719-follow-up-610-4-pontos-do-review-sourcery-no-job-de-migracao
Jun 15, 2026
Merged

fix(templates): harden legacy-migration job per Sourcery review (EVO-1719)#149
dpaes merged 1 commit into
developfrom
danilocarneiro/evo-1719-follow-up-610-4-pontos-do-review-sourcery-no-job-de-migracao

Conversation

@daniloleonecarneiro

@daniloleonecarneiro daniloleonecarneiro commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Non-blocking follow-up (6.10) to the Sourcery review of the EVO-1234 [6.5] legacy → MessageTemplate migration job. Four points raised; three applied, one intentionally declined.

  • 🟡 [Medium] N+1 on the batch loop. MessageTemplate.where.not(channel_id: nil).find_in_batches had no preload, so keep_channel_bound? issued a per-row source.channel query for every WhatsApp row. Added .includes(:channel). (channel is a polymorphic belongs_to; includes keeps it a preload and preserves find_in_batches' default ORDER BY id.)
  • 🟡 [Medium] Over-broad rollback DELETE. rollback_legacy_migration deleted where.not(external_legacy_id: nil). The column is exclusive to this migration today, but a future integration reusing it would be wiped. Scoped to this migration's provenance prefix: external_legacy_id LIKE 'message_template:%', reusing MigrateLegacyTemplatesToMessageTemplateJob::LEGACY_KEY_PREFIX. The rollback spec mirrors the filter and now seeds a foreign-provenance row (other_integration:1) that must survive — without it the test was vacuous (passed against the old scope too).
  • 🟢 [Low] Raw skip-reason symbols. :invalid and :error escaped the REASON_* convention. Promoted to REASON_VALIDATION_FAILED / REASON_ERROR (used in the job, the rake :error gate, and the spec). Named REASON_VALIDATION_FAILED, not REASON_INVALID, to avoid a near-synonym collision with the existing REASON_INVALID_CONTENT. Symbol values are unchanged, and the comment's deliberate "documented taxonomy vs. diagnostic buckets" two-tier distinction is preserved.
  • ⚪ [Declined] require 'set'. Not added. Under Ruby 3.4 Set is autoloaded and require 'set' trips Lint/RedundantRequireStatement (verified: 1 offense, autocorrectable) — adding it would introduce a lint offense. This matches Sourcery's own "não procede / inócuo" verdict on this point.

No migration, no schema change, no behavior change.

Test plan

  • POSTGRES_PORT=5433 ... bundle exec rspec spec/jobs/migrate_legacy_templates_to_message_template_job_spec.rb13 examples, 0 failures.
  • Non-vacuity check (rollback): temporarily reverting the spec's delete to the old where.not(external_legacy_id: nil) makes the example FAIL on the foreign-provenance assertion — confirming the test now distinguishes the new scope.
  • RuboCop: 9 offenses on the branch == 9 on the develop baseline → zero new offenses; Lint/RedundantRequireStatement = 0.

Notes

Summary by Sourcery

Harden the legacy-to-MessageTemplate migration job and rollback task based on Sourcery review feedback, tightening rollback scoping, naming skip reasons, and reducing query overhead.

Bug Fixes:

  • Restrict the rollback migration to delete only templates created by this migration using the legacy key prefix, ensuring admin and foreign-provenance templates are preserved.

Enhancements:

  • Add eager loading of channels when batching message templates to avoid N+1 queries during migration.
  • Introduce named constants for validation-failure and error skip reasons and update the job, rake task, and specs to consistently use them.
  • Strengthen the rollback spec to assert that foreign-provenance templates survive and that only this migration's templates are removed.

…1719)

Addresses the EVO-1234 [6.5] migration-job review (Sourcery follow-up 6.10):

- N+1: eager-load :channel in the find_in_batches query (keep_channel_bound?
  reads source.channel per WhatsApp row).
- Rollback blast-radius: scope rollback_legacy_migration to this migration's
  own provenance prefix (external_legacy_id LIKE 'message_template:%', reusing
  LEGACY_KEY_PREFIX) so foreign-tagged rows are never deleted. The rollback
  spec mirrors the filter and asserts a foreign-provenance row survives
  (guards against a vacuous test).
- Consistency: promote the :invalid and :error diagnostic buckets to
  REASON_VALIDATION_FAILED / REASON_ERROR constants (named distinctly from the
  existing REASON_INVALID_CONTENT), used in the job, the rake :error gate, and
  the spec. Symbol values unchanged.

require 'set' (4th point) is intentionally NOT added: it triggers
Lint/RedundantRequireStatement under Ruby 3.4 (Set is autoloaded), matching
Sourcery's own "não procede" verdict.

13/13 specs green; no new RuboCop offenses.
@sourcery-ai

sourcery-ai Bot commented Jun 15, 2026

Copy link
Copy Markdown

Reviewer's Guide

Hardens the legacy-to-MessageTemplate migration job and its rake tasks per Sourcery feedback by preloading associations to avoid N+1 queries, tightening rollback scoping to only this job’s provenance keys, and standardizing skip-reason constants and their usage in the job, rake task, and specs.

File-Level Changes

Change Details Files
Avoid N+1 queries in the migration job’s batch processing.
  • Add an includes(:channel) preload to the MessageTemplate.where.not(channel_id: nil).find_in_batches scope used by the migration job so keep_channel_bound? no longer issues per-row channel queries
  • Preserve find_in_batches default ordering while ensuring polymorphic channel is eagerly loaded
app/jobs/migrate_legacy_templates_to_message_template_job.rb
Tighten rollback scope to only delete rows created by this migration and verify it via specs.
  • Change rollback_legacy_migration scope from where.not(external_legacy_id: nil) to a prefix-based LIKE filter using MigrateLegacyTemplatesToMessageTemplateJob::LEGACY_KEY_PREFIX
  • Update rollback spec description and expectations to mirror the new prefix-based filter
  • Seed a foreign-provenance MessageTemplate row in the rollback spec and assert it survives the rollback to ensure the test is non-vacuous
  • Align the spec’s delete query and globals expectation with the new prefix-scoped rollback behavior
lib/tasks/templates.rake
spec/jobs/migrate_legacy_templates_to_message_template_job_spec.rb
Normalize skip-reason handling using named constants and wire them through job, rake task, and specs.
  • Introduce REASON_VALIDATION_FAILED and REASON_ERROR constants as diagnostic buckets distinct from the main taxonomy constants
  • Replace raw :invalid and :error symbols in the job with REASON_VALIDATION_FAILED and REASON_ERROR in skip paths and error handling
  • Update the rake task to read the error skip count through the job’s REASON_ERROR constant instead of a hardcoded :error key
  • Adjust specs to reference described_class::REASON_ERROR when asserting skipped error counts
  • Clarify comments describing the taxonomy vs diagnostic skip reasons and their intended separation
app/jobs/migrate_legacy_templates_to_message_template_job.rb
lib/tasks/templates.rake
spec/jobs/migrate_legacy_templates_to_message_template_job_spec.rb

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • The prefix-based provenance filter (where('external_legacy_id LIKE ?', "#{prefix}:%")) is now duplicated across the rake task and specs; consider extracting it into a named scope or helper on MessageTemplate to centralize the logic and avoid future drift.
  • The use of plain hashes for summary[:skipped] keys now spread across job, rake task, and specs could be further hardened by adding a small value-object or helper method to encapsulate skip-reason handling and reduce reliance on raw symbol keys.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The prefix-based provenance filter (`where('external_legacy_id LIKE ?', "#{prefix}:%")`) is now duplicated across the rake task and specs; consider extracting it into a named scope or helper on `MessageTemplate` to centralize the logic and avoid future drift.
- The use of plain hashes for `summary[:skipped]` keys now spread across job, rake task, and specs could be further hardened by adding a small value-object or helper method to encapsulate skip-reason handling and reduce reliance on raw symbol keys.

## Individual Comments

### Comment 1
<location path="lib/tasks/templates.rake" line_range="36-37" />
<code_context>
+    # (external_legacy_id LIKE 'message_template:%'); channel-bound originals,
+    # admin-created globals (external_legacy_id IS NULL), and any rows tagged by
+    # a future unrelated integration are never touched.
+    prefix = MigrateLegacyTemplatesToMessageTemplateJob::LEGACY_KEY_PREFIX
+    scope = MessageTemplate.where('external_legacy_id LIKE ?', "#{prefix}:%")
     count = scope.count
     scope.delete_all
</code_context>
<issue_to_address>
**issue (bug_risk):** Consider escaping the prefix for SQL LIKE to avoid future wildcard surprises.

If `LEGACY_KEY_PREFIX` ever includes `%` or `_`, this `LIKE` will treat them as wildcards and could delete unintended rows. Consider escaping the prefix first (e.g. via `ActiveRecord::Base.send(:sanitize_sql_like, prefix)`) before appending `:%` so only the intended namespace is matched.
</issue_to_address>

### Comment 2
<location path="spec/jobs/migrate_legacy_templates_to_message_template_job_spec.rb" line_range="153-159" />
<code_context>

-      # Mirrors lib/tasks/templates.rake rollback_legacy_migration.
-      MessageTemplate.where.not(external_legacy_id: nil).delete_all
+      # Mirrors lib/tasks/templates.rake rollback_legacy_migration (prefix-scoped).
+      MessageTemplate.where('external_legacy_id LIKE ?', "#{described_class::LEGACY_KEY_PREFIX}:%").delete_all

       expect(MessageTemplate.exists?(admin.id)).to be(true)
       expect(MessageTemplate.exists?(source.id)).to be(true)
-      expect(globals.where.not(external_legacy_id: nil).count).to eq(0)
+      expect(MessageTemplate.exists?(foreign.id)).to be(true)
+      expect(globals.where('external_legacy_id LIKE ?', "#{described_class::LEGACY_KEY_PREFIX}:%").count).to eq(0)
     end
   end
</code_context>
<issue_to_address>
**suggestion (testing):** Consider adding a dedicated spec for the `templates:rollback_legacy_migration` rake task to avoid test drift from the job spec.

Because this example now mirrors the rake task, changes to the rake implementation could introduce bugs that the job spec would also mirror, hiding the issue. To reduce this risk, add a small rake-task spec (or shared example used by both specs) that directly invokes `Rake::Task['templates:rollback_legacy_migration'].invoke` and asserts: admin globals kept, channel-bound originals kept, foreign-provenance globals kept, and only `LEGACY_KEY_PREFIX` globals deleted. This helps ensure the tests stay aligned with the real rake behavior over time.

Suggested implementation:

```ruby
  shared_examples 'legacy template rollback scope' do
    it 'deletes only this migration\'s globals, leaving originals, admin globals, and foreign-provenance rows' do
      admin = MessageTemplate.create!(channel: nil, name: 'Kept', content: 'admin')
      source = coupled_template(channel: baileys, name: 'Migrated')

      # A global tagged by a hypothetical OTHER integration. The old unscoped
      # rollback (where.not(external_legacy_id: nil)) would have wrongly deleted
      # this; the prefix-scoped delete must spare it. Without this row the test
      # is vacuous — it would pass against the old scope too.

```

```ruby
      foreign = MessageTemplate.create!(
        channel: nil,
        name: 'Foreign',
        content: 'x',
        external_legacy_id: 'other_integration:1'
      )

      perform_rollback.call

      expect(MessageTemplate.exists?(admin.id)).to be(true)
      expect(MessageTemplate.exists?(source.id)).to be(true)
      expect(MessageTemplate.exists?(foreign.id)).to be(true)
      expect(
        globals.where(
          'external_legacy_id LIKE ?',
          "#{described_class::LEGACY_KEY_PREFIX}:%"
        ).count
      ).to eq(0)
    end
  end

  describe 'rollback scope (AC7)' do
    let(:perform_rollback) do
      lambda do
        described_class.new.perform

        # Mirrors lib/tasks/templates.rake rollback_legacy_migration (prefix-scoped).
        MessageTemplate.where(
          'external_legacy_id LIKE ?',
          "#{described_class::LEGACY_KEY_PREFIX}:%"
        ).delete_all
      end
    end

    it_behaves_like 'legacy template rollback scope'

```

To fully implement your suggestion and de-couple the job spec from the rake task implementation, add a new spec file, e.g. `spec/tasks/templates_rake_spec.rb`, that:

1. Loads rake tasks (commonly via `Rails.application.load_tasks` or your existing helper).
2. Defines `let(:perform_rollback) { -> { Rake::Task['templates:rollback_legacy_migration'].reenable && Rake::Task['templates:rollback_legacy_migration'].invoke } }`.
3. Includes the shared example with `it_behaves_like 'legacy template rollback scope'` so it runs the same expectations as the job spec, but against the real rake task.
4. Ensures the task is re-enabled between examples if there are multiple invocations.

This keeps the shared behavior in one place while directly exercising the rake task, reducing the risk of the job spec drifting alongside the rake implementation.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread lib/tasks/templates.rake
Comment on lines +36 to +37
prefix = MigrateLegacyTemplatesToMessageTemplateJob::LEGACY_KEY_PREFIX
scope = MessageTemplate.where('external_legacy_id LIKE ?', "#{prefix}:%")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Consider escaping the prefix for SQL LIKE to avoid future wildcard surprises.

If LEGACY_KEY_PREFIX ever includes % or _, this LIKE will treat them as wildcards and could delete unintended rows. Consider escaping the prefix first (e.g. via ActiveRecord::Base.send(:sanitize_sql_like, prefix)) before appending :% so only the intended namespace is matched.

Comment on lines +153 to +159
# Mirrors lib/tasks/templates.rake rollback_legacy_migration (prefix-scoped).
MessageTemplate.where('external_legacy_id LIKE ?', "#{described_class::LEGACY_KEY_PREFIX}:%").delete_all

expect(MessageTemplate.exists?(admin.id)).to be(true)
expect(MessageTemplate.exists?(source.id)).to be(true)
expect(globals.where.not(external_legacy_id: nil).count).to eq(0)
expect(MessageTemplate.exists?(foreign.id)).to be(true)
expect(globals.where('external_legacy_id LIKE ?', "#{described_class::LEGACY_KEY_PREFIX}:%").count).to eq(0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Consider adding a dedicated spec for the templates:rollback_legacy_migration rake task to avoid test drift from the job spec.

Because this example now mirrors the rake task, changes to the rake implementation could introduce bugs that the job spec would also mirror, hiding the issue. To reduce this risk, add a small rake-task spec (or shared example used by both specs) that directly invokes Rake::Task['templates:rollback_legacy_migration'].invoke and asserts: admin globals kept, channel-bound originals kept, foreign-provenance globals kept, and only LEGACY_KEY_PREFIX globals deleted. This helps ensure the tests stay aligned with the real rake behavior over time.

Suggested implementation:

  shared_examples 'legacy template rollback scope' do
    it 'deletes only this migration\'s globals, leaving originals, admin globals, and foreign-provenance rows' do
      admin = MessageTemplate.create!(channel: nil, name: 'Kept', content: 'admin')
      source = coupled_template(channel: baileys, name: 'Migrated')

      # A global tagged by a hypothetical OTHER integration. The old unscoped
      # rollback (where.not(external_legacy_id: nil)) would have wrongly deleted
      # this; the prefix-scoped delete must spare it. Without this row the test
      # is vacuous — it would pass against the old scope too.
      foreign = MessageTemplate.create!(
        channel: nil,
        name: 'Foreign',
        content: 'x',
        external_legacy_id: 'other_integration:1'
      )

      perform_rollback.call

      expect(MessageTemplate.exists?(admin.id)).to be(true)
      expect(MessageTemplate.exists?(source.id)).to be(true)
      expect(MessageTemplate.exists?(foreign.id)).to be(true)
      expect(
        globals.where(
          'external_legacy_id LIKE ?',
          "#{described_class::LEGACY_KEY_PREFIX}:%"
        ).count
      ).to eq(0)
    end
  end

  describe 'rollback scope (AC7)' do
    let(:perform_rollback) do
      lambda do
        described_class.new.perform

        # Mirrors lib/tasks/templates.rake rollback_legacy_migration (prefix-scoped).
        MessageTemplate.where(
          'external_legacy_id LIKE ?',
          "#{described_class::LEGACY_KEY_PREFIX}:%"
        ).delete_all
      end
    end

    it_behaves_like 'legacy template rollback scope'

To fully implement your suggestion and de-couple the job spec from the rake task implementation, add a new spec file, e.g. spec/tasks/templates_rake_spec.rb, that:

  1. Loads rake tasks (commonly via Rails.application.load_tasks or your existing helper).
  2. Defines let(:perform_rollback) { -> { Rake::Task['templates:rollback_legacy_migration'].reenable && Rake::Task['templates:rollback_legacy_migration'].invoke } }.
  3. Includes the shared example with it_behaves_like 'legacy template rollback scope' so it runs the same expectations as the job spec, but against the real rake task.
  4. Ensures the task is re-enabled between examples if there are multiple invocations.

This keeps the shared behavior in one place while directly exercising the rake task, reducing the risk of the job spec drifting alongside the rake implementation.

@dpaes dpaes left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved. Clean, well-scoped follow-up. Verified all four Sourcery points against origin/develop + the PR head:

  1. N+1 → includes(:channel)keep_channel_bound? reads source.channel (WhatsApp rows only); belongs_to :channel, polymorphic: true, optional: trueincludes does a polymorphic preload and preserves the find_in_batches PK order. N+1 gone.
  2. Rollback blast-radius ✅ Now scoped to external_legacy_id LIKE 'message_template:%' reusing LEGACY_KEY_PREFIX (matches legacy_key). The spec is non-vacuous: it seeds a foreign-provenance row (other_integration:1) and asserts it survives — the old unscoped delete would have wrongly removed it.
  3. require 'set' ⚪ Correctly NOT added — Set is autoloaded on Ruby 3.2+ and the require would trip Lint/RedundantRequireStatement, matching Sourcery's own "não procede" verdict.
  4. Reason constants:invalid/:error promoted to REASON_VALIDATION_FAILED / REASON_ERROR across job + rake gate + spec. Symbol values unchanged, so the summary buckets and reason.to_s metrics stay backward-compatible; no other reader of the raw bucket exists in the repo.

Non-blocking nit (cosmetic): the _ chars in 'message_template:%' are single-char LIKE wildcards, so it could in theory match messageXtemplate:.... Practically harmless (no plausible foreign prefix matches; rollback is a manual operator task) — no follow-up needed.

No schema/behavior change. Note: this repo's CI doesn't run RSpec, so the 13/13 green run is the author's local result.

@dpaes dpaes merged commit 6ba7e43 into develop Jun 15, 2026
2 checks passed
@dpaes dpaes deleted the danilocarneiro/evo-1719-follow-up-610-4-pontos-do-review-sourcery-no-job-de-migracao branch June 15, 2026 16:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants