diff --git a/Gemfile.lock b/Gemfile.lock index ccdaa4f..d152cfd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,6 +3,7 @@ PATH specs: code0-zero_track (0.0.0) rails (>= 8.0.1) + zeitwerk (~> 2.7) GEM remote: https://rubygems.org/ @@ -82,11 +83,14 @@ GEM base64 (0.2.0) benchmark (0.4.0) bigdecimal (3.1.9) + binding_of_caller (1.0.1) + debug_inspector (>= 1.2.0) builder (3.3.0) concurrent-ruby (1.3.5) connection_pool (2.5.0) crass (1.0.6) date (3.4.1) + debug_inspector (1.2.0) diff-lcs (1.6.0) drb (2.2.1) erubi (1.13.1) @@ -147,6 +151,10 @@ GEM pp (0.6.2) prettyprint prettyprint (0.2.0) + proc_to_ast (0.2.0) + parser + rouge + unparser psych (5.2.3) date stringio @@ -195,6 +203,7 @@ GEM regexp_parser (2.10.0) reline (0.6.0) io-console (~> 0.5) + rouge (4.5.1) rspec (3.13.0) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) @@ -207,6 +216,17 @@ GEM rspec-mocks (3.13.2) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) + rspec-parameterized (1.0.2) + rspec-parameterized-core (< 2) + rspec-parameterized-table_syntax (< 2) + rspec-parameterized-core (1.0.1) + parser + proc_to_ast (>= 0.2.0) + rspec (>= 2.13, < 4) + unparser + rspec-parameterized-table_syntax (1.0.1) + binding_of_caller + rspec-parameterized-core (< 2) rspec-rails (7.1.1) actionpack (>= 7.0) activesupport (>= 7.0) @@ -253,6 +273,9 @@ GEM unicode-display_width (3.1.4) unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (4.0.4) + unparser (0.6.15) + diff-lcs (~> 1.3) + parser (>= 3.3.0) uri (1.0.2) useragent (0.16.11) websocket-driver (0.7.7) @@ -275,6 +298,7 @@ DEPENDENCIES code0-zero_track! rake (~> 13.0) rspec (~> 3.0) + rspec-parameterized (~> 1.0) rspec-rails (~> 7.0) rubocop-rails (~> 2.19) rubocop-rake (~> 0.7.0) diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..7bacebb --- /dev/null +++ b/bin/console @@ -0,0 +1,11 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'bundler/setup' +require 'code0/zero_track' + +# 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. +require 'irb' +ENV['IRB_USE_AUTOCOMPLETE'] = 'false' +IRB.start(__FILE__) diff --git a/code0-zero_track.gemspec b/code0-zero_track.gemspec index 59631e8..1d555d8 100644 --- a/code0-zero_track.gemspec +++ b/code0-zero_track.gemspec @@ -15,19 +15,21 @@ Gem::Specification.new do |spec| spec.metadata['homepage_uri'] = spec.homepage spec.metadata['source_code_uri'] = spec.homepage spec.metadata['changelog_uri'] = "#{spec.homepage}/releases" + spec.metadata['rubygems_mfa_required'] = 'true' spec.files = Dir.chdir(File.expand_path(__dir__)) do Dir['{app,config,db,lib}/**/*', 'LICENSE', 'Rakefile', 'README.md'] end spec.add_dependency 'rails', '>= 8.0.1' + spec.add_dependency 'zeitwerk', '~> 2.7' spec.add_development_dependency 'rake', '~> 13.0' spec.add_development_dependency 'rspec', '~> 3.0' + spec.add_development_dependency 'rspec-parameterized', '~> 1.0' spec.add_development_dependency 'rspec-rails', '~> 7.0' spec.add_development_dependency 'rubocop-rails', '~> 2.19' spec.add_development_dependency 'rubocop-rake', '~> 0.7.0' spec.add_development_dependency 'rubocop-rspec', '~> 3.0' spec.add_development_dependency 'rubocop-rspec_rails', '~> 2.30' - spec.metadata['rubygems_mfa_required'] = 'true' end diff --git a/lib/code0/zero_track.rb b/lib/code0/zero_track.rb index f3634ee..4b17087 100644 --- a/lib/code0/zero_track.rb +++ b/lib/code0/zero_track.rb @@ -1,10 +1,18 @@ # frozen_string_literal: true -require 'code0/zero_track/version' -require 'code0/zero_track/railtie' +require 'rails/railtie' + +require 'zeitwerk' +loader = Zeitwerk::Loader.new +loader.tag = File.basename(__FILE__, '.rb') +loader.inflector = Zeitwerk::GemInflector.new(__FILE__) +loader.push_dir(File.expand_path(File.join(__dir__, '..'))) +loader.setup module Code0 module ZeroTrack # Your code goes here... end end + +Code0::ZeroTrack::Railtie # eager load the railtie diff --git a/lib/code0/zero_track/context.rb b/lib/code0/zero_track/context.rb new file mode 100644 index 0000000..5fcd3b3 --- /dev/null +++ b/lib/code0/zero_track/context.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +module Code0 + module ZeroTrack + class Context + LOG_KEY = 'meta' + CORRELATION_ID_KEY = 'correlation_id' + RAW_KEYS = [CORRELATION_ID_KEY].freeze + + class << self + def with_context(attributes = {}) + context = push(attributes) + + begin + yield(context) + ensure + pop(context) + end + end + + def push(new_attributes = {}) + new_context = current&.merge(new_attributes) || new(new_attributes) + + contexts.push(new_context) + + new_context + end + + def pop(context) + contexts.pop while contexts.include?(context) + end + + def correlation_id + current&.correlation_id + end + + def current + contexts.last + end + + def log_key(key) + key = key.to_s + return key if RAW_KEYS.include?(key) + return key if key.start_with?("#{LOG_KEY}.") + + "#{LOG_KEY}.#{key}" + end + + private + + def contexts + Thread.current[:labkit_contexts] ||= [] + end + end + + def initialize(values = {}) + @data = {} + + assign_attributes(values) + end + + def merge(new_attributes) + new_context = self.class.new(data.dup) + new_context.assign_attributes(new_attributes) + + new_context + end + + def to_h + expand_data + end + + def [](key) + to_h[log_key(key)] + end + + def correlation_id + data[CORRELATION_ID_KEY] + end + + def get_attribute(attribute) + raw = call_or_value(data[log_key(attribute)]) + + call_or_value(raw) + end + + protected + + def assign_attributes(attributes) + attributes = attributes.transform_keys(&method(:log_key)) + + data.merge!(attributes) + + # Remove keys that had their values set to `nil` in the new attributes + data.keep_if { |_, value| valid_data?(value) } + + # Assign a correlation if it was missing in the first context or when + # explicitly removed + data[CORRELATION_ID_KEY] ||= new_id + + data + end + + private + + attr_reader :data + + def log_key(key) + self.class.log_key(key) + end + + def call_or_value(value) + value.respond_to?(:call) ? value.call : value + end + + def expand_data + data.transform_values do |value| + value = call_or_value(value) + + value if valid_data?(value) + end.compact + end + + def new_id + SecureRandom.hex + end + + def valid_data?(value) + value == false || value.present? + end + end + end +end diff --git a/lib/code0/zero_track/database/column_methods.rb b/lib/code0/zero_track/database/column_methods.rb new file mode 100644 index 0000000..4f2cf12 --- /dev/null +++ b/lib/code0/zero_track/database/column_methods.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Code0 + module ZeroTrack + module Database + module ColumnMethods + module Timestamps + # Appends columns `created_at` and `updated_at` to a table. + # + # It is used in table creation like: + # create_table 'users' do |t| + # t.timestamps_with_timezone + # end + def timestamps_with_timezone(**options) + options[:null] = false if options[:null].nil? + + %i[created_at updated_at].each do |column_name| + column(column_name, :datetime_with_timezone, **options) + end + end + + # Adds specified column with appropriate timestamp type + # + # It is used in table creation like: + # create_table 'users' do |t| + # t.datetime_with_timezone :did_something_at + # end + def datetime_with_timezone(column_name, **options) + column(column_name, :datetime_with_timezone, **options) + end + end + end + end + end +end diff --git a/lib/code0/zero_track/database/migration.rb b/lib/code0/zero_track/database/migration.rb new file mode 100644 index 0000000..a86b323 --- /dev/null +++ b/lib/code0/zero_track/database/migration.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Code0 + module ZeroTrack + module Database + class Migration + # rubocop:disable Naming/ClassAndModuleCamelCase + class V1_0 < ::ActiveRecord::Migration[7.1] + include Database::MigrationHelpers::AddColumnEnhancements + include Database::MigrationHelpers::ConstraintHelpers + include Database::MigrationHelpers::IndexHelpers + include Database::MigrationHelpers::TableEnhancements + end + # rubocop:enable Naming/ClassAndModuleCamelCase + + def self.[](version) + version = version.to_s + name = "V#{version.tr('.', '_')}" + raise ArgumentError, "Invalid migration version: #{version}" unless const_defined?(name, false) + + const_get(name, false) + end + end + end + end +end diff --git a/lib/code0/zero_track/database/migration_helpers/add_column_enhancements.rb b/lib/code0/zero_track/database/migration_helpers/add_column_enhancements.rb new file mode 100644 index 0000000..fcaf5a1 --- /dev/null +++ b/lib/code0/zero_track/database/migration_helpers/add_column_enhancements.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Code0 + module ZeroTrack + module Database + module MigrationHelpers + module AddColumnEnhancements + def add_column(table_name, column_name, type, *args, **kwargs, &block) + helper_context = self + + limit = kwargs.delete(:limit) + unique = kwargs.delete(:unique) + + super + + return unless type == :text + + quoted_column_name = helper_context.quote_column_name(column_name) + + if limit + name = helper_context.send(:text_limit_name, table_name, column_name) + + definition = "char_length(#{quoted_column_name}) <= #{limit}" + + add_check_constraint(table_name, definition, name: name) + end + + if unique.is_a?(Hash) + unique[:where] = "#{column_name} IS NOT NULL" if unique.delete(:allow_nil_duplicate) + column_name = "LOWER(#{quoted_column_name})" if unique.delete(:case_insensitive) + + add_index table_name, column_name, unique: true, **unique + elsif unique + add_index table_name, column_name, unique: unique + end + end + end + end + end + end +end diff --git a/lib/code0/zero_track/database/migration_helpers/constraint_helpers.rb b/lib/code0/zero_track/database/migration_helpers/constraint_helpers.rb new file mode 100644 index 0000000..7443236 --- /dev/null +++ b/lib/code0/zero_track/database/migration_helpers/constraint_helpers.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Code0 + module ZeroTrack + module Database + module MigrationHelpers + module ConstraintHelpers + def text_limit_name(table, column, name: nil) + name.presence || check_constraint_name(table, column, 'max_length') + end + + def check_constraint_name(table, column, type) + identifier = "#{table}_#{column}_check_#{type}" + hashed_identifier = Digest::SHA256.hexdigest(identifier).first(10) + + "check_#{hashed_identifier}" + end + end + end + end + end +end diff --git a/lib/code0/zero_track/database/migration_helpers/index_helpers.rb b/lib/code0/zero_track/database/migration_helpers/index_helpers.rb new file mode 100644 index 0000000..a2bef35 --- /dev/null +++ b/lib/code0/zero_track/database/migration_helpers/index_helpers.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Code0 + module ZeroTrack + module Database + module MigrationHelpers + module IndexHelpers + def index_name(table, column, type) + identifier = "#{table}_#{column}_index_#{type}" + hashed_identifier = Digest::SHA256.hexdigest(identifier).first(10) + + "index_#{hashed_identifier}" + end + end + end + end + end +end diff --git a/lib/code0/zero_track/database/migration_helpers/table_enhancements.rb b/lib/code0/zero_track/database/migration_helpers/table_enhancements.rb new file mode 100644 index 0000000..5425520 --- /dev/null +++ b/lib/code0/zero_track/database/migration_helpers/table_enhancements.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Code0 + module ZeroTrack + module Database + module MigrationHelpers + module TableEnhancements + def create_table(table_name, *args, **kwargs, &block) + helper_context = self + + super do |t| + enhance(t, table_name, helper_context, &block) + end + end + + def change_table(table_name, *args, **kwargs, &block) + helper_context = self + + super do |t| + enhance(t, table_name, helper_context, &block) + end + end + + private + + def enhance(t, table_name, helper_context, &block) + t.define_singleton_method(:text) do |column_name, **inner_kwargs| + limit = inner_kwargs.delete(:limit) + unique = inner_kwargs.delete(:unique) + + super(column_name, **inner_kwargs) + + quoted_column_name = helper_context.quote_column_name(column_name) + + if limit + name = helper_context.send(:text_limit_name, table_name, column_name) + + definition = "char_length(#{quoted_column_name}) <= #{limit}" + + t.check_constraint(definition, name: name) + end + + if unique.is_a?(Hash) + index_definition = column_name + unique[:where] = "#{column_name} IS NOT NULL" if unique.delete(:allow_nil_duplicate) + index_definition = "LOWER(#{quoted_column_name})" if unique.delete(:case_insensitive) + + t.index index_definition, unique: true, **unique + elsif unique + t.index column_name, unique: unique + end + end + + t.define_singleton_method(:integer) do |column_name, **inner_kwargs| + unique = inner_kwargs.delete(:unique) + + super(column_name, **inner_kwargs) + + t.index column_name, unique: unique unless unique.nil? + end + + return if block.nil? + + t.instance_eval do |obj| + if block.arity == 1 + block.call(obj) + elsif block.arity == 2 + block.call(obj, helper_context) + else + raise ArgumentError, "Unsupported arity of #{block.arity}" + end + end + end + end + end + end + end +end diff --git a/lib/code0/zero_track/database/postgresql_adapter/dump_schema_versions_mixin.rb b/lib/code0/zero_track/database/postgresql_adapter/dump_schema_versions_mixin.rb new file mode 100644 index 0000000..e5798c5 --- /dev/null +++ b/lib/code0/zero_track/database/postgresql_adapter/dump_schema_versions_mixin.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# Heavily inspired by the implementation of GitLab +# (https://gitlab.com/gitlab-org/gitlab/-/blob/7983d2a2203aff265fae479d7c1b7066858d1265/lib/gitlab/database/postgresql_adapter/dump_schema_versions_mixin.rb) +# which is licensed under a modified version of the MIT license which can be found at +# https://gitlab.com/gitlab-org/gitlab/-/blob/7983d2a2203aff265fae479d7c1b7066858d1265/LICENSE +# +# The code might have been modified to accommodate for the needs of this project + +module Code0 + module ZeroTrack + module Database + module PostgresqlAdapter + module DumpSchemaVersionsMixin + extend ActiveSupport::Concern + + def dump_schema_information + # rubocop:disable Rails/SkipsModelValidations -- not an active record object + Database::SchemaMigrations.touch_all(self) unless Rails.env.production? + # rubocop:enable Rails/SkipsModelValidations + nil + end + end + end + end + end +end diff --git a/lib/code0/zero_track/database/postgresql_database_tasks/load_schema_versions_mixin.rb b/lib/code0/zero_track/database/postgresql_database_tasks/load_schema_versions_mixin.rb new file mode 100644 index 0000000..613d676 --- /dev/null +++ b/lib/code0/zero_track/database/postgresql_database_tasks/load_schema_versions_mixin.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +# Heavily inspired by the implementation of GitLab +# (https://gitlab.com/gitlab-org/gitlab/-/blob/7983d2a2203aff265fae479d7c1b7066858d1265/lib/gitlab/database/postgresql_database_tasks/load_schema_versions_mixin.rb) +# which is licensed under a modified version of the MIT license which can be found at +# https://gitlab.com/gitlab-org/gitlab/-/blob/7983d2a2203aff265fae479d7c1b7066858d1265/LICENSE +# +# The code might have been modified to accommodate for the needs of this project + +module Code0 + module ZeroTrack + module Database + module PostgresqlDatabaseTasks + module LoadSchemaVersionsMixin + extend ActiveSupport::Concern + + def structure_load(...) + super + + Database::SchemaMigrations.load_all(connection) + end + end + end + end + end +end diff --git a/lib/code0/zero_track/database/schema_cleaner.rb b/lib/code0/zero_track/database/schema_cleaner.rb new file mode 100644 index 0000000..8cb4e0e --- /dev/null +++ b/lib/code0/zero_track/database/schema_cleaner.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +# Heavily inspired by the implementation of GitLab +# (https://gitlab.com/gitlab-org/gitlab/-/blob/7983d2a2203aff265fae479d7c1b7066858d1265/lib/gitlab/database/schema_cleaner.rb) +# which is licensed under a modified version of the MIT license which can be found at +# https://gitlab.com/gitlab-org/gitlab/-/blob/7983d2a2203aff265fae479d7c1b7066858d1265/LICENSE +# +# The code might have been modified to accommodate for the needs of this project + +module Code0 + module ZeroTrack + module Database + class SchemaCleaner + attr_reader :original_schema + + def initialize(original_schema) + @original_schema = original_schema + end + + def clean(io) + structure = original_schema.dup + + # Remove noise + structure.gsub!(/^COMMENT ON EXTENSION.*/, '') + structure.gsub!(/^SET.+/, '') + structure.gsub!(/^SELECT pg_catalog\.set_config\('search_path'.+/, '') + structure.gsub!(/^--.*/, "\n") + + # We typically don't assume we're working with the public schema. + # pg_dump uses fully qualified object names though, since we have multiple schemas + # in the database. + # + # The intention here is to not introduce an assumption about the standard schema, + # unless we have a good reason to do so. + structure.gsub!(/public\.(\w+)/, '\1') + structure.gsub!( + /CREATE EXTENSION IF NOT EXISTS (\w+) WITH SCHEMA public;/, + 'CREATE EXTENSION IF NOT EXISTS \1;' + ) + + structure.gsub!(/\n{3,}/, "\n\n") + + io << structure.strip + io << "\n" + + nil + end + end + end + end +end diff --git a/lib/code0/zero_track/database/schema_migrations.rb b/lib/code0/zero_track/database/schema_migrations.rb new file mode 100644 index 0000000..ae8daab --- /dev/null +++ b/lib/code0/zero_track/database/schema_migrations.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# Heavily inspired by the implementation of GitLab +# (https://gitlab.com/gitlab-org/gitlab/-/blob/7983d2a2203aff265fae479d7c1b7066858d1265/lib/gitlab/database/schema_migrations.rb) +# which is licensed under a modified version of the MIT license which can be found at +# https://gitlab.com/gitlab-org/gitlab/-/blob/7983d2a2203aff265fae479d7c1b7066858d1265/LICENSE +# +# The code might have been modified to accommodate for the needs of this project + +module Code0 + module ZeroTrack + module Database + module SchemaMigrations + module_function + + def touch_all(connection) + context = Database::SchemaMigrations::Context.new(connection) + + # rubocop:disable Rails/SkipsModelValidations -- not an active record object + Database::SchemaMigrations::Migrations.new(context).touch_all + # rubocop:enable Rails/SkipsModelValidations + end + + def load_all(connection) + context = Database::SchemaMigrations::Context.new(connection) + + Database::SchemaMigrations::Migrations.new(context).load_all + end + end + end + end +end diff --git a/lib/code0/zero_track/database/schema_migrations/context.rb b/lib/code0/zero_track/database/schema_migrations/context.rb new file mode 100644 index 0000000..8c37a19 --- /dev/null +++ b/lib/code0/zero_track/database/schema_migrations/context.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +# Heavily inspired by the implementation of GitLab +# (https://gitlab.com/gitlab-org/gitlab/-/blob/7983d2a2203aff265fae479d7c1b7066858d1265/lib/gitlab/database/schema_migrations/context.rb) +# which is licensed under a modified version of the MIT license which can be found at +# https://gitlab.com/gitlab-org/gitlab/-/blob/7983d2a2203aff265fae479d7c1b7066858d1265/LICENSE +# +# The code might have been modified to accommodate for the needs of this project + +module Code0 + module ZeroTrack + module Database + module SchemaMigrations + class Context + attr_reader :connection + + class_attribute :default_schema_migrations_path, default: 'db/schema_migrations' + + def initialize(connection) + @connection = connection + end + + def schema_directory + @schema_directory ||= Rails.root.join(database_schema_migrations_path).to_s + end + + def versions_to_create + versions_from_database = @connection.pool.schema_migration.versions + versions_from_migration_files = @connection.pool.migration_context.migrations.map { |m| m.version.to_s } + + versions_from_database & versions_from_migration_files + end + + private + + def database_name + @database_name ||= @connection.pool.db_config.name + end + + def database_schema_migrations_path + @connection.pool.db_config.configuration_hash[:schema_migrations_path] || + self.class.default_schema_migrations_path + end + end + end + end + end +end diff --git a/lib/code0/zero_track/database/schema_migrations/migrations.rb b/lib/code0/zero_track/database/schema_migrations/migrations.rb new file mode 100644 index 0000000..a18c6c2 --- /dev/null +++ b/lib/code0/zero_track/database/schema_migrations/migrations.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +# Heavily inspired by the implementation of GitLab +# (https://gitlab.com/gitlab-org/gitlab/-/blob/7983d2a2203aff265fae479d7c1b7066858d1265/lib/gitlab/database/schema_migrations/migrations.rb) +# which is licensed under a modified version of the MIT license which can be found at +# https://gitlab.com/gitlab-org/gitlab/-/blob/7983d2a2203aff265fae479d7c1b7066858d1265/LICENSE +# +# The code might have been modified to accommodate for the needs of this project + +module Code0 + module ZeroTrack + module Database + module SchemaMigrations + class Migrations + MIGRATION_VERSION_GLOB = '20[0-9][0-9]*' + + def initialize(context) + @context = context + end + + def touch_all + return unless @context.versions_to_create.any? + + version_filepaths = version_filenames.map { |f| File.join(schema_directory, f) } + FileUtils.rm(version_filepaths) + + @context.versions_to_create.each do |version| + version_filepath = File.join(schema_directory, version) + + File.open(version_filepath, 'w') do |file| + file << Digest::SHA256.hexdigest(version) + end + end + end + + def load_all + return if version_filenames.empty? + return unless @context.connection.pool.schema_migration.table_exists? + + values = version_filenames.map { |vf| "('#{@context.connection.quote_string(vf)}')" } + + @context.connection.execute(<<~SQL.squish) + INSERT INTO schema_migrations (version) + VALUES #{values.join(',')} + ON CONFLICT DO NOTHING + SQL + end + + private + + def schema_directory + @context.schema_directory + end + + def version_filenames + @version_filenames ||= Dir.glob(MIGRATION_VERSION_GLOB, base: schema_directory) + end + end + end + end + end +end diff --git a/lib/code0/zero_track/injectors/active_record_schema_migrations.rb b/lib/code0/zero_track/injectors/active_record_schema_migrations.rb new file mode 100644 index 0000000..d482bdb --- /dev/null +++ b/lib/code0/zero_track/injectors/active_record_schema_migrations.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Code0 + module ZeroTrack + module Injectors + class ActiveRecordSchemaMigrations + def self.inject! + # Patch to write version information as empty files under the db/schema_migrations directory + # This is intended to reduce potential for merge conflicts in db/structure.sql + ActiveSupport.on_load(:active_record_postgresqladapter) do + prepend Database::PostgresqlAdapter::DumpSchemaVersionsMixin + end + # Patch to load version information from empty files under the db/schema_migrations directory + ActiveRecord::Tasks::PostgreSQLDatabaseTasks + .prepend Database::PostgresqlDatabaseTasks::LoadSchemaVersionsMixin + end + end + end + end +end diff --git a/lib/code0/zero_track/injectors/active_record_timestamps.rb b/lib/code0/zero_track/injectors/active_record_timestamps.rb new file mode 100644 index 0000000..7dfcf9b --- /dev/null +++ b/lib/code0/zero_track/injectors/active_record_timestamps.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Code0 + module ZeroTrack + module Injectors + class ActiveRecordTimestamps + def self.inject! + ActiveSupport.on_load(:active_record_postgresqladapter) do + self::NATIVE_DATABASE_TYPES[:datetime_with_timezone] = { name: 'timestamptz' } + end + + ActiveSupport.on_load(:active_record) do + ActiveRecord::Base.time_zone_aware_types += [:datetime_with_timezone] + end + + ActiveRecord::ConnectionAdapters::ColumnMethods.include ZeroTrack::Database::ColumnMethods::Timestamps + end + end + end + end +end diff --git a/lib/code0/zero_track/loggable.rb b/lib/code0/zero_track/loggable.rb new file mode 100644 index 0000000..0a7120a --- /dev/null +++ b/lib/code0/zero_track/loggable.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Code0 + module ZeroTrack + module Loggable + extend ActiveSupport::Concern + + class_methods do + def logger + Logger.new(Rails.logger, name || '') + end + end + + def logger + Logger.new(Rails.logger, self.class.name || '') + end + + class Logger + def initialize(log, clazz) + @log = log + @clazz = clazz + end + + delegate :debug?, :info?, :warn?, :error?, :fatal?, :formatter, :level, to: :@log + + def with_context(&block) + Code0::ZeroTrack::Context.with_context(class: @clazz, &block) + end + + def debug(message) + with_context { @log.debug(message) } + end + + def error(message) + with_context { @log.error(message) } + end + + def warn(message) + with_context { @log.warn(message) } + end + + def info(message) + with_context { @log.info(message) } + end + end + end + end +end diff --git a/lib/code0/zero_track/logs/json_formatter.rb b/lib/code0/zero_track/logs/json_formatter.rb new file mode 100644 index 0000000..54234f7 --- /dev/null +++ b/lib/code0/zero_track/logs/json_formatter.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Code0 + module ZeroTrack + module Logs + class JsonFormatter < ::Logger::Formatter + def call(severity, datetime, _progname, message) + JSON.generate(data(severity, datetime, message)) << "\n" + end + + def data(severity, datetime, message) + data = {} + data[:severity] = severity + data[:time] = datetime.utc.iso8601(3) + + case message + when String + data[:message] = chomp message + when Hash + data.merge!(message) + end + + data.merge!(Code0::ZeroTrack::Context.current.to_h) + end + + def chomp(message) + message.chomp! until message.chomp == message + + message.strip + end + + class Tagged < JsonFormatter + include ActiveSupport::TaggedLogging::Formatter + + def tagged(*_args) + yield self # Ignore tags, they break the json layout as they are prepended to the log line + end + end + end + end + end +end diff --git a/lib/code0/zero_track/memoize.rb b/lib/code0/zero_track/memoize.rb new file mode 100644 index 0000000..266aa7d --- /dev/null +++ b/lib/code0/zero_track/memoize.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Code0 + module ZeroTrack + module Memoize + def memoize(name, reset_on_change: nil) + unless reset_on_change.nil? + reset_trigger = reset_on_change.call + reset_memoize = memoize("#{name}_reset_on_change") { reset_trigger } + + if reset_trigger != reset_memoize + clear_memoize(name) + clear_memoize("#{name}_reset_on_change") + end + end + + if memoized?(name) + instance_variable_get(ivar(name)) + else + instance_variable_set(ivar(name), yield) + end + end + + def memoized?(name) + instance_variable_defined?(ivar(name)) + end + + def clear_memoize(name) + clear_memoize!(name) if memoized?(name) + end + + def clear_memoize!(name) + remove_instance_variable(ivar(name)) + end + + private + + def ivar(name) + case name + when Symbol + name.to_s.prepend('@').to_sym + when String + :"@#{name}" + else + raise ArgumentError, "Invalid type of '#{name}'" + end + end + end + end +end diff --git a/lib/code0/zero_track/railtie.rb b/lib/code0/zero_track/railtie.rb index e5a638c..bcc9115 100644 --- a/lib/code0/zero_track/railtie.rb +++ b/lib/code0/zero_track/railtie.rb @@ -3,6 +3,21 @@ module Code0 module ZeroTrack class Railtie < ::Rails::Railtie + config.zero_track = ActiveSupport::OrderedOptions.new + config.zero_track.active_record = ActiveSupport::OrderedOptions.new + config.zero_track.active_record.timestamps = false + config.zero_track.active_record.schema_migrations = false + config.zero_track.active_record.schema_cleaner = false + + rake_tasks do + path = File.expand_path(__dir__) + Dir.glob("#{path}/../../tasks/**/*.rake").each { |f| load f } + end + + config.after_initialize do + Injectors::ActiveRecordTimestamps.inject! if config.zero_track.active_record.timestamps + Injectors::ActiveRecordSchemaMigrations.inject! if config.zero_track.active_record.schema_migrations + end end end end diff --git a/lib/tasks/code0/zero_track_tasks.rake b/lib/tasks/code0/zero_track_tasks.rake index a4e0c52..2be9959 100644 --- a/lib/tasks/code0/zero_track_tasks.rake +++ b/lib/tasks/code0/zero_track_tasks.rake @@ -1,6 +1,35 @@ # frozen_string_literal: true -# desc "Explaining what the task does" -# task :code0_zero_track do -# # Task goes here -# end +namespace :code0 do + namespace :zero_track do + namespace :db do + desc 'This adjusts and cleans db/structure.sql - it runs after db:schema:dump' + task clean_structure_sql: :environment do |task_name| + # Allow this task to be called multiple times, as happens when running db:migrate:redo + Rake::Task[task_name].reenable + + next unless Rails.application.config.zero_track.active_record.schema_cleaner + + ActiveRecord::Base.configurations + .configs_for(env_name: ActiveRecord::Tasks::DatabaseTasks.env) + .each do |db_config| + structure_file = ActiveRecord::Tasks::DatabaseTasks.schema_dump_path(db_config) + + schema = File.read(structure_file) + + File.open(structure_file, 'wb+') do |io| + Code0::ZeroTrack::Database::SchemaCleaner.new(schema).clean(io) + end + end + end + + # Inform Rake that custom tasks should be run every time rake db:schema:dump is run + Rake::Task['db:schema:dump'].enhance do + Rake::Task['code0:zero_track:db:clean_structure_sql'].invoke + end + Rake::Task['db:prepare'].enhance do + Rake::Task['code0:zero_track:db:clean_structure_sql'].invoke + end + end + end +end diff --git a/spec/code0/zero_track/context_spec.rb b/spec/code0/zero_track/context_spec.rb new file mode 100644 index 0000000..bba9bb4 --- /dev/null +++ b/spec/code0/zero_track/context_spec.rb @@ -0,0 +1,285 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Code0::ZeroTrack::Context do + describe '.with_context' do + it 'builds a context with values from the previous one' do + inner = nil + outer = nil + + described_class.with_context(described_class::CORRELATION_ID_KEY => 'hello') do |outer_context| + outer = outer_context + described_class.with_context(user: 'username') do |inner_context| + inner = inner_context + end + end + + expect(data_from(outer)).to eq(log_hash(described_class::CORRELATION_ID_KEY => 'hello')) + expect(data_from(inner)).to eq(log_hash(described_class::CORRELATION_ID_KEY => 'hello', 'user' => 'username')) + end + + it 'builds a context with overwritten key/values in the newer context' do + inner = nil + outer = nil + + described_class.with_context(caller_id: 'foo') do |outer_context| + outer = outer_context + described_class.with_context(caller_id: 'bar') do |inner_context| + inner = inner_context + end + end + + expect(data_from(outer)).to include(log_hash(caller_id: 'foo')) + expect(data_from(inner)).to include(log_hash(caller_id: 'bar')) + end + + it 'yields the block' do + expect { |b| described_class.with_context(&b) }.to yield_control + end + + it "pushes the context on the stack while it's running" do + described_class.with_context do |outer| + described_class.with_context do |inner| + expect(contexts).to eq([outer, inner]) + end + end + end + + it 'pops the context from the stack when the block fails' do + expect { described_class.with_context { |_| raise('broken') } }.to raise_error('broken') + + expect(contexts).to be_empty + end + + it 'returns the value from the block' do + expect(described_class.with_context { |_| 'some random string' }).to eq('some random string') + end + end + + describe '.push' do + let!(:root_context) { described_class.push } + + after do + described_class.pop(root_context) + end + + it 'pushes a new context on the stack' do + context = described_class.push + + expect(contexts).to eq([root_context, context]) + end + + it 'merges the attributes of the context with the previous one' do + expected_values = { described_class::CORRELATION_ID_KEY => root_context.correlation_id, + 'root_namespace' => 'namespace' } + + context = described_class.push(root_namespace: 'namespace') + + expect(data_from(context)).to eq(log_hash(expected_values)) + end + end + + describe '.pop' do + let!(:root_context) { described_class.push } + + after do + described_class.pop(root_context) + end + + it 'pops all context up to and including the given one' do + second_context = described_class.push + _third_context = described_class.push + + described_class.pop(second_context) + + expect(contexts).to contain_exactly(root_context) + end + end + + describe '.current' do + let!(:root_context) { described_class.push } + + after do + described_class.pop(root_context) + end + + it 'returns the last context' do + expect(described_class.current).to eq(root_context) + + new_context = described_class.push + + expect(described_class.current).to eq(new_context) + end + end + + describe '#to_h' do + let(:expected_hash) do + log_hash(user: 'user', + root_namespace: 'namespace', + project: 'project', + 'random.key': 'included') + end + + it 'returns a hash containing the expected values' do + context = described_class.new(user: 'user', + project: 'project', + root_namespace: 'namespace', + 'random.key': 'included') + + expect(context.to_h).to include(expected_hash) + end + + it 'returns a new hash every call' do + context = described_class.new + + expect(context.to_h.object_id).not_to eq(context.to_h.object_id) + end + + it 'loads the lazy values' do + context = described_class.new( + user: -> { 'user' }, + root_namespace: -> { 'namespace' }, + project: -> { 'project' }, + 'random.key': -> { 'included' } + ) + + expect(context.to_h).to include(expected_hash) + end + + it 'does not change the original data' do + context = described_class.new( + user: -> { 'user' }, + root_namespace: -> { 'namespace' }, + project: -> { 'project' } + ) + + expect { context.to_h }.not_to(change { data_from(context) }) + end + + it 'does not include empty values' do + context = described_class.new( + user: -> {}, + root_namespace: nil, + project: '' + ) + + expect(context.to_h.keys).to contain_exactly(described_class::CORRELATION_ID_KEY) + end + end + + describe '#initialize' do + it 'assigns all keys as strings' do + context = described_class.new( # -- deliberately testing asigning symbols and strings as keys + user: 'u', + 'project' => 'p', + something_else: 'nothing' + ) + + expect(data_from(context)).to include(log_hash('user' => 'u', 'project' => 'p', 'something_else' => 'nothing')) + end + + it 'assigns known keys starting with the log key' do + context = described_class.new( + log_hash(project: 'p', root_namespace: 'n', user: 'u', something_else: 'nothing') + ) + + expect(data_from(context)).to include(log_hash('project' => 'p', 'root_namespace' => 'n', 'user' => 'u', + 'something_else' => 'nothing')) + end + + it 'always assigns a correlation id' do + expect(described_class.new.correlation_id).not_to be_empty + end + end + + describe '#merge' do + it 'returns a new context with duplicated data' do + context = described_class.new(user: 'user') + + new_context = context.merge({}) + + expect(context.to_h).to eq(new_context.to_h) + expect(context).not_to eq(new_context) + end + + it 'merges values into the existing context' do + context = described_class.new(project: 'p', root_namespace: 'n', user: 'u') + + new_context = context.merge(project: '', root_namespace: 'namespace') + + expect(data_from(new_context)).to include(log_hash('root_namespace' => 'namespace', 'user' => 'u')) + end + + it 'removes empty values' do + context = described_class.new(project: 'p', root_namespace: 'n', user: 'u') + + new_context = context.merge(project: '', user: nil) + + expect(data_from(new_context)).to include(log_hash('root_namespace' => 'n')) + expect(data_from(new_context).keys).not_to include(described_class.log_key('project'), + described_class.log_key('user')) + end + + it 'keeps false values' do + context = described_class.new(project: 'p', root_namespace: 'n', flag: false) + + new_context = context.merge(project: '', false: nil) # rubocop:disable Lint/BooleanSymbol -- intended for this spec + + expect(data_from(new_context)).to include(log_hash('root_namespace' => 'n', 'flag' => false)) + expect(context.to_h).to include(log_hash('root_namespace' => 'n', 'flag' => false)) + end + + it 'does not overwrite the correlation id' do + context = described_class.new(described_class::CORRELATION_ID_KEY => 'hello') + + new_context = context.merge(user: 'u') + + expect(new_context.correlation_id).to eq('hello') + end + + it 'generates a new correlation id if a blank one was passed' do + context = described_class.new + old_correlation_id = context.correlation_id + + new_context = context.merge(described_class::CORRELATION_ID_KEY => '') + + expect(new_context.correlation_id).not_to be_empty + expect(new_context.correlation_id).not_to eq(old_correlation_id) + end + end + + describe '#get_attribute' do + using RSpec::Parameterized::TableSyntax + + where(:set_context, :attribute, :expected_value) do + [ + [{}, :caller_id, nil], + [{ caller_id: 'caller' }, :caller_id, 'caller'], + [{ caller_id: -> { 'caller' } }, :caller_id, 'caller'], + [{ caller_id: -> { 'caller' } }, 'caller_id', 'caller'], + [{ caller_id: -> { 'caller' } }, 'meta.caller_id', 'caller'] + ] + end + + with_them do + it 'returns the expected value for the attribute' do + described_class.with_context(set_context) do |context| + expect(context.get_attribute(attribute)).to eq(expected_value) + end + end + end + end + + def contexts + described_class.__send__(:contexts) + end + + def data_from(context) + context.__send__(:data) + end + + def log_hash(hash) + hash.transform_keys! { |key| described_class.log_key(key) } + end +end diff --git a/spec/code0/zero_track/database/postgresql_adapter/dump_schema_versions_mixin_spec.rb b/spec/code0/zero_track/database/postgresql_adapter/dump_schema_versions_mixin_spec.rb new file mode 100644 index 0000000..819829e --- /dev/null +++ b/spec/code0/zero_track/database/postgresql_adapter/dump_schema_versions_mixin_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +# Heavily inspired by the implementation of GitLab +# (https://gitlab.com/gitlab-org/gitlab/-/blob/7983d2a2203aff265fae479d7c1b7066858d1265/spec/lib/gitlab/database/postgresql_adapter/dump_schema_versions_mixin_spec.rb) +# which is licensed under a modified version of the MIT license which can be found at +# https://gitlab.com/gitlab-org/gitlab/-/blob/7983d2a2203aff265fae479d7c1b7066858d1265/LICENSE +# +# The code might have been modified to accommodate for the needs of this project + +require 'spec_helper' + +RSpec.describe Code0::ZeroTrack::Database::PostgresqlAdapter::DumpSchemaVersionsMixin do + let(:instance_class) do + klass = Class.new do + def dump_schema_information + original_dump_schema_information + end + + def original_dump_schema_information; end + end + + klass.prepend(described_class) + + klass + end + + let(:instance) { instance_class.new } + + it 'calls SchemaMigrations touch_all and skips original implementation' do + allow(Code0::ZeroTrack::Database::SchemaMigrations).to receive(:touch_all) + allow(instance).to receive(:original_dump_schema_information) + + instance.dump_schema_information + + expect(Code0::ZeroTrack::Database::SchemaMigrations).to have_received(:touch_all).with(instance) + expect(instance).not_to have_received(:original_dump_schema_information) + end + + it 'does not call touch_all in production' do + allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new('production')) + allow(Code0::ZeroTrack::Database::SchemaMigrations).to receive(:touch_all) + + instance.dump_schema_information + + expect(Code0::ZeroTrack::Database::SchemaMigrations).not_to have_received(:touch_all) + end +end diff --git a/spec/code0/zero_track/database/postgresql_database_tasks/load_schema_versions_mixin_spec.rb b/spec/code0/zero_track/database/postgresql_database_tasks/load_schema_versions_mixin_spec.rb new file mode 100644 index 0000000..c3a0661 --- /dev/null +++ b/spec/code0/zero_track/database/postgresql_database_tasks/load_schema_versions_mixin_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +# Heavily inspired by the implementation of GitLab +# (https://gitlab.com/gitlab-org/gitlab/-/blob/7983d2a2203aff265fae479d7c1b7066858d1265/spec/lib/gitlab/database/postgresql_database_tasks/load_schema_versions_mixin_spec.rb) +# which is licensed under a modified version of the MIT license which can be found at +# https://gitlab.com/gitlab-org/gitlab/-/blob/7983d2a2203aff265fae479d7c1b7066858d1265/LICENSE +# +# The code might have been modified to accommodate for the needs of this project + +require 'spec_helper' + +RSpec.describe Code0::ZeroTrack::Database::PostgresqlDatabaseTasks::LoadSchemaVersionsMixin do + let(:instance_class) do + klass = Class.new do + def structure_load + original_structure_load + end + + def original_structure_load; end + def connection; end + end + + klass.prepend(described_class) + + klass + end + + let(:instance) { instance_class.new } + + it 'calls SchemaMigrations load_all' do + connection = double('connection') # rubocop:disable RSpec/VerifiedDoubles -- we don't need an actual connection here + allow(instance).to receive(:connection).and_return(connection) + allow(instance).to receive(:original_structure_load) + allow(Code0::ZeroTrack::Database::SchemaMigrations).to receive(:load_all) + + instance.structure_load + + expect(instance).to have_received(:original_structure_load).ordered + expect(Code0::ZeroTrack::Database::SchemaMigrations).to have_received(:load_all).with(connection).ordered + end +end diff --git a/spec/code0/zero_track/database/schema_migrations/context_spec.rb b/spec/code0/zero_track/database/schema_migrations/context_spec.rb new file mode 100644 index 0000000..fa54f79 --- /dev/null +++ b/spec/code0/zero_track/database/schema_migrations/context_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +# Heavily inspired by the implementation of GitLab +# (https://gitlab.com/gitlab-org/gitlab/-/blob/7983d2a2203aff265fae479d7c1b7066858d1265/spec/lib/gitlab/database/schema_migrations/context_spec.rb) +# which is licensed under a modified version of the MIT license which can be found at +# https://gitlab.com/gitlab-org/gitlab/-/blob/7983d2a2203aff265fae479d7c1b7066858d1265/LICENSE +# +# The code might have been modified to accommodate for the needs of this project + +require 'spec_helper' + +RSpec.describe Code0::ZeroTrack::Database::SchemaMigrations::Context do + let(:connection) do + connection = double + pool = double + db_config = double + configuration_hash = { schema_migrations_path: 'db/schema_migrations' } + allow(connection).to receive(:pool).and_return(pool) + allow(pool).to receive(:db_config).and_return(db_config) + allow(db_config).to receive(:configuration_hash).and_return(configuration_hash) + + connection + end + + let(:context) { described_class.new(connection) } + + it '#schema_directory returns db/schema_migrations' do + expect(context.schema_directory).to eq(Rails.root.join(described_class.default_schema_migrations_path).to_s) + end + + describe '#versions_to_create' do + before do + # rubocop:disable RSpec/MessageChain -- we are mocking into active records structure + allow(connection.pool).to receive_message_chain(:schema_migration, :versions).and_return(migrated_versions) + + migrations_struct = Struct.new(:version) + migrations = file_versions.map { |version| migrations_struct.new(version) } + allow(connection.pool).to receive_message_chain(:migration_context, :migrations).and_return(migrations) + # rubocop:enable RSpec/MessageChain + end + + # rubocop:disable RSpec/IndexedLet -- these indexes do make sense + let(:version1) { '20200123' } + let(:version2) { '20200410' } + let(:version3) { '20200602' } + let(:version4) { '20200809' } + # rubocop:enable RSpec/IndexedLet + + let(:file_versions) { [version1, version2, version3, version4] } + let(:migrated_versions) { file_versions } + + context 'when migrated versions is the same as migration file versions' do + it 'returns migrated versions' do + expect(context.versions_to_create).to eq(migrated_versions) + end + end + + context 'when migrated versions is subset of migration file versions' do + let(:migrated_versions) { [version1, version2] } + + it 'returns migrated versions' do + expect(context.versions_to_create).to eq(migrated_versions) + end + end + + context 'when migrated versions is superset of migration file versions' do + let(:migrated_versions) { file_versions + ['20210809'] } + + it 'returns file versions' do + expect(context.versions_to_create).to eq(file_versions) + end + end + + context 'when migrated versions has slightly different versions to migration file versions' do + let(:migrated_versions) { [version1, version2, version3, version4, '20210101'] } + let(:file_versions) { [version1, version2, version3, version4, '20210102'] } + + it 'returns the common set' do + expect(context.versions_to_create).to eq([version1, version2, version3, version4]) + end + end + end +end diff --git a/spec/code0/zero_track/database/schema_migrations/migrations_spec.rb b/spec/code0/zero_track/database/schema_migrations/migrations_spec.rb new file mode 100644 index 0000000..7979c04 --- /dev/null +++ b/spec/code0/zero_track/database/schema_migrations/migrations_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +# Heavily inspired by the implementation of GitLab +# (https://gitlab.com/gitlab-org/gitlab/-/blob/7983d2a2203aff265fae479d7c1b7066858d1265/spec/lib/gitlab/database/schema_migrations/migrations_spec.rb) +# which is licensed under a modified version of the MIT license which can be found at +# https://gitlab.com/gitlab-org/gitlab/-/blob/7983d2a2203aff265fae479d7c1b7066858d1265/LICENSE +# +# The code might have been modified to accommodate for the needs of this project + +require 'spec_helper' + +RSpec.describe Code0::ZeroTrack::Database::SchemaMigrations::Migrations do + let(:connection) do + connection = double + pool = double + schema_migration = double + allow(connection).to receive(:pool).and_return(pool) + allow(pool).to receive(:schema_migration).and_return(schema_migration) + allow(schema_migration).to receive(:create_table) + allow(schema_migration).to receive(:table_exists?).and_return(true) + + connection + end + let(:context) { Code0::ZeroTrack::Database::SchemaMigrations::Context.new(connection) } + + let(:migrations) { described_class.new(context) } + + describe '#touch_all' do + # rubocop:disable RSpec/IndexedLet -- these indexes do make sense + let(:version1) { '20200123' } + let(:version2) { '20200410' } + let(:version3) { '20200602' } + let(:version4) { '20200809' } + # rubocop:enable RSpec/IndexedLet + + let(:relative_schema_directory) { 'db/schema_migrations' } + + it 'creates a file containing a checksum for each version with a matching migration' do + Dir.mktmpdir do |tmpdir| + schema_directory = Pathname.new(tmpdir).join(relative_schema_directory) + FileUtils.mkdir_p(schema_directory) + + old_version_filepath = schema_directory.join('20200101') + FileUtils.touch(old_version_filepath) + + expect(File.exist?(old_version_filepath)).to be(true) + + allow(context).to receive_messages(schema_directory: schema_directory, versions_to_create: [version1, version2]) + + migrations.touch_all # rubocop:disable Rails/SkipsModelValidations -- not an active record object + + expect(File.exist?(old_version_filepath)).to be(false) + + [version1, version2].each do |version| + version_filepath = schema_directory.join(version) + expect(File.exist?(version_filepath)).to be(true) + + hashed_value = Digest::SHA256.hexdigest(version) + expect(File.read(version_filepath)).to eq(hashed_value) + end + + [version3, version4].each do |version| + version_filepath = schema_directory.join(version) + expect(File.exist?(version_filepath)).to be(false) + end + end + end + end + + describe '#load_all' do + before do + allow(migrations).to receive(:version_filenames).and_return(filenames) + end + + context 'when there are no version files' do + let(:filenames) { [] } + + it 'does nothing' do + allow(connection).to receive(:quote_string) + allow(connection).to receive(:execute) + + migrations.load_all + + expect(connection).not_to have_received(:quote_string) + expect(connection).not_to have_received(:execute) + end + end + + context 'when there are version files' do + let(:filenames) { %w[123 456 789] } + + it 'inserts the missing versions into schema_migrations' do + allow(connection).to receive(:quote_string).with('schema_migrations').and_return('schema_migrations') + filenames.each do |filename| + allow(connection).to receive(:quote_string).with(filename).and_return(filename) + end + allow(connection).to receive(:execute) + + migrations.load_all + + filenames.each do |filename| + expect(connection).to have_received(:quote_string).with(filename) + end + expect(connection).to have_received(:execute).with(<<~SQL.squish) + INSERT INTO schema_migrations (version) + VALUES ('123'),('456'),('789') + ON CONFLICT DO NOTHING + SQL + end + + it 'does nothing if schema_migrations table does not exist' do + allow(connection).to receive(:execute) + + schema_migration = connection.pool.schema_migration + allow(connection.pool).to receive(:schema_migration).and_return(schema_migration) + allow(schema_migration).to receive(:table_exists?).and_return(false) + + migrations.load_all + + expect(connection).not_to have_received(:execute) + end + end + end +end diff --git a/spec/code0/zero_track/loggable_spec.rb b/spec/code0/zero_track/loggable_spec.rb new file mode 100644 index 0000000..6653b27 --- /dev/null +++ b/spec/code0/zero_track/loggable_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Code0::ZeroTrack::Loggable do + let(:clazz) do + Class.new do + include Code0::ZeroTrack::Loggable + end + end + + context 'with named class' do + before do + stub_const('TestClass', clazz) + end + + it 'for instantiated class' do + TestClass.new.logger.with_context do |context| + expect(context.to_h).to include(Code0::ZeroTrack::Context.log_key(:class) => 'TestClass') + end + end + + it 'when called on the class' do + TestClass.logger.with_context do |context| + expect(context.to_h).to include(Code0::ZeroTrack::Context.log_key(:class) => 'TestClass') + end + end + end + + context 'with anonymous class' do + it 'for instantiated class' do + clazz.new.logger.with_context do |context| + expect(context.to_h).to include(Code0::ZeroTrack::Context.log_key(:class) => '') + end + end + + it 'when called on the class' do + clazz.logger.with_context do |context| + expect(context.to_h).to include(Code0::ZeroTrack::Context.log_key(:class) => '') + end + end + end +end diff --git a/spec/code0/zero_track/memoize_spec.rb b/spec/code0/zero_track/memoize_spec.rb new file mode 100644 index 0000000..90ec0f9 --- /dev/null +++ b/spec/code0/zero_track/memoize_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Code0::ZeroTrack::Memoize do + let(:impl_class) do + Class.new do + include Code0::ZeroTrack::Memoize + + def memoized(block) + memoize(:memoized, &block) + end + + def memoize_with_change(change_value, block) + memoize(:memoize_with_change, reset_on_change: -> { change_value }, &block) + end + end + end + + let(:instance) { impl_class.new } + + it 'yields once' do + expect { |b| instance.memoized(b) }.to yield_control + expect { |b| instance.memoized(b) }.not_to yield_control + end + + it 'allows memoize reset' do + expect { |b| instance.memoized(b) }.to yield_control + expect { |b| instance.memoized(b) }.not_to yield_control + + instance.clear_memoize(:memoized) + + expect { |b| instance.memoized(b) }.to yield_control + expect { |b| instance.memoized(b) }.not_to yield_control + end + + it 'allows memoize check' do + expect(instance.memoized?(:memoized)).to be false + + instance.memoized(-> {}) + + expect(instance.memoized?(:memoized)).to be true + end + + it 'returns correct values' do + expect(instance.memoized(-> { 1 })).to eq 1 + expect(instance.memoized(-> { 2 })).to eq 1 # 1 is due to memoization of first call + end + + context 'with reset_on_change' do + it 'memoizes when value is not changing' do + expect { |b| instance.memoize_with_change(1, b) }.to yield_control + expect { |b| instance.memoize_with_change(1, b) }.not_to yield_control + end + + it 'does not memoize when value is changing' do + expect { |b| instance.memoize_with_change(1, b) }.to yield_control + expect { |b| instance.memoize_with_change(2, b) }.to yield_control + end + + it 'clears old memoize when value changed' do + expect { |b| instance.memoize_with_change(1, b) }.to yield_control + expect { |b| instance.memoize_with_change(1, b) }.not_to yield_control + + expect { |b| instance.memoize_with_change(2, b) }.to yield_control + expect { |b| instance.memoize_with_change(2, b) }.not_to yield_control + + expect { |b| instance.memoize_with_change(1, b) }.to yield_control + end + + it 'returns correct values' do + expect(instance.memoize_with_change(1, -> { 1 })).to eq 1 + expect(instance.memoize_with_change(2, -> { 2 })).to eq 2 + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index a3ce6fd..5daba99 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'code0/zero_track' + # these requires are necessary because without them, requiring rspec/rails fails require 'action_view' require 'action_dispatch' @@ -7,6 +9,11 @@ require 'rspec/rails' +require 'rails/all' +require 'support/application' + +require 'rspec-parameterized' + RSpec.configure do |config| # Enable flags like --only-failures and --next-failure config.example_status_persistence_file_path = '.rspec_status' diff --git a/spec/support/application.rb b/spec/support/application.rb new file mode 100644 index 0000000..022fd65 --- /dev/null +++ b/spec/support/application.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Support + class Application < Rails::Application + end +end