activerecord-covering-index 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ab81849fab4e6f4f806285e0260d6cd85dcf3b30ea6ca6ddf34246eade1d75ee
4
+ data.tar.gz: 154b89bcb99f80e44872b07d2192f799cc211e9e91c2ef8542881e2d0e97374b
5
+ SHA512:
6
+ metadata.gz: a5c108baf5d51576070829e94af6789591f212ff712631b96f48948a85a17da8c31f8d6411eb1ed78833052a636000b0947fc2e51d0328481b49ed9eb221bda6
7
+ data.tar.gz: f658f0294875aeb8d9e482f4cad5485a27c9375e7d8a8d8240a969516ff510f0be8e854101faf4431cdca1308ce3b2f763c193be9557c582b968d4cb423123c5
data/.gitignore ADDED
@@ -0,0 +1,16 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+
10
+ # rspec failure tracking
11
+ .rspec_status
12
+
13
+ .tool-versions
14
+
15
+ Gemfile.lock
16
+ gemfiles/*.gemfile.lock
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/Appraisals ADDED
@@ -0,0 +1,11 @@
1
+ appraise "activerecord-5-2" do
2
+ gem "activerecord", "~> 5.2"
3
+ end
4
+
5
+ appraise "activerecord-6-0" do
6
+ gem "activerecord", "~> 6.0.0"
7
+ end
8
+
9
+ appraise "activerecord-6-1" do
10
+ gem "activerecord", "~> 6.1.0"
11
+ end
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec
data/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # activerecord-covering-index
2
+
3
+ Extends ActiveRecord/Rails to support [covering indexes](https://www.postgresql.org/docs/11/indexes-index-only-scans.html) in PostgreSQL using the `INCLUDE` clause.
4
+
5
+ From the [PostgreSQL documentation](https://www.postgresql.org/docs/11/sql-createindex.html):
6
+
7
+ > The optional INCLUDE clause specifies a list of columns which will be included in the index as non-key columns. A non-key column cannot be used in an index scan search qualification, and it is disregarded for purposes of any uniqueness or exclusion constraint enforced by the index. However, an index-only scan can return the contents of non-key columns without having to visit the index's table, since they are available directly from the index entry. Thus, addition of non-key columns allows index-only scans to be used for queries that otherwise could not use them.
8
+
9
+ ## Compatibility
10
+
11
+ - ActiveRecord 5.2, 6.0 and 6.1
12
+ - PostgreSQL 11 and later
13
+
14
+ ## Installation
15
+
16
+ Add this line to your application's Gemfile:
17
+
18
+ ```ruby
19
+ gem 'activerecord-covering-index'
20
+ ```
21
+
22
+ And then execute:
23
+
24
+ $ bundle install
25
+
26
+ ## Usage
27
+
28
+ In a migration, use the `include` option with `add_index`:
29
+
30
+ ```ruby
31
+ class IndexUsersOnName < ActiveRecord::Migration[6.1]
32
+ def change
33
+ add_index :users, :name, include: :email
34
+ end
35
+ end
36
+ ```
37
+
38
+ Or within a `create_table` block:
39
+
40
+ ```ruby
41
+ class CreateUsers < ActiveRecord::Migration[6.1]
42
+ def change
43
+ create_table :users do |t|
44
+ t.string :name
45
+ t.string :email
46
+ t.timestamps
47
+
48
+ t.index :name, include: :email
49
+ end
50
+ end
51
+ end
52
+ ```
53
+
54
+ You can also `include` multiple columns:
55
+
56
+ ```ruby
57
+ add_index :users, :name, include: [:email, :updated_at]
58
+ ```
59
+
60
+ Or combine `include` with other index options:
61
+
62
+ ```ruby
63
+ add_index :users, :name, include: :email, where: 'email IS NOT NULL', unique: true
64
+ ```
65
+
66
+ ## Caveats
67
+
68
+ Non-key columns are not included in the name Rails generates for an index. For example, the following two indexes will receive the same name, which causes the second to raise a `PG::DuplicateTable` error:
69
+
70
+ ```ruby
71
+ add_index :users, :name
72
+ add_index :users, :name, include: :email
73
+ ```
74
+
75
+ To avoid collisions, you can specify a different name:
76
+
77
+ ```ruby
78
+ add_index :users, :name, include: :email, name: 'index_users_on_name_include_email'
79
+ ```
80
+
81
+ ## Development
82
+
83
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
84
+
85
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
86
+
87
+ ## Contributing
88
+
89
+ Bug reports and merge requests are welcome on GitLab at https://gitlab.com/schlock/activerecord-covering-index.
90
+
91
+ ## License
92
+
93
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
94
+
95
+ ## Credits
96
+
97
+ The gem was adapted from https://github.com/rails/rails/pull/37515, created by [@sebastian-palma](https://github.com/sebastian-palma).
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
@@ -0,0 +1,29 @@
1
+ require_relative 'lib/activerecord-covering-index/version'
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "activerecord-covering-index"
5
+ spec.version = ActiverecordCoveringIndex::VERSION
6
+ spec.authors = ["Tiger Watson"]
7
+ spec.email = ["tigerwnz@gmail.com"]
8
+
9
+ spec.summary = %q{Create covering indexes in Rails with PostgreSQL}
10
+ spec.description = %q{Extends ActiveRecord to support covering indexes in PostgreSQL using the INCLUDE clause.}
11
+ spec.homepage = "https://gitlab.com/schlock/activerecord-covering-index"
12
+ spec.license = "MIT"
13
+
14
+ spec.metadata["homepage_uri"] = spec.homepage
15
+
16
+ # Specify which files should be added to the gem when it is released.
17
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
18
+ spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
19
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(.gitlab|spec|gemfiles)/}) }
20
+ end
21
+
22
+ spec.require_paths = ["lib"]
23
+
24
+ spec.add_dependency "activerecord", ">= 5.2", "< 7"
25
+ spec.add_dependency "pg"
26
+
27
+ spec.add_development_dependency "rspec", "~> 3.10"
28
+ spec.add_development_dependency "appraisal", "~> 2.4"
29
+ end
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "activerecord-covering-index"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,15 @@
1
+ require 'active_record'
2
+ require "active_record/connection_adapters/postgresql_adapter"
3
+
4
+ require 'activerecord-covering-index/version'
5
+ require 'activerecord-covering-index/abstract_adapter'
6
+ require 'activerecord-covering-index/postgresql_adapter'
7
+ require 'activerecord-covering-index/schema_creation'
8
+ require 'activerecord-covering-index/index_definition'
9
+ require 'activerecord-covering-index/schema_dumper'
10
+
11
+ ActiveRecord::ConnectionAdapters::AbstractAdapter.send(:include, ActiverecordCoveringIndex::AbstractAdapter)
12
+ ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.send(:prepend, ActiverecordCoveringIndex::PostgreSQLAdapter)
13
+ ActiveRecord::ConnectionAdapters::SchemaCreation.send(:prepend, ActiverecordCoveringIndex::SchemaCreation)
14
+ ActiveRecord::ConnectionAdapters::IndexDefinition.send(:prepend, ActiverecordCoveringIndex::IndexDefinition)
15
+ ActiveRecord::SchemaDumper.send(:prepend, ActiverecordCoveringIndex::SchemaDumper)
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiverecordCoveringIndex
4
+ module AbstractAdapter
5
+ def supports_covering_index?
6
+ false
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiverecordCoveringIndex
4
+ module IndexDefinition
5
+ def self.prepended(base)
6
+ base.attr_reader :include
7
+ end
8
+
9
+ def initialize(
10
+ table, name,
11
+ unique = false,
12
+ columns = [],
13
+ lengths: {},
14
+ orders: {},
15
+ opclasses: {},
16
+ where: nil,
17
+ type: nil,
18
+ using: nil,
19
+ comment: nil,
20
+ include: []
21
+ )
22
+ @table = table
23
+ @name = name
24
+ @unique = unique
25
+ @columns = columns
26
+ @lengths = concise_options(lengths)
27
+ @orders = concise_options(orders)
28
+ @opclasses = concise_options(opclasses)
29
+ @where = where
30
+ @type = type
31
+ @using = using
32
+ @comment = comment
33
+ @include = include
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiverecordCoveringIndex
4
+ module PostgreSQLAdapter
5
+ def supports_covering_index?
6
+ if respond_to?(:database_version) # ActiveRecord 6+
7
+ database_version >= 110_000
8
+ else
9
+ postgresql_version >= 110_000
10
+ end
11
+ end
12
+
13
+ def add_index_options(table_name, column_name, name: nil, if_not_exists: false, internal: false, **options)
14
+ options.assert_valid_keys(:unique, :length, :order, :opclass, :where, :type, :using, :comment, :algorithm, :include)
15
+
16
+ options[:name] = name if name
17
+ options[:internal] = internal
18
+ non_key_columns = options.delete(:include)
19
+
20
+ original_index_options = super(table_name, column_name, **options)
21
+
22
+ if original_index_options.first.is_a?(String)
23
+ index_name, index_type, index_columns, index_options, algorithm, using, comment = original_index_options
24
+
25
+ if non_key_columns && supports_covering_index?
26
+ non_key_columns = [non_key_columns] if non_key_columns.is_a?(Symbol)
27
+ non_key_columns = quoted_columns_for_index(non_key_columns, {}).join(", ")
28
+
29
+ index_options = " INCLUDE (#{non_key_columns})" + index_options
30
+ end
31
+
32
+ [index_name, index_type, index_columns, index_options, algorithm, using, comment]
33
+ else
34
+ index, algorithm, if_not_exists = original_index_options
35
+
36
+ index_with_include = ActiveRecord::ConnectionAdapters::IndexDefinition.new(
37
+ index.table,
38
+ index.name,
39
+ index.unique,
40
+ index.columns,
41
+ lengths: index.lengths,
42
+ orders: index.orders,
43
+ opclasses: index.opclasses,
44
+ where: index.where,
45
+ type: index.type,
46
+ using: index.using,
47
+ comment: index.comment,
48
+ include: non_key_columns
49
+ )
50
+
51
+ [index_with_include, algorithm, if_not_exists]
52
+ end
53
+ end
54
+
55
+ def indexes(table_name)
56
+ scope = quoted_scope(table_name)
57
+
58
+ result = query(<<~SQL, "SCHEMA")
59
+ SELECT distinct i.relname, d.indisunique, d.indkey, pg_get_indexdef(d.indexrelid), t.oid,
60
+ pg_catalog.obj_description(i.oid, 'pg_class') AS comment, d.indnkeyatts
61
+ FROM pg_class t
62
+ INNER JOIN pg_index d ON t.oid = d.indrelid
63
+ INNER JOIN pg_class i ON d.indexrelid = i.oid
64
+ LEFT JOIN pg_namespace n ON n.oid = i.relnamespace
65
+ WHERE i.relkind IN ('i', 'I')
66
+ AND d.indisprimary = 'f'
67
+ AND t.relname = #{scope[:name]}
68
+ AND n.nspname = #{scope[:schema]}
69
+ ORDER BY i.relname
70
+ SQL
71
+
72
+ result.map do |row|
73
+ index_name = row[0]
74
+ unique = row[1]
75
+ indkey = row[2].split(" ").map(&:to_i)
76
+ inddef = row[3]
77
+ oid = row[4]
78
+ comment = row[5]
79
+ indnkeyatts = row[6]
80
+
81
+ using, expressions, _, where = inddef.scan(/ USING (\w+?) \((.+?)\)(?: INCLUDE \((.+?)\))?(?: WHERE (.+))?\z/m).flatten
82
+
83
+ orders = {}
84
+ opclasses = {}
85
+
86
+ columns = Hash[query(<<~SQL, "SCHEMA")].values_at(*indkey)
87
+ SELECT a.attnum, a.attname
88
+ FROM pg_attribute a
89
+ WHERE a.attrelid = #{oid}
90
+ AND a.attnum IN (#{indkey.join(",")})
91
+ SQL
92
+
93
+ non_key_columns = columns.pop(columns.count - indnkeyatts)
94
+
95
+ if indkey.include?(0)
96
+ columns = expressions
97
+ else
98
+ # add info on sort order (only desc order is explicitly specified, asc is the default)
99
+ # and non-default opclasses
100
+ expressions.scan(/(?<column>\w+)"?\s?(?<opclass>\w+_ops)?\s?(?<desc>DESC)?\s?(?<nulls>NULLS (?:FIRST|LAST))?/).each do |column, opclass, desc, nulls|
101
+ opclasses[column] = opclass.to_sym if opclass
102
+ if nulls
103
+ orders[column] = [desc, nulls].compact.join(" ")
104
+ else
105
+ orders[column] = :desc if desc
106
+ end
107
+ end
108
+ end
109
+
110
+ ActiveRecord::ConnectionAdapters::IndexDefinition.new(
111
+ table_name,
112
+ index_name,
113
+ unique,
114
+ columns,
115
+ orders: orders,
116
+ opclasses: opclasses,
117
+ where: where,
118
+ using: using.to_sym,
119
+ comment: comment.presence,
120
+ include: non_key_columns
121
+ )
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiverecordCoveringIndex
4
+ module SchemaCreation
5
+ def self.prepended(base)
6
+ attr_opts = { to: :@conn }
7
+ attr_opts[:private] = true if ActiveRecord::VERSION::MAJOR >= 6
8
+
9
+ base.delegate :supports_covering_index?, **attr_opts
10
+ end
11
+
12
+ private
13
+
14
+ def visit_CreateIndexDefinition(o)
15
+ index = o.index
16
+
17
+ sql = ["CREATE"]
18
+ sql << "UNIQUE" if index.unique
19
+ sql << "INDEX"
20
+ sql << "IF NOT EXISTS" if o.if_not_exists
21
+ sql << o.algorithm if o.algorithm
22
+ sql << index.type if index.type
23
+ sql << "#{quote_column_name(index.name)} ON #{quote_table_name(index.table)}"
24
+ sql << "USING #{index.using}" if supports_index_using? && index.using
25
+ sql << "(#{quoted_columns(index)})"
26
+ sql << "INCLUDE (#{quoted_index_includes(index.include)})" if supports_covering_index? && index.include
27
+ sql << "WHERE #{index.where}" if supports_partial_index? && index.where
28
+
29
+ sql.join(" ")
30
+ end
31
+
32
+ def quoted_index_includes(columns)
33
+ String === columns ? columns : quoted_columns_for_index(Array(columns), {})
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiverecordCoveringIndex
4
+ module SchemaDumper
5
+ private
6
+
7
+ def index_parts(index)
8
+ index_parts = super
9
+ index_parts << "include: #{index.include.inspect}" if index.include.present?
10
+ index_parts
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,3 @@
1
+ module ActiverecordCoveringIndex
2
+ VERSION = "0.1.0"
3
+ end
metadata ADDED
@@ -0,0 +1,123 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: activerecord-covering-index
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Tiger Watson
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2021-08-10 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '5.2'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '7'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '5.2'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '7'
33
+ - !ruby/object:Gem::Dependency
34
+ name: pg
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ type: :runtime
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ - !ruby/object:Gem::Dependency
48
+ name: rspec
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.10'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '3.10'
61
+ - !ruby/object:Gem::Dependency
62
+ name: appraisal
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '2.4'
68
+ type: :development
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '2.4'
75
+ description: Extends ActiveRecord to support covering indexes in PostgreSQL using
76
+ the INCLUDE clause.
77
+ email:
78
+ - tigerwnz@gmail.com
79
+ executables: []
80
+ extensions: []
81
+ extra_rdoc_files: []
82
+ files:
83
+ - ".gitignore"
84
+ - ".rspec"
85
+ - Appraisals
86
+ - Gemfile
87
+ - README.md
88
+ - Rakefile
89
+ - activerecord-covering-index.gemspec
90
+ - bin/console
91
+ - bin/setup
92
+ - lib/activerecord-covering-index.rb
93
+ - lib/activerecord-covering-index/abstract_adapter.rb
94
+ - lib/activerecord-covering-index/index_definition.rb
95
+ - lib/activerecord-covering-index/postgresql_adapter.rb
96
+ - lib/activerecord-covering-index/schema_creation.rb
97
+ - lib/activerecord-covering-index/schema_dumper.rb
98
+ - lib/activerecord-covering-index/version.rb
99
+ homepage: https://gitlab.com/schlock/activerecord-covering-index
100
+ licenses:
101
+ - MIT
102
+ metadata:
103
+ homepage_uri: https://gitlab.com/schlock/activerecord-covering-index
104
+ post_install_message:
105
+ rdoc_options: []
106
+ require_paths:
107
+ - lib
108
+ required_ruby_version: !ruby/object:Gem::Requirement
109
+ requirements:
110
+ - - ">="
111
+ - !ruby/object:Gem::Version
112
+ version: '0'
113
+ required_rubygems_version: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ requirements: []
119
+ rubygems_version: 3.1.4
120
+ signing_key:
121
+ specification_version: 4
122
+ summary: Create covering indexes in Rails with PostgreSQL
123
+ test_files: []