diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d18605c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +# Editor configuration, see https://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..a985e47 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,18 @@ +name: Ruby + +on: [push,pull_request] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.0.1 + - name: Run the default task + run: | + gem install bundler -v 2.2.15 + bundle install + bundle exec rake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b04a8c8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +/.bundle/ +/.yardoc +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ + +# rspec failure tracking +.rspec_status diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..34c5164 --- /dev/null +++ b/.rspec @@ -0,0 +1,3 @@ +--format documentation +--color +--require spec_helper diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..bfef2d0 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,13 @@ +AllCops: + TargetRubyVersion: 2.4 + +Style/StringLiterals: + Enabled: true + EnforcedStyle: double_quotes + +Style/StringLiteralsInInterpolation: + Enabled: true + EnforcedStyle: double_quotes + +Layout/LineLength: + Max: 120 diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..49217ff --- /dev/null +++ b/Gemfile @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +# Specify your gem's dependencies in deep_versionable.gemspec +gemspec + +gem "rake", "~> 13.0" + +gem "rspec", "~> 3.0" + +gem "rubocop", "~> 1.7" diff --git a/LICENSE b/LICENSE.txt similarity index 87% rename from LICENSE rename to LICENSE.txt index 8f40e50..e6f435e 100644 --- a/LICENSE +++ b/LICENSE.txt @@ -1,6 +1,6 @@ -MIT License +The MIT License (MIT) -Copyright (c) 2023 Mònade +Copyright (c) 2023 Mònade srl Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -9,13 +9,13 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md index ff9339e..b6b49d0 100644 --- a/README.md +++ b/README.md @@ -1 +1,39 @@ -# deep_versionable \ No newline at end of file +# DeepVersionable + +Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/deep_versionable`. To experiment with that code, run `bin/console` for an interactive prompt. + +TODO: Delete this and the text above, and describe your gem + +## Installation + +Add this line to your application's Gemfile: + +```ruby +gem 'deep_versionable' +``` + +And then execute: + + $ bundle install + +Or install it yourself as: + + $ gem install deep_versionable + +## Usage + +TODO: Write usage instructions here + +## Development + +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. + +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 the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). + +## Contributing + +Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/deep_versionable. + +## License + +The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..cca7175 --- /dev/null +++ b/Rakefile @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "bundler/gem_tasks" +require "rspec/core/rake_task" + +RSpec::Core::RakeTask.new(:spec) + +require "rubocop/rake_task" + +RuboCop::RakeTask.new + +task default: %i[spec rubocop] diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..a5aa167 --- /dev/null +++ b/bin/console @@ -0,0 +1,15 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "bundler/setup" +require "deep_versionable" + +# You can add fixtures and/or initialization code here to make experimenting +# with your gem easier. You can also use a different console, if you like. + +# (If you use this, don't forget to add pry to your Gemfile!) +# require "pry" +# Pry.start + +require "irb" +IRB.start(__FILE__) diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..dce67d8 --- /dev/null +++ b/bin/setup @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' +set -vx + +bundle install + +# Do any other automated setup that you need to do here diff --git a/deep_versionable.gemspec b/deep_versionable.gemspec new file mode 100644 index 0000000..8b23260 --- /dev/null +++ b/deep_versionable.gemspec @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require_relative "lib/deep_versionable/version" + +Gem::Specification.new do |spec| + spec.name = "deep_versionable" + spec.version = DeepVersionable::VERSION + spec.authors = ["Daniel Fornarini"] + spec.email = ["daniel@monade.io"] + + spec.summary = "TODO: Write a short summary, because RubyGems requires one." + spec.description = "TODO: Write a longer description or delete this line." + spec.homepage = "TODO: Put your gem's website or public repo URL here." + spec.license = "MIT" + spec.required_ruby_version = Gem::Requirement.new(">= 2.4.0") + + spec.metadata["allowed_push_host"] = "TODO: Set to 'http://mygemserver.com'" + + spec.metadata["homepage_uri"] = spec.homepage + spec.metadata["source_code_uri"] = "TODO: Put your gem's public repo URL here." + spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here." + + # Specify which files should be added to the gem when it is released. + # The `git ls-files -z` loads the files in the RubyGem that have been added into git. + spec.files = Dir.chdir(File.expand_path(__dir__)) do + `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) } + end + spec.bindir = "exe" + spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } + spec.require_paths = ["lib"] + + # Uncomment to register a new dependency of your gem + # spec.add_dependency "example-gem", "~> 1.0" + + # For more information and examples about making a new gem, checkout our + # guide at: https://bundler.io/guides/creating_gem.html +end diff --git a/lib/deep_versionable.rb b/lib/deep_versionable.rb new file mode 100644 index 0000000..fd51784 --- /dev/null +++ b/lib/deep_versionable.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require 'active_support/concern' + +module DeepVersionable + extend ActiveSupport::Concern + + module Initializer + def as_deep_versionable + send :include, DeepVersionable + end + end + + included do + class_attribute :_deep_versionable_relations + has_many :versions, as: :versionable, dependent: :destroy + end + + module ClassMethods + def deep_versionable(include: {}) + declare_versionable_class(self.name) + self._deep_versionable_relations = parse_include(include, self) + end + + private + + def declare_versionable_class(name) + return if Object.const_defined?("DeepVersionable::Version::#{name}") + + dynamic_class = Class.new(OpenStruct) do + include ActiveModel::Serialization + + def initialize(object = nil) + super(object) + end + end + + Version.const_set(name, dynamic_class) + + declare_versionable_serializer(name) if Rails.configuration.deep_versionable_declare_serializers + end + + def declare_versionable_serializer(name) + return if Object.const_defined?("DeepVersionable::Version::#{name}Serializer") + + serializer = "#{name}Serializer".safe_constantize + # TODO: Implement logger + # Rails.logger.debug("Serializer #{name}Serializer not found. Using ApplicationSerializer for Version::#{name}") unless serializer + + dynamic_class = Class.new(serializer || ApplicationSerializer) do + type name.underscore.pluralize + end + + Version.const_set("#{name}Serializer", dynamic_class) + end + + def parse_include(include, klass) + parsed_include = { include: [] } + + include.each do |i| + if i.is_a?(Hash) + i.each do |k, v| + declare_versionable_class(load_class(klass, k).name) + parsed_include[:include] << { k => parse_include(v, load_class(klass, k)) } + end + else + declare_versionable_class(load_class(klass, i).name) + parsed_include[:include] << i + end + end + + parsed_include + end + + def load_class(klass, key) + association = klass.reflect_on_all_associations.detect { |e| e.name == key.to_sym } + class_name = association.options[:class_name] ? association.options[:class_name].to_s : key.to_s.classify + + class_name.constantize + end + end + + def versionize + object = to_json(self.class._deep_versionable_relations) + + versions.create!(version: versions.count + 1, object: object) + end +end + +# rubocop:disable Lint/SendWithMixinArgument +ActiveRecord::Base.send(:extend, DeepVersionable::Initializer) +# rubocop:enable Lint/SendWithMixinArgument \ No newline at end of file diff --git a/lib/deep_versionable/models/version.rb b/lib/deep_versionable/models/version.rb new file mode 100644 index 0000000..2919b22 --- /dev/null +++ b/lib/deep_versionable/models/version.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module DeepVersionable + class Version < ApplicationRecord + belongs_to :versionable, polymorphic: true + + validates :version, :object, presence: true + validates :version, uniqueness: { scope: %i[versionable_id versionable_type] } + + def reify + rebuild_model(versionable_type, JSON.parse(object)) + end + + private + + def rebuild_model(klass, object) + object.each do |k, v| + next unless association_exists?(klass, k) + + case v + when Hash + object[k] = rebuild_model(load_class(klass, k), v) + when Array + object[k] = v.map do |i| + if i.is_a?(Hash) + rebuild_model(load_class(klass, k), i) + else + i + end + end + end + end + + instance = Version.const_get(klass).new(object) + instance.freeze + instance + end + + def load_class(klass, key) + association = klass.constantize.reflect_on_all_associations.detect { |e| e.name == key.to_sym } + association.options[:class_name] ? association.options[:class_name].to_s : key.to_s.classify + end + + def association_exists?(klass, key) + klass.constantize.reflect_on_all_associations.detect { |e| e.name == key.to_sym }.present? + end + end +end \ No newline at end of file diff --git a/lib/deep_versionable/version.rb b/lib/deep_versionable/version.rb new file mode 100644 index 0000000..d13ff09 --- /dev/null +++ b/lib/deep_versionable/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module DeepVersionable + VERSION = "0.1.0" +end diff --git a/lib/generators/deep_versionable/install/install_generator.rb b/lib/generators/deep_versionable/install/install_generator.rb new file mode 100644 index 0000000..8599c0d --- /dev/null +++ b/lib/generators/deep_versionable/install/install_generator.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require_relative "../migration_generator" + +module DeepVersionable + class InstallGenerator < MigrationGenerator + # Class names of MySQL adapters. + # - `MysqlAdapter` - Used by gems: `mysql`, `activerecord-jdbcmysql-adapter`. + # - `Mysql2Adapter` - Used by `mysql2` gem. + MYSQL_ADAPTERS = [ + "ActiveRecord::ConnectionAdapters::MysqlAdapter", + "ActiveRecord::ConnectionAdapters::Mysql2Adapter" + ].freeze + + source_root File.expand_path("templates", __dir__) + class_option( + :with_changes, + type: :boolean, + default: false, + desc: "Store changeset (diff) with each version" + ) + class_option( + :uuid, + type: :boolean, + default: false, + desc: "Use uuid instead of bigint for item_id type (use only if tables use UUIDs)" + ) + + desc "Generates (but does not run) a migration to add a versions table." \ + " See section 5.c. Generators in README.md for more information." + + def create_migration_file + add_deep_versionable_migration( + "create_versions", + item_type_options: item_type_options, + versions_table_options: versions_table_options, + item_id_type_options: item_id_type_options, + version_table_primary_key_type: version_table_primary_key_type + ) + if options.with_changes? + add_paper_trail_migration("add_object_changes_to_versions") + end + end + + private + + # To use uuid instead of integer for primary key + def item_id_type_options + options.uuid? ? "string" : "bigint" + end + + # To use uuid for version table primary key + def version_table_primary_key_type + if options.uuid? + ", id: :uuid" + else + "" + end + end + + def item_type_options + if mysql? + ", null: false, limit: 191" + else + ", null: false" + end + end + + def mysql? + MYSQL_ADAPTERS.include?(ActiveRecord::Base.connection.class.name) + end + + # Even modern versions of MySQL still use `latin1` as the default character + # encoding. Many users are not aware of this, and run into trouble when they + # try to use PaperTrail in apps that otherwise tend to use UTF-8. Postgres, by + # comparison, uses UTF-8 except in the unusual case where the OS is configured + # with a custom locale. + # + # - https://dev.mysql.com/doc/refman/5.7/en/charset-applications.html + # - http://www.postgresql.org/docs/9.4/static/multibyte.html + # + # Furthermore, MySQL's original implementation of UTF-8 was flawed, and had + # to be fixed later by introducing a new charset, `utf8mb4`. + # + # - https://mathiasbynens.be/notes/mysql-utf8mb4 + # - https://dev.mysql.com/doc/refman/5.5/en/charset-unicode-utf8mb4.html + # + def versions_table_options + if mysql? + ', options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci"' + else + "" + end + end + end +end diff --git a/lib/generators/deep_versionable/install/templates/create_versions.rb.erb b/lib/generators/deep_versionable/install/templates/create_versions.rb.erb new file mode 100644 index 0000000..73cf192 --- /dev/null +++ b/lib/generators/deep_versionable/install/templates/create_versions.rb.erb @@ -0,0 +1,12 @@ +class CreateVersions < ActiveRecord::Migration<%= migration_version %> + def change + create_table :versions do |t| + t.integer :version, null: false + t.references :versionable, polymorphic: true, null: false + t.jsonb :object, null: false + + t.index [:version, :versionable_id, :versionable_type], unique: true, name: 'index_versions_on_version_and_versionable' + t.timestamps + end + end +end \ No newline at end of file diff --git a/lib/generators/deep_versionable/migration_generator.rb b/lib/generators/deep_versionable/migration_generator.rb new file mode 100644 index 0000000..c2f7b26 --- /dev/null +++ b/lib/generators/deep_versionable/migration_generator.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "rails/generators" +require "rails/generators/active_record" + +module DeepVersionable + # Basic structure to support a generator that builds a migration + class MigrationGenerator < ::Rails::Generators::Base + include ::Rails::Generators::Migration + + def self.next_migration_number(dirname) + ::ActiveRecord::Generators::Base.next_migration_number(dirname) + end + + protected + + def add_deep_versionable_migration(template, extra_options = {}) + migration_dir = File.expand_path("db/migrate") + if self.class.migration_exists?(migration_dir, template) + ::Kernel.warn "Migration already exists: #{template}" + else + migration_template( + "#{template}.rb.erb", + "db/migrate/#{template}.rb", + { migration_version: migration_version }.merge(extra_options) + ) + end + end + + def migration_version + format( + "[%d.%d]", + ActiveRecord::VERSION::MAJOR, + ActiveRecord::VERSION::MINOR + ) + end + end +end \ No newline at end of file diff --git a/spec/deep_versionable_spec.rb b/spec/deep_versionable_spec.rb new file mode 100644 index 0000000..6039dbf --- /dev/null +++ b/spec/deep_versionable_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +RSpec.describe DeepVersionable do + it "has a version number" do + expect(DeepVersionable::VERSION).not_to be nil + end + + it "does something useful" do + expect(false).to eq(true) + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..cf8e905 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "deep_versionable" + +RSpec.configure do |config| + # Enable flags like --only-failures and --next-failure + config.example_status_persistence_file_path = ".rspec_status" + + # Disable RSpec exposing methods globally on `Module` and `main` + config.disable_monkey_patching! + + config.expect_with :rspec do |c| + c.syntax = :expect + end +end