# frozen_string_literal: true

require 'spec_helper'

RSpec.describe Gitlab::Database::TablesTruncate, :reestablished_active_record_base,
  :suppress_gitlab_schemas_validate_connection, feature_category: :cell do
  include MigrationsHelpers

  let(:min_batch_size) { 1 }
  let(:main_connection) { ApplicationRecord.connection }
  let(:ci_connection) { Ci::ApplicationRecord.connection }
  let(:logger) { instance_double(Logger) }

  # Main Database
  let(:main_db_main_item_model) { table("_test_gitlab_main_items", database: "main") }
  let(:main_db_main_reference_model) { table("_test_gitlab_main_references", database: "main") }
  let(:main_db_ci_item_model) { table("_test_gitlab_ci_items", database: "main") }
  let(:main_db_ci_reference_model) { table("_test_gitlab_ci_references", database: "main") }
  let(:main_db_shared_item_model) { table("_test_gitlab_shared_items", database: "main") }
  let(:main_db_partitioned_item) { table("_test_gitlab_hook_logs", database: "main") }
  let(:main_db_partitioned_item_detached) do
    table("gitlab_partitions_dynamic._test_gitlab_hook_logs_202201", database: "main")
  end

  # CI Database
  let(:ci_db_main_item_model) { table("_test_gitlab_main_items", database: "ci") }
  let(:ci_db_main_reference_model) { table("_test_gitlab_main_references", database: "ci") }
  let(:ci_db_ci_item_model) { table("_test_gitlab_ci_items", database: "ci") }
  let(:ci_db_ci_reference_model) { table("_test_gitlab_ci_references", database: "ci") }
  let(:ci_db_shared_item_model) { table("_test_gitlab_shared_items", database: "ci") }
  let(:ci_db_partitioned_item) { table("_test_gitlab_hook_logs", database: "ci") }
  let(:ci_db_partitioned_item_detached) do
    table("gitlab_partitions_dynamic._test_gitlab_hook_logs_202201", database: "ci")
  end

  before do
    skip_if_shared_database(:ci)

    # Creating some test tables on the main database
    main_tables_sql = <<~SQL
      CREATE TABLE _test_gitlab_main_items (id serial NOT NULL PRIMARY KEY);

      CREATE TABLE _test_gitlab_main_references (
        id serial NOT NULL PRIMARY KEY,
        item_id BIGINT NOT NULL,
        CONSTRAINT fk_constrained_1 FOREIGN KEY(item_id) REFERENCES _test_gitlab_main_items(id)
      );

      CREATE TABLE _test_gitlab_hook_logs (
        id bigserial not null,
        created_at timestamptz not null,
        item_id BIGINT NOT NULL,
        PRIMARY KEY (id, created_at),
        CONSTRAINT fk_constrained_1 FOREIGN KEY(item_id) REFERENCES _test_gitlab_main_items(id)
      ) PARTITION BY RANGE(created_at);

      CREATE TABLE gitlab_partitions_dynamic._test_gitlab_hook_logs_202201
      PARTITION OF _test_gitlab_hook_logs
      FOR VALUES FROM ('20220101') TO ('20220131');

      CREATE TABLE gitlab_partitions_dynamic._test_gitlab_hook_logs_202202
      PARTITION OF _test_gitlab_hook_logs
      FOR VALUES FROM ('20220201') TO ('20220228');

      ALTER TABLE _test_gitlab_hook_logs DETACH PARTITION gitlab_partitions_dynamic._test_gitlab_hook_logs_202201;
    SQL

    execute_on_each_database(main_tables_sql)

    ci_tables_sql = <<~SQL
      CREATE TABLE _test_gitlab_ci_items (id serial NOT NULL PRIMARY KEY);

      CREATE TABLE _test_gitlab_ci_references (
        id serial NOT NULL PRIMARY KEY,
        item_id BIGINT NOT NULL,
        CONSTRAINT fk_constrained_1 FOREIGN KEY(item_id) REFERENCES _test_gitlab_ci_items(id)
      );
    SQL

    execute_on_each_database(ci_tables_sql)

    internal_tables_sql = <<~SQL
      CREATE TABLE _test_gitlab_shared_items (id serial NOT NULL PRIMARY KEY);
    SQL

    execute_on_each_database(internal_tables_sql)

    # Filling the tables
    5.times do |i|
      # Main Database
      main_db_main_item_model.create!(id: i)
      main_db_main_reference_model.create!(item_id: i)
      main_db_ci_item_model.create!(id: i)
      main_db_ci_reference_model.create!(item_id: i)
      main_db_shared_item_model.create!(id: i)
      main_db_partitioned_item.create!(item_id: i, created_at: '2022-02-02 02:00')
      main_db_partitioned_item_detached.create!(item_id: i, created_at: '2022-01-01 01:00')
      # CI Database
      ci_db_main_item_model.create!(id: i)
      ci_db_main_reference_model.create!(item_id: i)
      ci_db_ci_item_model.create!(id: i)
      ci_db_ci_reference_model.create!(item_id: i)
      ci_db_shared_item_model.create!(id: i)
      ci_db_partitioned_item.create!(item_id: i, created_at: '2022-02-02 02:00')
      ci_db_partitioned_item_detached.create!(item_id: i, created_at: '2022-01-01 01:00')
    end

    Gitlab::Database::SharedModel.using_connection(main_connection) do
      Postgresql::DetachedPartition.create!(
        table_name: '_test_gitlab_hook_logs_202201',
        drop_after: Time.current
      )
    end

    Gitlab::Database::SharedModel.using_connection(ci_connection) do
      Postgresql::DetachedPartition.create!(
        table_name: '_test_gitlab_hook_logs_202201',
        drop_after: Time.current
      )
    end

    allow(Gitlab::Database::GitlabSchema).to receive(:tables_to_schema).and_return(
      {
        "_test_gitlab_main_items" => :gitlab_main,
        "_test_gitlab_main_references" => :gitlab_main,
        "_test_gitlab_hook_logs" => :gitlab_main,
        "_test_gitlab_ci_items" => :gitlab_ci,
        "_test_gitlab_ci_references" => :gitlab_ci,
        "_test_gitlab_shared_items" => :gitlab_shared,
        "_test_gitlab_geo_items" => :gitlab_geo
      }
    )

    allow(Gitlab::Database::GitlabSchema).to receive(:views_and_tables_to_schema).and_return(
      {
        "_test_gitlab_main_items" => :gitlab_main,
        "_test_gitlab_main_references" => :gitlab_main,
        "_test_gitlab_hook_logs" => :gitlab_main,
        "_test_gitlab_ci_items" => :gitlab_ci,
        "_test_gitlab_ci_references" => :gitlab_ci,
        "_test_gitlab_shared_items" => :gitlab_shared,
        "_test_gitlab_geo_items" => :gitlab_geo,
        "detached_partitions" => :gitlab_shared,
        "postgres_foreign_keys" => :gitlab_shared,
        "postgres_partitions" => :gitlab_shared
      }
    )

    allow(logger).to receive(:info).with(any_args)
  end

  shared_examples 'truncating legacy tables on a database' do
    let(:dry_run) { false }
    let(:until_table) { nil }

    subject(:truncate_legacy_tables) do
      described_class.new(
        database_name: connection.pool.db_config.name,
        min_batch_size: min_batch_size,
        logger: logger,
        dry_run: dry_run,
        until_table: until_table
      ).execute
    end

    context 'when the truncated tables are not locked for writes' do
      it 'raises an error that the tables are not locked for writes' do
        error_message = /is not locked for writes. Run the rake task gitlab:db:lock_writes first/
        expect { truncate_legacy_tables }.to raise_error(error_message)
      end
    end

    context 'when the truncated tables are locked for writes' do
      before do
        legacy_tables_models.map(&:table_name).each do |table|
          Gitlab::Database::LockWritesManager.new(
            table_name: table,
            connection: connection,
            database_name: connection.pool.db_config.name,
            with_retries: false
          ).lock_writes
        end
      end

      it 'truncates the legacy tables' do
        old_counts = legacy_tables_models.map(&:count)
        expect do
          truncate_legacy_tables
        end.to change { legacy_tables_models.map(&:count) }.from(old_counts).to([0] * legacy_tables_models.length)
      end

      it 'does not affect the other tables' do
        expect do
          truncate_legacy_tables
        end.not_to change { other_tables_models.map(&:count) }
      end

      it 'logs the sql statements to the logger' do
        expect(logger).to receive(:info).with("SET LOCAL lock_timeout = 0")
        expect(logger).to receive(:info).with("SET LOCAL statement_timeout = 0")
        expect(logger).to receive(:info)
                      .with(/TRUNCATE TABLE #{legacy_tables_models.map(&:table_name).sort.join(', ')} RESTRICT/)
        truncate_legacy_tables
      end

      context 'when running in dry_run mode' do
        let(:dry_run) { true }

        it 'does not truncate the legacy tables if running in dry run mode' do
          legacy_tables_models = [main_db_ci_reference_model, main_db_ci_reference_model]
          expect do
            truncate_legacy_tables
          end.not_to change { legacy_tables_models.map(&:count) }
        end
      end

      context 'when passing until_table parameter' do
        context 'with a table that exists' do
          let(:until_table) { referencing_table_model.table_name }

          it 'only truncates until the table specified' do
            expect { truncate_legacy_tables }
              .to change(referencing_table_model, :count).by(-5)
              .and not_change(referenced_table_model, :count)
          end
        end

        context 'with a table that does not exist' do
          let(:until_table) { 'foobar' }

          it 'raises an error if the specified table does not exist' do
            expect do
              truncate_legacy_tables
            end.to raise_error(/The table 'foobar' is not within the truncated tables/)
          end
        end
      end

      context 'when one of the attached partitions happened to be locked for writes' do
        before do
          skip if connection.pool.db_config.name != 'ci'

          Gitlab::Database::LockWritesManager.new(
            table_name: "#{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}._test_gitlab_hook_logs_202202",
            connection: connection,
            database_name: connection.pool.db_config.name,
            with_retries: false
          ).lock_writes
        end

        it 'truncates the locked partition successfully' do
          expect do
            truncate_legacy_tables
          end.to change { ci_db_partitioned_item.count }.from(5).to(0)
        end
      end

      context 'with geo configured' do
        let(:geo_connection) { Gitlab::Database.database_base_models[:geo].connection }

        before do
          skip unless geo_configured?
          geo_connection.execute('CREATE TABLE _test_gitlab_geo_items (id serial NOT NULL PRIMARY KEY)')
          geo_connection.execute('INSERT INTO _test_gitlab_geo_items VALUES(generate_series(1, 50))')
        end

        it 'does not truncate gitlab_geo tables' do
          expect do
            truncate_legacy_tables
          end.not_to change { geo_connection.select_value("select count(*) from _test_gitlab_geo_items") }
        end
      end
    end
  end

  context 'when truncating gitlab_ci tables on the main database' do
    let(:connection) { ApplicationRecord.connection }
    let(:legacy_tables_models) { [main_db_ci_item_model, main_db_ci_reference_model] }
    let(:referencing_table_model) { main_db_ci_reference_model }
    let(:referenced_table_model) { main_db_ci_item_model }
    let(:other_tables_models) do
      [
        main_db_main_item_model, main_db_main_reference_model,
        ci_db_ci_item_model, ci_db_ci_reference_model,
        ci_db_main_item_model, ci_db_main_reference_model,
        main_db_shared_item_model, ci_db_shared_item_model
      ]
    end

    it_behaves_like 'truncating legacy tables on a database'
  end

  context 'when truncating gitlab_main tables on the ci database' do
    let(:connection) { Ci::ApplicationRecord.connection }
    let(:legacy_tables_models) do
      [ci_db_main_item_model, ci_db_main_reference_model, ci_db_partitioned_item, ci_db_partitioned_item_detached]
    end

    let(:referencing_table_model) { ci_db_main_reference_model }
    let(:referenced_table_model) { ci_db_main_item_model }
    let(:other_tables_models) do
      [
        main_db_main_item_model, main_db_main_reference_model,
        ci_db_ci_item_model, ci_db_ci_reference_model,
        main_db_ci_item_model, main_db_ci_reference_model,
        main_db_shared_item_model, ci_db_shared_item_model
      ]
    end

    it_behaves_like 'truncating legacy tables on a database'
  end

  context 'when running with multiple shared databases' do
    before do
      skip_if_multiple_databases_not_setup(:ci)
      skip_if_database_exists(:ci)
    end

    it 'raises an error when truncating the main database that it is a single database setup' do
      expect do
        described_class.new(database_name: 'main', min_batch_size: min_batch_size).execute
      end.to raise_error(/Cannot truncate legacy tables in single-db setup/)
    end

    it 'raises an error when truncating the ci database that it is a single database setup' do
      expect do
        described_class.new(database_name: 'ci', min_batch_size: min_batch_size).execute
      end.to raise_error(/Cannot truncate legacy tables in single-db setup/)
    end
  end

  context 'when running in a single database mode' do
    before do
      skip_if_multiple_databases_are_setup(:ci)
    end

    it 'raises an error when truncating the main database that it is a single database setup' do
      expect do
        described_class.new(database_name: 'main', min_batch_size: min_batch_size).execute
      end.to raise_error(/Cannot truncate legacy tables in single-db setup/)
    end

    it 'raises an error when truncating the ci database that it is a single database setup' do
      expect do
        described_class.new(database_name: 'ci', min_batch_size: min_batch_size).execute
      end.to raise_error(/Cannot truncate legacy tables in single-db setup/)
    end
  end

  describe '#needs_truncation?' do
    let(:database_name) { 'ci' }

    subject { described_class.new(database_name: database_name).needs_truncation? }

    context 'when running in a single database mode' do
      before do
        skip_if_multiple_databases_are_setup(:ci)
      end

      it { is_expected.to eq(false) }
    end

    context 'when running in a multiple database mode' do
      before do
        skip_if_shared_database(:ci)
      end

      context 'with main data in ci database' do
        it { is_expected.to eq(true) }
      end

      context 'with no main data in ci datatabase' do
        before do
          # Remove 'main' data in ci database
          ci_connection.execute(
            "TRUNCATE TABLE _test_gitlab_main_items, _test_gitlab_main_references RESTART IDENTITY CASCADE;"
          )
        end

        it { is_expected.to eq(false) }

        it 'supresses some QueryAnalyzers' do
          expect(
            Gitlab::Database::QueryAnalyzers::GitlabSchemasValidateConnection
          ).to receive(:with_suppressed).and_call_original
          expect(
            Gitlab::Database::QueryAnalyzers::Ci::PartitioningRoutingAnalyzer
          ).to receive(:with_suppressed).and_call_original

          subject
        end
      end
    end
  end

  def geo_configured?
    !!ActiveRecord::Base.configurations.configs_for(env_name: Rails.env, name: 'geo')
  end
end
