Skip to content

Optional identity columns #2201

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Original file line number Diff line number Diff line change
@@ -200,56 +200,59 @@ def columns(table_name)
# end

def create_table(table_name, id: :primary_key, primary_key: nil, force: nil, **options)
create_sequence = id != false
td = create_table_definition(
table_name, **options.extract!(:temporary, :options, :as, :comment, :tablespace, :organization)
)
OracleEnhancedAdapter.using_identity(options[:primary_key_as_identity]) do
create_sequence = id != false
td = create_table_definition(
table_name, **options.extract!(:temporary, :options, :as, :comment, :tablespace, :organization)
)

if id && !td.as
pk = primary_key || Base.get_primary_key(table_name.to_s.singularize)
if id && !td.as
pk = primary_key || Base.get_primary_key(table_name.to_s.singularize)

if pk.is_a?(Array)
td.primary_keys pk
else
td.primary_key pk, id, **options
if pk.is_a?(Array)
td.primary_keys pk
else
td.primary_key pk, id, **options
end
end
end

# store that primary key was defined in create_table block
unless create_sequence
class << td
attr_accessor :create_sequence
def primary_key(*args)
self.create_sequence = true
super(*args)
# store that primary key was defined in create_table block
unless create_sequence
class << td
attr_accessor :create_sequence
def primary_key(name, type = :primary_key, **options)
self.create_sequence = true

super(name, type, **options)
end
end
end
end

yield td if block_given?
create_sequence = create_sequence || td.create_sequence
yield td if block_given?
create_sequence = create_sequence || td.create_sequence

if force && data_source_exists?(table_name)
drop_table(table_name, force: force, if_exists: true)
else
schema_cache.clear_data_source_cache!(table_name.to_s)
end
if force && data_source_exists?(table_name)
drop_table(table_name, force: force, if_exists: true)
else
schema_cache.clear_data_source_cache!(table_name.to_s)
end

execute schema_creation.accept td
execute schema_creation.accept td

create_sequence_and_trigger(table_name, options) if create_sequence
create_sequence_and_trigger(table_name, options) if create_sequence

if supports_comments? && !supports_comments_in_create?
if table_comment = td.comment.presence
change_table_comment(table_name, table_comment)
end
td.columns.each do |column|
change_column_comment(table_name, column.name, column.comment) if column.comment.present?
if supports_comments? && !supports_comments_in_create?
if table_comment = td.comment.presence
change_table_comment(table_name, table_comment)
end
td.columns.each do |column|
change_column_comment(table_name, column.name, column.comment) if column.comment.present?
end
end
end
td.indexes.each { |c, o| add_index table_name, c, **o }
td.indexes.each { |c, o| add_index table_name, c, **o }

rebuild_primary_key_index_to_default_tablespace(table_name, options)
rebuild_primary_key_index_to_default_tablespace(table_name, options)
end
end

def rename_table(table_name, new_name) # :nodoc:
@@ -413,14 +416,16 @@ def add_reference(table_name, ref_name, **options)
end

def add_column(table_name, column_name, type, **options) # :nodoc:
type = aliased_types(type.to_s, type)
at = create_alter_table table_name
at.add_column(column_name, type, **options)
add_column_sql = schema_creation.accept at
add_column_sql << tablespace_for((type_to_sql(type).downcase.to_sym), nil, table_name, column_name)
execute add_column_sql
create_sequence_and_trigger(table_name, options) if type && type.to_sym == :primary_key
change_column_comment(table_name, column_name, options[:comment]) if options.key?(:comment)
OracleEnhancedAdapter.using_identity(options[:identity]) do
type = aliased_types(type.to_s, type)
at = create_alter_table table_name
at.add_column(column_name, type, **options)
add_column_sql = schema_creation.accept at
add_column_sql << tablespace_for((type_to_sql(type).downcase.to_sym), nil, table_name, column_name)
execute add_column_sql
create_sequence_and_trigger(table_name, options) if type && type.to_sym == :primary_key
change_column_comment(table_name, column_name, options[:comment]) if options.key?(:comment)
end
ensure
clear_table_columns_cache(table_name)
end
@@ -535,11 +540,13 @@ def column_comment(table_name, column_name) # :nodoc:
end

# Maps logical Rails types to Oracle-specific data types.
def type_to_sql(type, limit: nil, precision: nil, scale: nil, **) # :nodoc:
# Ignore options for :text, :ntext and :binary columns
return super(type) if ["text", "ntext", "binary"].include?(type.to_s)
def type_to_sql(type, limit: nil, precision: nil, scale: nil, identity: nil, **) # :nodoc:
OracleEnhancedAdapter.using_identity(identity) do
# Ignore options for :text, :ntext and :binary columns
return super(type) if ["text", "ntext", "binary"].include?(type.to_s)

super
super
end
end

def tablespace(table_name)
@@ -702,6 +709,8 @@ def column_for(table_name, column_name)
def create_sequence_and_trigger(table_name, options)
# TODO: Needs rename since no triggers created
# This method will be removed since sequence will not be created separately
return if OracleEnhancedAdapter.use_identity_for_pk

seq_name = options[:sequence_name] || default_sequence_name(table_name)
seq_start_value = options[:sequence_start_value] || default_sequence_start_value
execute "CREATE SEQUENCE #{quote_table_name(seq_name)} START WITH #{seq_start_value}"
32 changes: 29 additions & 3 deletions lib/active_record/connection_adapters/oracle_enhanced_adapter.rb
Original file line number Diff line number Diff line change
@@ -229,6 +229,14 @@ class OracleEnhancedAdapter < AbstractAdapter
cattr_accessor :permissions
self.permissions = ["unlimited tablespace", "create session", "create table", "create view", "create sequence"]

##
# :singleton-method:
# To generate primary key columns using IDENTITY:
#
# ActiveRecord::ConnectionAdapters::OracleEnhancedAdapter.use_identity_for_pk = true
cattr_accessor :use_identity_for_pk
self.use_identity_for_pk = false

##
# :singleton-method:
# Specify default sequence start with value (by default 1 if not explicitly set), e.g.:
@@ -409,10 +417,17 @@ def supports_longer_identifier?
NATIVE_DATABASE_TYPES_BOOLEAN_STRINGS = NATIVE_DATABASE_TYPES.dup.merge(
boolean: { name: "VARCHAR2", limit: 1 }
)
# if use_identity_for_pk then generate primary key as IDENTITY
NATIVE_DATABASE_TYPES_IDENTITY_PK = {
primary_key: "NUMBER(38) GENERATED BY DEFAULT ON NULL AS IDENTITY NOT NULL PRIMARY KEY"
}
# :startdoc:

def native_database_types # :nodoc:
emulate_booleans_from_strings ? NATIVE_DATABASE_TYPES_BOOLEAN_STRINGS : NATIVE_DATABASE_TYPES
types = emulate_booleans_from_strings ? NATIVE_DATABASE_TYPES_BOOLEAN_STRINGS : NATIVE_DATABASE_TYPES
types = types.merge(NATIVE_DATABASE_TYPES_IDENTITY_PK) if use_identity_for_pk

types
end

# CONNECTION MANAGEMENT ====================================
@@ -476,14 +491,25 @@ def discard!
# called directly; used by ActiveRecord to get the next primary key value
# when inserting a new database record (see #prefetch_primary_key?).
def next_sequence_value(sequence_name)
# if sequence_name is set to :autogenerated then it means that primary key will be populated by trigger
raise ArgumentError.new "Trigger based primary key is not supported" if sequence_name == AUTOGENERATED_SEQUENCE_NAME
# if sequence_name is set to :autogenerated it means that primary key will be populated by an identity sequence
return nil if sequence_name == AUTOGENERATED_SEQUENCE_NAME

# call directly connection method to avoid prepared statement which causes fetching of next sequence value twice
select_value(<<~SQL.squish, "SCHEMA")
SELECT #{quote_table_name(sequence_name)}.NEXTVAL FROM dual
SQL
end

# Helper method for temporarily changing the value of OracleEnhancedAdapter.use_identity_for_pk (e.g., for a
# single create_table block)
def self.using_identity(value = nil, &block)
previous_value = self.use_identity_for_pk
self.use_identity_for_pk = value.nil? ? self.use_identity_for_pk : value
yield
ensure
self.use_identity_for_pk = previous_value
end

# Returns true for Oracle adapter (since Oracle requires primary key
# values to be pre-fetched before insert). See also #next_sequence_value.
def prefetch_primary_key?(table_name = nil)
Original file line number Diff line number Diff line change
@@ -340,52 +340,52 @@ class ::TestEmployee < ActiveRecord::Base; end
end

describe "rename index" do
before(:each) do
@conn = ActiveRecord::Base.connection
schema_define do
create_table :test_employees do |t|
t.string :first_name
t.string :last_name
before(:each) do
@conn = ActiveRecord::Base.connection
schema_define do
create_table :test_employees do |t|
t.string :first_name
t.string :last_name
end
add_index :test_employees, :first_name
end
add_index :test_employees, :first_name
class ::TestEmployee < ActiveRecord::Base; end
end
class ::TestEmployee < ActiveRecord::Base; end
end

after(:each) do
schema_define do
drop_table :test_employees
after(:each) do
schema_define do
drop_table :test_employees
end
Object.send(:remove_const, "TestEmployee")
ActiveRecord::Base.clear_cache!
end
Object.send(:remove_const, "TestEmployee")
ActiveRecord::Base.clear_cache!
end

it "should raise error when current index name and new index name are identical" do
expect do
@conn.rename_index("test_employees", "i_test_employees_first_name", "i_test_employees_first_name")
end.to raise_error(ActiveRecord::StatementInvalid)
end
it "should raise error when current index name and new index name are identical" do
expect do
@conn.rename_index("test_employees", "i_test_employees_first_name", "i_test_employees_first_name")
end.to raise_error(ActiveRecord::StatementInvalid)
end

it "should raise error when new index name length is too long" do
skip if @oracle12cr2_or_higher
expect do
@conn.rename_index("test_employees", "i_test_employees_first_name", "a" * 31)
end.to raise_error(ArgumentError)
end
it "should raise error when new index name length is too long" do
skip if @oracle12cr2_or_higher
expect do
@conn.rename_index("test_employees", "i_test_employees_first_name", "a" * 31)
end.to raise_error(ArgumentError)
end

it "should raise error when current index name does not exist" do
expect do
@conn.rename_index("test_employees", "nonexist_index_name", "new_index_name")
end.to raise_error(ActiveRecord::StatementInvalid)
end
it "should raise error when current index name does not exist" do
expect do
@conn.rename_index("test_employees", "nonexist_index_name", "new_index_name")
end.to raise_error(ActiveRecord::StatementInvalid)
end

it "should rename index name with new one" do
skip if @oracle12cr2_or_higher
expect do
@conn.rename_index("test_employees", "i_test_employees_first_name", "new_index_name")
end.not_to raise_error
it "should rename index name with new one" do
skip if @oracle12cr2_or_higher
expect do
@conn.rename_index("test_employees", "i_test_employees_first_name", "new_index_name")
end.not_to raise_error
end
end
end

describe "ignore options for LOB columns" do
after(:each) do
@@ -622,9 +622,9 @@ class ::TestPost < ActiveRecord::Base

index_name = @conn.select_value(
"SELECT index_name FROM all_constraints
WHERE table_name = 'TEST_POSTS'
AND constraint_type = 'P'
AND owner = SYS_CONTEXT('userenv', 'current_schema')")
WHERE table_name = 'TEST_POSTS'
AND constraint_type = 'P'
AND owner = SYS_CONTEXT('userenv', 'current_schema')")

expect(TestPost.connection.select_value("SELECT tablespace_name FROM user_indexes WHERE index_name = '#{index_name}'")).to eq("USERS")
end
@@ -637,9 +637,9 @@ class ::TestPost < ActiveRecord::Base

index_name = @conn.select_value(
"SELECT index_name FROM all_constraints
WHERE table_name = 'TEST_POSTS'
AND constraint_type = 'P'
AND owner = SYS_CONTEXT('userenv', 'current_schema')")
WHERE table_name = 'TEST_POSTS'
AND constraint_type = 'P'
AND owner = SYS_CONTEXT('userenv', 'current_schema')")

expect(TestPost.connection.select_value("SELECT tablespace_name FROM user_indexes WHERE index_name = '#{index_name}'")).to eq(DATABASE_NON_DEFAULT_TABLESPACE)
end
@@ -1046,7 +1046,7 @@ class ::TestFraction < ActiveRecord::Base
it "should change virtual column definition" do
schema_define do
change_column :test_fractions, :percent, :virtual,
as: "ROUND((numerator/NULLIF(denominator,0))*100, 2)", type: :decimal, precision: 15, scale: 2
as: "ROUND((numerator/NULLIF(denominator,0))*100, 2)", type: :decimal, precision: 15, scale: 2
end
TestFraction.reset_column_information
tf = TestFraction.columns.detect { |c| c.name == "percent" }
@@ -1249,4 +1249,67 @@ class << @conn
ActiveRecord::SchemaMigration.drop_table
end
end

describe "identity columns" do
before(:all) do
@conn = ActiveRecord::Base.connection
schema_define do
end
end

before(:each) do
@conn.instance_variable_set :@would_execute_sql, @would_execute_sql = +""
class << @conn
def execute(sql, name = nil); @would_execute_sql << sql << ";\n"; end
end
end

after(:each) do
class << @conn
remove_method :execute
end
@conn.instance_eval { remove_instance_variable :@would_execute_sql }
end

it "should create an identity column when primary_key_as_identity: true is used" do
schema_define do
create_table :identity_test_table, primary_key_as_identity: true
end
expect(@would_execute_sql).to match(/CREATE +TABLE .* GENERATED BY DEFAULT ON NULL AS IDENTITY NOT NULL PRIMARY KEY/)
end

it 'should create an identity column when identity: true is used with t.primary_key' do
schema_define do
create_table :identity_test_table, id: false do |t|
t.primary_key :test, :primary_key, identity: true
end
end
expect(@would_execute_sql).to match(/CREATE +TABLE .* GENERATED BY DEFAULT ON NULL AS IDENTITY NOT NULL PRIMARY KEY/)
end

it 'should create an identity column when identity: true is used with add_column' do
schema_define do
add_column :identity_test_table, :test_id, :primary_key, identity: true
end
expect(@would_execute_sql).to match(/ALTER +TABLE .* GENERATED BY DEFAULT ON NULL AS IDENTITY NOT NULL PRIMARY KEY/)
end

it 'should create an identity column when primary_key_as_identity: true is used with a custom primary_key column' do
schema_define do
create_table :identity_test_table, id: false, primary_key_as_identity: true do |t|
t.primary_key :test, :primary_key
end
end
expect(@would_execute_sql).to match(/CREATE +TABLE .* GENERATED BY DEFAULT ON NULL AS IDENTITY NOT NULL PRIMARY KEY/)
end

it 'should create an identity column with primary_key_as_identity: false and identity: true' do
schema_define do
create_table :identity_test_table, id: false, primary_key_as_identity: false do |t|
t.primary_key :test, :primary_key, identity: true
end
end
expect(@would_execute_sql).to match(/CREATE +TABLE .* GENERATED BY DEFAULT ON NULL AS IDENTITY NOT NULL PRIMARY KEY/)
end
end
end