526 lines
20 KiB
Ruby
526 lines
20 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
#
|
|
# Copyright (C) 2012 - present Instructure, Inc.
|
|
#
|
|
# This file is part of Canvas.
|
|
#
|
|
# Canvas is free software: you can redistribute it and/or modify it under
|
|
# the terms of the GNU Affero General Public License as published by the Free
|
|
# Software Foundation, version 3 of the License.
|
|
#
|
|
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
|
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
|
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
|
# details.
|
|
#
|
|
# You should have received a copy of the GNU Affero General Public License along
|
|
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
module ActiveRecord
|
|
describe Base do
|
|
describe '.wildcard' do
|
|
it 'produces a useful wildcard sql string' do
|
|
sql = Base.wildcard('users.name', 'users.short_name', 'Sinatra, Frank', { :delimiter => ',' })
|
|
expect(sql).to eq "(LOWER(',' || users.name || ',') LIKE '%,sinatra, frank,%' OR LOWER(',' || users.short_name || ',') LIKE '%,sinatra, frank,%')"
|
|
end
|
|
end
|
|
|
|
describe '.wildcard_pattern' do
|
|
it 'downcases the query string' do
|
|
expect(Base.wildcard_pattern('SomeString')).to include('somestring')
|
|
end
|
|
|
|
it 'escapes special characters in the query' do
|
|
%w[% _].each do |char|
|
|
expect(Base.wildcard_pattern('some' + char + 'string')).to include('some\\' + char + 'string')
|
|
end
|
|
end
|
|
|
|
it 'bases modulos on either end of the query per the configured type' do
|
|
{ :full => '%somestring%', :left => '%somestring', :right => 'somestring%' }.each do |type, result|
|
|
expect(Base.wildcard_pattern('somestring', :type => type)).to eq result
|
|
end
|
|
end
|
|
end
|
|
|
|
describe ".coalesced_wildcard" do
|
|
it 'produces a useful wildcard string for a coalesced index' do
|
|
sql = Base.coalesced_wildcard('users.name', 'users.short_name', 'Sinatra, Frank')
|
|
expect(sql).to eq "((COALESCE(LOWER(users.name), '') || ' ' || COALESCE(LOWER(users.short_name), '')) LIKE '%sinatra, frank%')"
|
|
end
|
|
end
|
|
|
|
describe ".coalesce_chain" do
|
|
it "chains together many columns for combined matching" do
|
|
sql = Base.coalesce_chain(["foo.bar", "foo.baz", "foo.bang"])
|
|
expect(sql).to eq "(COALESCE(LOWER(foo.bar), '') || ' ' || COALESCE(LOWER(foo.baz), '') || ' ' || COALESCE(LOWER(foo.bang), ''))"
|
|
end
|
|
end
|
|
|
|
describe "find_in_batches" do
|
|
describe "with cursor" do
|
|
before do
|
|
skip "needs PostgreSQL" unless Account.connection.adapter_name == 'PostgreSQL'
|
|
end
|
|
|
|
it "iterates through all selected rows" do
|
|
users = Set.new
|
|
3.times { users << user_model }
|
|
found = Set.new
|
|
User.connection.cache { User.find_each(batch_size: 1) { |u| found << u } }
|
|
expect(found).to eq users
|
|
end
|
|
|
|
it "cleans up the cursor" do
|
|
# two cursors with the same name; if it didn't get cleaned up, it would error
|
|
expect do
|
|
User.all.find_each { nil }
|
|
User.all.find_each { nil } # rubocop:disable Style/CombinableLoops
|
|
end.to_not raise_error
|
|
end
|
|
|
|
it "cleans up the temp table for non-DB error" do
|
|
User.create!
|
|
# two temp tables with the same name; if it didn't get cleaned up, it would error
|
|
expect do
|
|
User.all.find_each do
|
|
raise ArgumentError
|
|
end
|
|
end.to raise_error(ArgumentError)
|
|
|
|
User.all.find_each { nil }
|
|
end
|
|
|
|
it "doesnt obfuscate the error when it dies in a transaction" do
|
|
account = Account.create!
|
|
account.courses.create!
|
|
User.create!
|
|
expect do
|
|
ActiveRecord::Base.transaction do
|
|
User.all.find_each do
|
|
# to force a foreign key error
|
|
Account.where(id: account).delete_all
|
|
end
|
|
end
|
|
end.to raise_error(ActiveRecord::InvalidForeignKey)
|
|
end
|
|
end
|
|
|
|
describe "with temp table" do
|
|
around do |example|
|
|
ActiveRecord::Base.in_migration = true
|
|
example.run
|
|
ensure
|
|
ActiveRecord::Base.in_migration = false
|
|
end
|
|
|
|
it "uses a temp table when you select without an id" do
|
|
expect do
|
|
User.create!
|
|
User.select(:name).find_in_batches do
|
|
User.connection.select_value("SELECT COUNT(*) FROM users_in_batches_temp_table_#{User.select(:name).to_sql.hash.abs.to_s(36)}")
|
|
end
|
|
end.to_not raise_error
|
|
end
|
|
|
|
it "does not use a temp table for a plain query" do
|
|
User.create!
|
|
User.find_in_batches do
|
|
expect { User.connection.select_value("SELECT COUNT(*) FROM users_in_batches_temp_table_#{User.all.to_sql.hash.abs.to_s(36)}") }.to raise_error(ActiveRecord::StatementInvalid)
|
|
end
|
|
end
|
|
|
|
it "does not use a temp table for a select with id" do
|
|
User.create!
|
|
User.select(:id).find_in_batches do
|
|
expect { User.connection.select_value("SELECT COUNT(*) FROM users_in_batches_temp_table_#{User.select(:id).to_sql.hash.abs.to_s(36)}") }.to raise_error(ActiveRecord::StatementInvalid)
|
|
end
|
|
end
|
|
|
|
it 'does not bomb when you try to force past the cursor option on selects with the primary key' do
|
|
selectors = ["*", "users.*", "users.id, users.updated_at"]
|
|
User.create!
|
|
selectors.each do |selector|
|
|
expect do
|
|
User.select(selector).find_in_batches(strategy: :id) { nil }
|
|
end.not_to raise_error
|
|
end
|
|
end
|
|
|
|
it "cleans up the temp table" do
|
|
# two temp tables with the same name; if it didn't get cleaned up, it would error
|
|
expect do
|
|
User.all.find_in_batches(strategy: :temp_table) { nil }
|
|
User.all.find_in_batches(strategy: :temp_table) { nil }
|
|
end.to_not raise_error
|
|
end
|
|
|
|
it "cleans up the temp table for non-DB error" do
|
|
User.create!
|
|
# two temp tables with the same name; if it didn't get cleaned up, it would error
|
|
expect do
|
|
User.all.find_in_batches(strategy: :temp_table) do
|
|
raise ArgumentError
|
|
end
|
|
end.to raise_error(ArgumentError)
|
|
|
|
User.all.find_in_batches(strategy: :temp_table) { nil }
|
|
end
|
|
|
|
it "does not die with index error when table size is exactly batch size" do
|
|
user_count = 10
|
|
User.delete_all
|
|
user_count.times { user_model }
|
|
expect(User.count).to eq(user_count)
|
|
User.all.find_in_batches(strategy: :temp_table, batch_size: user_count) { nil }
|
|
end
|
|
|
|
it "doesnt obfuscate the error when it dies in a transaction" do
|
|
account = Account.create!
|
|
account.courses.create!
|
|
User.create!
|
|
expect do
|
|
ActiveRecord::Base.transaction do
|
|
User.all.find_in_batches(strategy: :temp_table) do
|
|
# to force a foreign key error
|
|
Account.where(id: account).delete_all
|
|
end
|
|
end
|
|
end.to raise_error(ActiveRecord::InvalidForeignKey)
|
|
end
|
|
end
|
|
|
|
describe "with id plucking" do
|
|
it "iterates through all selected rows" do
|
|
users = Set.new
|
|
3.times { users << user_model }
|
|
found = Set.new
|
|
User.find_in_batches(strategy: :pluck_ids, batch_size: 1) do |u_batch|
|
|
u_batch.each { |u| found << u }
|
|
end
|
|
expect(found).to eq users
|
|
end
|
|
|
|
it "keeps the specified order" do
|
|
%w[user_F user_D user_A user_C user_B user_E].map { |name| user_model(name: name) }
|
|
names = []
|
|
User.order(:name).find_in_batches(strategy: :pluck_ids, batch_size: 3) do |u_batch|
|
|
names += u_batch.map(&:name)
|
|
end
|
|
expect(names).to eq(%w[user_A user_B user_C user_D user_E user_F])
|
|
end
|
|
end
|
|
end
|
|
|
|
describe ".bulk_insert" do
|
|
it "throws exception if it violates a foreign key" do
|
|
attrs = {
|
|
'request_id' => 'abcde-12345',
|
|
'uuid' => 'edcba-54321',
|
|
'account_id' => Account.default.id,
|
|
'user_id' => -1,
|
|
'pseudonym_id' => -1,
|
|
'event_type' => 'login',
|
|
'created_at' => DateTime.now.utc
|
|
}
|
|
expect do
|
|
Auditors::ActiveRecord::AuthenticationRecord.bulk_insert([attrs])
|
|
end.to raise_error(ActiveRecord::InvalidForeignKey)
|
|
end
|
|
|
|
it "writes to the correct partition" do
|
|
user = user_with_pseudonym(active_user: true)
|
|
pseud = @pseudonym
|
|
attrs_1 = {
|
|
'request_id' => 'abcde-12345',
|
|
'uuid' => 'edcba-54321',
|
|
'account_id' => Account.default.id,
|
|
'user_id' => user.id,
|
|
'pseudonym_id' => pseud.id,
|
|
'event_type' => 'login',
|
|
'created_at' => DateTime.now.utc
|
|
}
|
|
attrs_2 = attrs_1.merge({
|
|
'created_at' => 40.days.ago
|
|
})
|
|
ar_type = Auditors::ActiveRecord::AuthenticationRecord
|
|
expect { ar_type.bulk_insert([attrs_1, attrs_2]) }.to_not raise_error
|
|
conn = ar_type.connection
|
|
root_partition_count = conn.execute("select count(*) from only #{ar_type.quoted_table_name};")[0]["count"]
|
|
expect(root_partition_count).to eq(0)
|
|
expect(ar_type.count).to eq(2)
|
|
now_partition_name = conn.quote_table_name(ar_type.infer_partition_table_name(attrs_1))
|
|
now_partition_count = conn.execute("select count(*) from #{now_partition_name};")[0]["count"]
|
|
expect(now_partition_count).to eq(1)
|
|
prev_partition_name = conn.quote_table_name(ar_type.infer_partition_table_name(attrs_2))
|
|
prev_partition_count = conn.execute("select count(*) from #{prev_partition_name};")[0]["count"]
|
|
expect(prev_partition_count).to eq(1)
|
|
end
|
|
end
|
|
|
|
describe "deconstruct_joins" do
|
|
describe "delete_all" do
|
|
it "allows delete all on inner join with alias" do
|
|
User.create(name: 'dr who')
|
|
User.create(name: 'dr who')
|
|
|
|
expect do
|
|
User.joins("INNER JOIN #{User.quoted_table_name} u ON users.sortable_name = u.sortable_name")
|
|
.where("u.sortable_name <> users.sortable_name").delete_all
|
|
end.to_not raise_error
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "update_all with limit" do
|
|
it "does the right thing with a join and a limit" do
|
|
u1 = User.create!(name: 'u1')
|
|
e1 = u1.eportfolios.create!(name: 'e1')
|
|
u2 = User.create!(name: 'u2')
|
|
e2 = u2.eportfolios.create!(name: 'e2')
|
|
Eportfolio.joins(:user).order(:id).limit(1).update_all(name: 'changed')
|
|
expect(e1.reload.name).to eq 'changed'
|
|
expect(e2.reload.name).not_to eq 'changed'
|
|
end
|
|
end
|
|
|
|
describe ".parse_asset_string" do
|
|
it "parses simple asset strings" do
|
|
expect(ActiveRecord::Base.parse_asset_string("course_123")).to eql(["Course", 123])
|
|
end
|
|
|
|
it "parses asset strings with multi-word class names" do
|
|
expect(ActiveRecord::Base.parse_asset_string("content_tag_456")).to eql(["ContentTag", 456])
|
|
end
|
|
|
|
it "parses namespaced asset strings" do
|
|
expect(ActiveRecord::Base.parse_asset_string("quizzes:quiz_789")).to eql(["Quizzes::Quiz", 789])
|
|
end
|
|
|
|
it "classifies the class name but leaves plurals in the namespaces alone" do
|
|
expect(ActiveRecord::Base.parse_asset_string("content_tags:content_tags_0")).to eql(["ContentTags::ContentTag", 0])
|
|
end
|
|
|
|
it "behaves predictably on an invalid asset string" do
|
|
expect(ActiveRecord::Base.parse_asset_string("what")).to eql(["", 0])
|
|
end
|
|
end
|
|
|
|
describe ".parse_asset_string_list" do
|
|
it "parses to a hash" do
|
|
expect(ActiveRecord::Base.parse_asset_string_list("course_1,course_2,user_3"))
|
|
.to eq({ 'Course' => [1, 2], 'User' => [3] })
|
|
end
|
|
|
|
it "accepts an array" do
|
|
expect(ActiveRecord::Base.parse_asset_string_list(%w[course_1 course_2 user_3]))
|
|
.to eq({ 'Course' => [1, 2], 'User' => [3] })
|
|
end
|
|
end
|
|
|
|
describe ".find_all_by_asset_string" do
|
|
let_once(:course) { course_factory }
|
|
let_once(:user) { user_factory }
|
|
|
|
it "works" do
|
|
expect(ActiveRecord::Base.find_all_by_asset_string([course.asset_string, user.asset_string]))
|
|
.to eq [course, user]
|
|
end
|
|
|
|
it "accepts a pre-parsed hash" do
|
|
expect(ActiveRecord::Base.find_all_by_asset_string('Course' => [course.id], 'User' => [user.id]))
|
|
.to eq [course, user]
|
|
end
|
|
|
|
it "ignores unnamed asset types" do
|
|
expect(ActiveRecord::Base.find_all_by_asset_string([course.asset_string, user.asset_string], ['User', 'Group']))
|
|
.to eq [user]
|
|
end
|
|
end
|
|
end
|
|
|
|
describe ".asset_string" do
|
|
it "generates a string with the reflection_type_name and id" do
|
|
expect(User.asset_string(3)).to eq('user_3')
|
|
end
|
|
end
|
|
|
|
describe Relation do
|
|
describe "lock_with_exclusive_smarts" do
|
|
let(:scope) { User.active }
|
|
|
|
it "uses FOR UPDATE on a normal exclusive lock" do
|
|
expect(scope.lock(true).lock_value).to eq true
|
|
end
|
|
|
|
it "substitutes 'FOR NO KEY UPDATE' if specified" do
|
|
expect(scope.lock(:no_key_update).lock_value).to eq "FOR NO KEY UPDATE"
|
|
end
|
|
end
|
|
|
|
describe "union" do
|
|
shared_examples_for "query creation" do
|
|
it "includes conditions after the union inside of the subquery" do
|
|
scope = base.active.where(id: 99).union(User.where(id: 1))
|
|
wheres = scope.where_clause.send(:predicates)
|
|
expect(wheres.count).to eq 1
|
|
sql_before_union, sql_after_union = wheres.first.split("UNION ALL")
|
|
expect(sql_before_union).to include('"id" = 99')
|
|
expect(sql_after_union).not_to include('"id" = 99')
|
|
end
|
|
|
|
it "includes conditions prior to the union outside of the subquery" do
|
|
scope = base.active.union(User.where(id: 1)).where(id: 99)
|
|
wheres = scope.where_clause.send(:predicates)
|
|
expect(wheres.count).to eq 2
|
|
union_where = wheres.detect { |w| w.is_a?(String) && w.include?("UNION ALL") }
|
|
expect(union_where).not_to include('"id" = 99')
|
|
end
|
|
|
|
it "ignores null scopes" do
|
|
s1 = Assignment.all
|
|
s2 = Assignment.all.none
|
|
expect(s1.union(s2)).to be s1
|
|
end
|
|
|
|
it "just returns self if everything is null scope" do
|
|
s1 = Assignment.all.none
|
|
s2 = Assignment.all.none
|
|
expect(s1).not_to be s2
|
|
expect(s1.union(s2)).to be s1
|
|
end
|
|
|
|
it "serializes to valid SQL with selects, limits, and orders" do
|
|
s = Assignment.select(:updated_at).order(updated_at: :desc).limit(1)
|
|
s.union(s)
|
|
end
|
|
end
|
|
|
|
context "directly on the table" do
|
|
let(:base) { User.active }
|
|
|
|
include_examples "query creation"
|
|
end
|
|
|
|
context "through a relation" do
|
|
let(:base) { Account.create.users }
|
|
|
|
include_examples "query creation"
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'ConnectionAdapters' do
|
|
describe 'SchemaStatements' do
|
|
it 'finds the name of a foreign key on the default column' do
|
|
fk_name = ActiveRecord::Migration.find_foreign_key(:enrollments, :users)
|
|
expect(fk_name).to eq('fk_rails_e860e0e46b')
|
|
end
|
|
|
|
it 'finds the name of a foreign key on a specific column' do
|
|
fk_name = ActiveRecord::Migration.find_foreign_key(:accounts, :outcome_imports,
|
|
column: 'latest_outcome_import_id')
|
|
expect(fk_name).to eq('fk_rails_3f0c8923c0')
|
|
end
|
|
|
|
it 'does not find a foreign key if there is not one' do
|
|
fk_name = ActiveRecord::Migration.find_foreign_key(:users, :courses)
|
|
other_fk_name = ActiveRecord::Migration.find_foreign_key(:users, :users)
|
|
expect(fk_name).to be_nil
|
|
expect(other_fk_name).to be_nil
|
|
end
|
|
|
|
it 'does not find a foreign key on a column that is not one' do
|
|
fk_name = ActiveRecord::Migration.find_foreign_key(:users, :pseudonyms, column: 'time_zone')
|
|
expect(fk_name).to be_nil
|
|
end
|
|
|
|
it 'does not crash on a non-existant column' do
|
|
fk_name = ActiveRecord::Migration.find_foreign_key(:users, :pseudonyms, column: 'notacolumn')
|
|
expect(fk_name).to be_nil
|
|
end
|
|
|
|
it 'does not crash on a non-existant table' do
|
|
fk_name = ActiveRecord::Migration.find_foreign_key(:notatable, :users)
|
|
other_fk_name = ActiveRecord::Migration.find_foreign_key(:users, :notatable)
|
|
expect(fk_name).to be_nil
|
|
expect(other_fk_name).to be_nil
|
|
end
|
|
|
|
it 'actually renames foreign keys' do
|
|
old_name = User.connection.find_foreign_key(:user_services, :users)
|
|
User.connection.alter_constraint(:user_services, old_name, new_name: 'test')
|
|
expect(User.connection.find_foreign_key(:user_services, :users)).to eq 'test'
|
|
end
|
|
|
|
it "allows if_not_exists on add_index" do
|
|
expect { User.connection.add_index(:enrollments, :user_id, if_not_exists: true) }.not_to raise_exception
|
|
end
|
|
|
|
it "allows if_not_exists on add_column" do
|
|
expect { User.connection.add_column(:enrollments, :user_id, :bigint, if_not_exists: true) }.not_to raise_exception
|
|
end
|
|
|
|
it "allows if_not_exists on add_foreign_key" do
|
|
expect { User.connection.add_foreign_key(:enrollments, :users, if_not_exists: true) }.not_to raise_exception
|
|
end
|
|
|
|
it "add_foreign_key automatically validates an invalid constraint with delay_validation" do
|
|
expect do
|
|
User.connection.remove_foreign_key(:enrollments, column: :user_id)
|
|
User.connection.add_foreign_key(:enrollments, :users, validate: false)
|
|
# so that delay_validation doesn't get ignored
|
|
allow(User.connection).to receive(:open_transactions).and_return(0)
|
|
User.connection.add_foreign_key(:enrollments, :users, delay_validation: true)
|
|
end.not_to raise_exception
|
|
end
|
|
|
|
it "remove_foreign_key allows if_exists" do
|
|
expect { User.connection.remove_foreign_key(:discussion_topics, :conversations, if_exists: true) }.not_to raise_exception
|
|
end
|
|
|
|
it "remove_foreign_key allows column and if_exists" do
|
|
expect { User.connection.remove_foreign_key(:enrollments, column: :associated_user_id, if_exists: true) }.not_to raise_exception
|
|
end
|
|
|
|
it "foreign_key_for prefers a 'bare' FK first" do
|
|
expect(User.connection.foreign_key_for(:enrollments, to_table: :users).column).to eq 'user_id'
|
|
end
|
|
|
|
it "remove_index allows if_exists" do
|
|
expect { User.connection.remove_index(:users, column: :non_existent, if_exists: true) }.not_to raise_exception
|
|
end
|
|
|
|
it "remove_index by name allows if_exists" do
|
|
expect { User.connection.remove_index(:users, name: :lti_id, if_exists: true) }.not_to raise_exception
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
describe ActiveRecord::Migration::CommandRecorder do
|
|
it "reverses if_exists/if_not_exists" do
|
|
recorder = ActiveRecord::Migration::CommandRecorder.new
|
|
r = recorder
|
|
recorder.revert do
|
|
r.add_column :accounts, :course_template_id, :integer, limit: 8, if_not_exists: true
|
|
r.add_foreign_key :accounts, :courses, column: :course_template_id, if_not_exists: true
|
|
r.add_index :accounts, :course_template_id, algorithm: :concurrently, if_not_exists: true
|
|
|
|
r.remove_column :courses, :id, :integer, limit: 8, if_exists: true
|
|
r.remove_foreign_key :enrollments, :users, if_exists: true
|
|
r.remove_index :accounts, :id, if_exists: true
|
|
end
|
|
expect(recorder.commands).to eq([
|
|
[:add_index, [:accounts, :id, { if_not_exists: true }]],
|
|
[:add_foreign_key, [:enrollments, :users, { if_not_exists: true }]],
|
|
[:add_column, [:courses, :id, :integer, { limit: 8, if_not_exists: true }], nil],
|
|
|
|
[:remove_index, [:accounts, { column: :course_template_id, algorithm: :concurrently, if_exists: true }]],
|
|
[:remove_foreign_key, [:accounts, :courses, { column: :course_template_id, if_exists: true }], nil],
|
|
[:remove_column, [:accounts, :course_template_id, :integer, { limit: 8, if_exists: true }], nil],
|
|
])
|
|
end
|
|
end
|