Skip to content

Commit d3c99af

Browse files
committed
feat: Omit Columns from SQL statements (e.g uuid columns)
I've added two options for omitting columns from the SQL statements, to avoid having to modify class level `self.ignored_columns` since we've had issues with this affecting other code that runs at the same time. These options work as follows: `Model.import(values, omit_columns: [:guid])` # Omit the guid column from SQL statement, allowing it to generate `Model.import(values, omit_columns: -> (model, column_name) { [:guid] if model == Model })` Allow per-model decisions, e.g for recursive imports `Model.import(values, omit_columns: { Model => [:guid] })` Use a hash instead of a proc The second option is `:omit_columns_with_default_functions` boolean, to automatically find columns that have a default function declared in the schema, and omit them by default. fix: Require AR 5.0+
1 parent f541f2d commit d3c99af

File tree

5 files changed

+313
-26
lines changed

5 files changed

+313
-26
lines changed

README.markdown

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -30,31 +30,36 @@ The gem provides the following high-level features:
3030

3131
## Table of Contents
3232

33-
* [Examples](#examples)
34-
* [Introduction](#introduction)
35-
* [Columns and Arrays](#columns-and-arrays)
36-
* [Hashes](#hashes)
37-
* [ActiveRecord Models](#activerecord-models)
38-
* [Batching](#batching)
39-
* [Recursive](#recursive)
40-
* [Options](#options)
41-
* [Duplicate Key Ignore](#duplicate-key-ignore)
42-
* [Duplicate Key Update](#duplicate-key-update)
43-
* [Return Info](#return-info)
44-
* [Counter Cache](#counter-cache)
45-
* [ActiveRecord Timestamps](#activerecord-timestamps)
46-
* [Callbacks](#callbacks)
47-
* [Supported Adapters](#supported-adapters)
48-
* [Additional Adapters](#additional-adapters)
49-
* [Requiring](#requiring)
50-
* [Autoloading via Bundler](#autoloading-via-bundler)
51-
* [Manually Loading](#manually-loading)
52-
* [Load Path Setup](#load-path-setup)
53-
* [Conflicts With Other Gems](#conflicts-with-other-gems)
54-
* [More Information](#more-information)
55-
* [Contributing](#contributing)
56-
* [Running Tests](#running-tests)
57-
* [Issue Triage](#issue-triage)
33+
- [Activerecord-Import ](#activerecord-import-)
34+
- [Table of Contents](#table-of-contents)
35+
- [Examples](#examples)
36+
- [Introduction](#introduction)
37+
- [Columns and Arrays](#columns-and-arrays)
38+
- [Hashes](#hashes)
39+
- [Import Using Hashes and Explicit Column Names](#import-using-hashes-and-explicit-column-names)
40+
- [ActiveRecord Models](#activerecord-models)
41+
- [Batching](#batching)
42+
- [Recursive](#recursive)
43+
- [Options](#options)
44+
- [Duplicate Key Ignore](#duplicate-key-ignore)
45+
- [Duplicate Key Update](#duplicate-key-update)
46+
- [Return Info](#return-info)
47+
- [Counter Cache](#counter-cache)
48+
- [ActiveRecord Timestamps](#activerecord-timestamps)
49+
- [Callbacks](#callbacks)
50+
- [Supported Adapters](#supported-adapters)
51+
- [Additional Adapters](#additional-adapters)
52+
- [Requiring](#requiring)
53+
- [Autoloading via Bundler](#autoloading-via-bundler)
54+
- [Manually Loading](#manually-loading)
55+
- [Load Path Setup](#load-path-setup)
56+
- [Conflicts With Other Gems](#conflicts-with-other-gems)
57+
- [More Information](#more-information)
58+
- [Contributing](#contributing)
59+
- [Running Tests](#running-tests)
60+
- [Issue Triage ](#issue-triage-)
61+
- [License](#license)
62+
- [Author](#author)
5863

5964
### Examples
6065

@@ -277,6 +282,8 @@ Key | Options | Default | Descrip
277282
:batch_size | `Integer` | total # of records | Max number of records to insert per import
278283
:raise_error | `true`/`false` | `false` | Raises an exception at the first invalid record. This means there will not be a result object returned. The `import!` method is a shortcut for this.
279284
:all_or_none | `true`/`false` | `false` | Will not import any records if there is a record with validation errors.
285+
:omit_columns | `Array`/`Hash`/`Proc` | `nil` | Array of columns to leave out of SQL statement, e.g `[:guid]`, or Hash of `{ Model => [:column_name] }` or a Proc `-> (model, column_names) { [:guid] }` returning columns to omit
286+
:omit_columns_with_default_functions | `true` / `false` | `false` | Automatically omit columns that have a default function defined in the schema, such as non-PK uuid columns
280287

281288
#### Duplicate Key Ignore
282289

lib/activerecord-import/import.rb

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,13 @@ def supports_setting_primary_key_of_imported_objects?
347347
# newly imported objects. PostgreSQL only.
348348
# * +batch_size+ - an integer value to specify the max number of records to
349349
# include per insert. Defaults to the total number of records to import.
350+
# * +omit_columns+ an array of column names to exclude from the import,
351+
# or a proc that receives the class, and array of column names, and returns
352+
# a new array of column names (for recursive imports). This is to avoid
353+
# having to populate Model.ignored_columns, which can leak to other threads.
354+
# * +omit_columns_with_default_functions+ - true|false, tells import to filter
355+
# out all columns with a default function defined in the schema, such as uuid
356+
# columns that have a default value of uuid_generate_v4(). Defaults to false.
350357
#
351358
# == Examples
352359
# class BlogPost < ActiveRecord::Base ; end
@@ -548,9 +555,60 @@ def bulk_import!(*args)
548555
end
549556
alias import! bulk_import! unless ActiveRecord::Base.respond_to? :import!
550557

558+
# Filters column names for a model according to the options given,
559+
# specifically the :omit_columns and :omit_columns_with_default_functions options
560+
def omitted_column_names(column_names, options)
561+
model = options[:model]
562+
# binding.pry if model == Redirection
563+
ignored_column_names = []
564+
565+
# Do nothing if none of these options are truthy
566+
return ignored_column_names unless options.slice(:omit_columns, :omit_columns_with_default_functions).values.any?
567+
568+
# Remove columns that have default functions defined if the option is given
569+
ignored_column_names += columns_with_default_function.map(&:name).map(&:to_sym) if options[:omit_columns_with_default_functions]
570+
571+
if (omit_columns = options[:omit_columns])
572+
# If the option is a Proc, which is useful for recursive imports
573+
# where the model class is not known yet, call it with the model
574+
# and the current set of column names and expect it to return
575+
# columns to ignore
576+
# Cast to array in case it returns a falsy value
577+
case omit_columns
578+
when Proc
579+
ignored_column_names += Array(omit_columns.call(model, column_names)).map(&:to_sym)
580+
when Array
581+
ignored_column_names += omit_columns.map(&:to_sym)
582+
when Hash
583+
# ignore_columns could also be a hash of { Model => [:guid, :uuid], OtherModel => [:some_column] }
584+
ignored_column_names += Array(omit_columns[model]).map(&:to_sym) if omit_columns[model]
585+
end
586+
end
587+
ignored_column_names
588+
end
589+
590+
# Finds all columns that have a default function defined
591+
# in the schema. These columns should not be forcibly set
592+
# to NULL even if it's allowed.
593+
# If options[:omit_columns_with_default_functions] is given,
594+
# we use this list to remove these columns from the list and
595+
# subsequently from the schema column hash.
596+
def columns_with_default_function
597+
columns.select do |column|
598+
# We should probably not ignore the primary key?
599+
# If we should, it's not the job of this method to do so,
600+
# so don't return the primary key in this list.
601+
next if column.name == primary_key
602+
# Any columns that have a default function
603+
next unless column.default_function
604+
true
605+
end
606+
end
607+
551608
def import_helper( *args )
552609
options = { model: self, validate: true, timestamps: true, track_validation_failures: false }
553610
options.merge!( args.pop ) if args.last.is_a? Hash
611+
554612
# making sure that current model's primary key is used
555613
options[:primary_key] = primary_key
556614
options[:locking_column] = locking_column if attribute_names.include?(locking_column)
@@ -572,6 +630,9 @@ def import_helper( *args )
572630
end
573631
end
574632

633+
# Filter column names according to the options given
634+
# before proceeding to construct the array of attributes
635+
column_names -= omitted_column_names(column_names, options).map(&:to_s)
575636
if models.first.id.nil?
576637
Array(primary_key).each do |c|
577638
if column_names.include?(c) && schema_columns_hash[c].type == :uuid
@@ -622,15 +683,22 @@ def import_helper( *args )
622683
allow_extra_hash_keys = false
623684
end
624685

686+
# When importing an array of hashes, we know `self` is the current model
687+
omitted_column_names = omitted_column_names(column_names, options)
688+
625689
array_of_attributes = array_of_hashes.map do |h|
626690
error_message = validate_hash_import(h, column_names, allow_extra_hash_keys)
627691

628692
raise ArgumentError, error_message if error_message
629693

630-
column_names.map do |key|
694+
# Ensure column attributes are set to the filtered list after validation,
695+
# but validate as if the original list was passed in so that we dont
696+
# fail validation on columns that are going to be ignored
697+
(column_names - omitted_column_names).map do |key|
631698
h[key]
632699
end
633700
end
701+
634702
# supports empty array
635703
elsif args.last.is_a?( Array ) && args.last.empty?
636704
return ActiveRecord::Import::Result.new([], 0, [], [])
@@ -653,6 +721,10 @@ def import_helper( *args )
653721
# Force the primary key col into the insert if it's not
654722
# on the list and we are using a sequence and stuff a nil
655723
# value for it into each row so the sequencer will fire later
724+
725+
# Remove omitted columns
726+
column_names -= omitted_column_names(column_names, options)
727+
656728
symbolized_column_names = Array(column_names).map(&:to_sym)
657729
symbolized_primary_key = Array(primary_key).map(&:to_sym)
658730

test/models/redirection.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# frozen_string_literal: true
2+
3+
class Redirection < ActiveRecord::Base
4+
end

test/schema/postgresql_schema.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
ActiveRecord::Schema.define do
4+
enable_extension "pgcrypto"
45
execute('CREATE extension IF NOT EXISTS "hstore";')
56
execute('CREATE extension IF NOT EXISTS "pgcrypto";')
67
execute('CREATE extension IF NOT EXISTS "uuid-ossp";')
@@ -59,5 +60,16 @@
5960
t.datetime :updated_at
6061
end
6162

63+
create_table :redirections, force: :cascade do |t|
64+
t.uuid "guid", default: -> { "gen_random_uuid()" }
65+
t.string :title, null: false
66+
t.string :author_name
67+
t.string :url
68+
t.datetime :created_at
69+
t.datetime :created_on
70+
t.datetime :updated_at
71+
t.datetime :updated_on
72+
end
73+
6274
add_index :alarms, [:device_id, :alarm_type], unique: true, where: 'status <> 0'
6375
end

0 commit comments

Comments
 (0)