Skip to content

Commit

Permalink
replace tableoid where clause with FROM ONLY (#31)
Browse files Browse the repository at this point in the history
* replace tableoid where clause with from only

* handle deprecation

* remove size

* bump pg, fix tests, add rails version to matrix

* export rails version

* remove rubocop from rakefile

* simplify gemfile and database set up

* soft pin to minor version

* drop support for rails 6.1 and ruby 2.7

* echo

* string

* rm echo

* update version and changelog

* query more arel-like

* update readme

* cleanup
  • Loading branch information
waymondo authored Dec 24, 2023
1 parent 0b29af6 commit d4589df
Show file tree
Hide file tree
Showing 22 changed files with 98 additions and 173 deletions.
17 changes: 11 additions & 6 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:

services:
postgres:
image: postgres:14
image: postgres:16
env:
POSTGRES_DB: hoardable
POSTGRES_PASSWORD: password
Expand All @@ -28,21 +28,26 @@ jobs:
strategy:
matrix:
ruby:
- 3.2
- 3.1
- 3.0
- 2.7
- "3.0"
- "3.1"
- "3.2"
rails:
- "7.0"
# - "7.1"

steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
env:
RAILS_VERSION: ${{ matrix.rails }}
with:
ruby-version: ${{ matrix.ruby }}
bundler-cache: true
- name: Run the default task
env:
RAILS_ENV: test
RAILS_VERSION: ${{ matrix.rails }}
POSTGRES_USER: postgres
PGPASSWORD: password
POSTGRES_PASSWORD: password
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## 0.15.0

- *Breaking Change* - Support for Ruby 2.7 and Rails 6.1 is dropped
- *Breaking Change* - The default scoping clause that controls the inherited table SQL construction
changes from a where clause using `tableoid`s to using `FROM ONLY`.

## 0.14.3

- The migration template is updated to make the primary key on the versions table its actual primary key.
Expand Down
15 changes: 6 additions & 9 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,11 @@

source 'https://rubygems.org'

gem 'benchmark-ips', '~> 2.10'
gem 'debug', '~> 1.6'
gem 'minitest', '~> 5.0'
gem 'rails', '>= 6.1'
gem 'rake', '~> 13.0'
gem 'rubocop', '~> 1.21'
gem 'rubocop-minitest', '~> 0.20'
gem 'rubocop-rake', '~> 0.6'
gem 'yard', '~> 0.9'
gem 'debug'
if (rails_version = ENV['RAILS_VERSION'])
gem 'rails', "~> #{rails_version}.0"
else
gem 'rails'
end

gemspec
22 changes: 9 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
# Hoardable ![gem version](https://img.shields.io/gem/v/hoardable?style=flat-square)

Hoardable is an ActiveRecord extension for Ruby 2.7+, Rails 6.1+, and PostgreSQL that allows for
Hoardable is an ActiveRecord extension for Ruby 3+, Rails 7+, and PostgreSQL that allows for
versioning and soft-deletion of records through the use of _uni-temporal inherited tables_.

[Temporal tables](https://en.wikipedia.org/wiki/Temporal_database) are a database design pattern
where each row of a table contains data along with one or more time ranges. In the case of this gem,
each database row has a time range that represents the row’s valid time range - hence
"uni-temporal".

[Table inheritance](https://www.postgresql.org/docs/14/ddl-inherit.html) is a feature of PostgreSQL
that allows a table to inherit all columns of a parent table. The descendant table’s schema will
stay in sync with its parent. If a new column is added to or removed from the parent, the schema
change is reflected on its descendants.
[Table inheritance](https://www.postgresql.org/docs/current/ddl-inherit.html) is a feature of
PostgreSQL that allows a table to inherit all columns of a parent table. The descendant table’s
schema will stay in sync with its parent. If a new column is added to or removed from the parent,
the schema change is reflected on its descendants.

With these concepts combined, `hoardable` offers a model versioning and soft deletion system for
Rails. Versions of records are stored in separate, inherited tables along with their valid time
Expand All @@ -36,9 +36,6 @@ bin/rails g hoardable:install
bin/rails db:migrate
```

This will generate PostgreSQL functions, an enum and an initiailzer. It will also set
`config.active_record.schema_format = :sql` in `application.rb` if you are using Rails < 7.

### Model Installation

You must include `Hoardable::Model` into an ActiveRecord model that you would like to hoard versions
Expand Down Expand Up @@ -143,11 +140,12 @@ Including `Hoardable::Model` into your source model modifies its default scope t
query the parent table:

```ruby
Post.where(state: :draft).to_sql # => SELECT posts.* FROM posts WHERE posts.tableoid = CAST('posts' AS regclass) AND posts.status = 'draft'
Post.where(state: :draft).to_sql # => SELECT posts.* FROM ONLY posts WHERE posts.status = 'draft'
```

_*Note*:_ If you are executing raw SQL, you will need to include this clause if you do not wish to
return versions in the results.
_*Note*:_ If you are executing raw SQL, you will need to include the `ONLY` keyword you see above to
the select statement if you do not wish to return versions in the results. Learn more about table
inheritance in [the PostgreSQL documentation](https://www.postgresql.org/docs/current/ddl-inherit.html).

Since a `PostVersion` is an `ActiveRecord` class, you can query them like another model resource:

Expand Down Expand Up @@ -510,8 +508,6 @@ deletion.

## Contributing

This gem still quite new and very open to feedback.

Bug reports and pull requests are welcome on GitHub at https://github.com/waymondo/hoardable.

## License
Expand Down
6 changes: 1 addition & 5 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,4 @@ Rake::TestTask.new(:test) do |t|
t.test_files = FileList['test/**/test_*.rb']
end

require 'rubocop/rake_task'

RuboCop::RakeTask.new

task default: %i[test rubocop]
task default: %i[test]
2 changes: 1 addition & 1 deletion bin/console
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
# frozen_string_literal: true

require 'irb'
require_relative '../test/test_helper'
require_relative '../test/helper'

IRB.start(__FILE__)
11 changes: 0 additions & 11 deletions lib/generators/hoardable/install_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ module Hoardable
class InstallGenerator < Rails::Generators::Base
source_root File.expand_path('templates', __dir__)
include Rails::Generators::Migration
delegate :supports_schema_enums?, to: :class

def create_initializer_file
create_file(
Expand All @@ -23,12 +22,6 @@ def create_initializer_file
)
end

def change_schema_format_to_sql
return if supports_schema_enums?

application 'config.active_record.schema_format = :sql'
end

def create_migration_file
migration_template 'install.rb.erb', 'db/migrate/install_hoardable.rb'
end
Expand All @@ -40,10 +33,6 @@ def create_functions
end
end

def self.supports_schema_enums?
ActiveRecord.version >= ::Gem::Version.new('7.0.0')
end

def self.next_migration_number(dir)
::ActiveRecord::Generators::Base.next_migration_number(dir)
end
Expand Down
24 changes: 0 additions & 24 deletions lib/generators/hoardable/templates/install.rb.erb
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,6 @@ class InstallHoardable < ActiveRecord::Migration[<%= ActiveRecord::Migration.cur
create_function :hoardable_prevent_update_id
create_function :hoardable_source_set_id
create_function :hoardable_version_prevent_update
<% if supports_schema_enums? %>
create_enum :hoardable_operation, %w[update delete insert]
<% else %>
reversible do |dir|
dir.up do
execute(
<<~SQL.squish
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_type t WHERE t.typname = 'hoardable_operation'
) THEN
CREATE TYPE hoardable_operation AS ENUM ('update', 'delete', 'insert');
END IF;
END
$$;
SQL
)
end

dir.down do
execute('DROP TYPE IF EXISTS hoardable_operation;')
end
end
<% end %>
end
end
3 changes: 0 additions & 3 deletions lib/hoardable/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,6 @@ module Hoardable
SUPPORTS_ENCRYPTED_ACTION_TEXT = ActiveRecord.version >= ::Gem::Version.new('7.0.4')
private_constant :SUPPORTS_ENCRYPTED_ACTION_TEXT

SUPPORTS_VIRTUAL_COLUMNS = ActiveRecord.version >= ::Gem::Version.new('7.0.0')
private_constant :SUPPORTS_VIRTUAL_COLUMNS

@context = {}
@config = CONFIG_KEYS.to_h do |key|
[key, true]
Expand Down
5 changes: 0 additions & 5 deletions lib/hoardable/has_many.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,6 @@ def scope

def hoardable_scope
if Hoardable.instance_variable_get('@at') && (hoardable_id = @association.owner.hoardable_id)
if @association.reflection.is_a?(ActiveRecord::Reflection::ThroughReflection)
@association.reflection.source_reflection.instance_variable_set(
'@active_record_primary_key', 'hoardable_id'
)
end
@association.scope.rewhere(@association.reflection.foreign_key => hoardable_id)
else
@association.scope
Expand Down
39 changes: 14 additions & 25 deletions lib/hoardable/scopes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,7 @@ module Hoardable
module Scopes
extend ActiveSupport::Concern

TABLEOID_AREL_CONDITIONS = lambda do |arel_table, condition|
arel_table[:tableoid].send(
condition,
Arel::Nodes::NamedFunction.new('CAST', [Arel::Nodes::Quoted.new(arel_table.name).as('regclass')])
)
end.freeze
private_constant :TABLEOID_AREL_CONDITIONS

included do
# @!visibility private
attr_writer :tableoid

# By default {Hoardable} only returns instances of the parent table, and not the +versions+ in
# the inherited table. This can be bypassed by using the {.include_versions} scope or wrapping
# the code in a `Hoardable.at(datetime)` block.
Expand All @@ -35,23 +24,23 @@ module Scopes
#
# Returns +versions+ along with instances of the source models, all cast as instances of the
# source model’s class.
scope :include_versions, -> { unscope(where: [:tableoid]) }
scope :include_versions, -> { unscope(:from) }

# @!scope class
# @!method versions
# @return [ActiveRecord<Object>]
#
# Returns only +versions+ of the parent +ActiveRecord+ class, cast as instances of the source
# model’s class.
scope :versions, -> { include_versions.where(TABLEOID_AREL_CONDITIONS.call(arel_table, :not_eq)) }
scope :versions, -> { from("ONLY #{version_class.table_name}") }

# @!scope class
# @!method exclude_versions
# @return [ActiveRecord<Object>]
#
# Excludes +versions+ of the parent +ActiveRecord+ class. This is included by default in the
# source model’s +default_scope+.
scope :exclude_versions, -> { where(TABLEOID_AREL_CONDITIONS.call(arel_table, :eq)) }
scope :exclude_versions, -> { from("ONLY #{table_name}") }

# @!scope class
# @!method at
Expand All @@ -60,20 +49,20 @@ module Scopes
# Returns instances of the source model and versions that were valid at the supplied
# +datetime+ or +time+, all cast as instances of the source model.
scope :at, lambda { |datetime|
raise(CreatedAtColumnMissingError, @klass.table_name) unless @klass.column_names.include?('created_at')
raise(CreatedAtColumnMissingError, table_name) unless column_names.include?('created_at')

include_versions.where(id: version_class.at(datetime).select(@klass.primary_key)).or(
exclude_versions
.where("#{table_name}.created_at < ?", datetime)
.where.not(id: version_class.select(:hoardable_id).where(DURING_QUERY, datetime))
from(
Arel::Nodes::As.new(
Arel::Nodes::Union.new(
include_versions.where(id: version_class.at(datetime).select(primary_key)).arel,
exclude_versions
.where(created_at: ..datetime)
.where.not(id: version_class.select(:hoardable_id).where(DURING_QUERY, datetime)).arel,
),
arel_table
)
).hoardable
}
end

private

def tableoid
@tableoid ||= connection.execute("SELECT oid FROM pg_class WHERE relname = '#{table_name}'")[0]['oid']
end
end
end
2 changes: 1 addition & 1 deletion lib/hoardable/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Hoardable
VERSION = '0.14.3'
VERSION = '0.15.0'
end
13 changes: 4 additions & 9 deletions lib/hoardable/version_model.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,6 @@ module VersionModel
def version_class
self
end

# This is needed to omit the pseudo row of 'tableoid' when using +ActiveRecord+’s +insert+.
#
# @!visibility private
def scope_attributes
super.without('tableoid')
end
end

included do
Expand Down Expand Up @@ -86,7 +79,9 @@ def revert!

transaction do
hoardable_source.tap do |reverted|
reverted.reload.update!(hoardable_source_attributes.without(self.class.superclass.primary_key))
reverted.reload.update!(
hoardable_source_attributes.without(self.class.superclass.primary_key, 'hoardable_id')
)
reverted.instance_variable_set(:@hoardable_version, self)
reverted.run_callbacks(:reverted)
end
Expand Down Expand Up @@ -132,7 +127,7 @@ def insert_untrashed_source
def hoardable_source_attributes
attributes.without(
(self.class.column_names - self.class.superclass.column_names) +
(SUPPORTS_VIRTUAL_COLUMNS ? self.class.columns.select(&:virtual?).map(&:name) : [])
self.class.columns.select(&:virtual?).map(&:name)
)
end
end
Expand Down
7 changes: 0 additions & 7 deletions sig/hoardable.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,7 @@ module Hoardable
end

module Scopes
TABLEOID_AREL_CONDITIONS: Proc
self.@klass: bot

private
def tableoid: -> untyped

public
attr_writer tableoid: untyped
end

class Error < StandardError
Expand Down
5 changes: 5 additions & 0 deletions test/config/application.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# frozen_string_literal: true

require 'active_model/railtie'
require 'active_record/railtie'
require 'action_text/engine'

class Dummy < Rails::Application
config.load_defaults Rails::VERSION::STRING.to_f
config.eager_load = false
Expand All @@ -8,4 +12,5 @@ class Dummy < Rails::Application
config.paths['db/migrate'] = ['tmp/db/migrate']
config.active_record.encryption&.key_derivation_salt = SecureRandom.hex
config.active_record.encryption&.primary_key = SecureRandom.hex
config.active_record.yaml_column_permitted_classes = [ActiveSupport::HashWithIndifferentAccess]
end
Loading

0 comments on commit d4589df

Please sign in to comment.