├── spec ├── dummy │ ├── db │ │ └── migrate │ │ │ └── .keep │ ├── bin │ │ ├── rake │ │ ├── bundle │ │ └── rails │ ├── config.ru │ ├── config │ │ ├── environment.rb │ │ ├── boot.rb │ │ ├── database.yml │ │ └── application.rb │ ├── Rakefile │ └── .gitignore ├── support │ ├── warning_helper.rb │ ├── migration_helpers.rb │ ├── database_reset.rb │ ├── definition_helpers.rb │ └── generator_setup.rb ├── spec_helper.rb ├── fx_spec.rb ├── fx │ ├── configuration_spec.rb │ ├── adapters │ │ ├── postgres │ │ │ ├── functions_spec.rb │ │ │ ├── triggers_spec.rb │ │ │ └── query_executor_spec.rb │ │ └── postgres_spec.rb │ ├── trigger_spec.rb │ ├── function_spec.rb │ ├── definition_spec.rb │ ├── schema_dumper_spec.rb │ ├── command_recorder_spec.rb │ └── statements_spec.rb ├── features │ ├── triggers │ │ ├── migrations_spec.rb │ │ └── revert_spec.rb │ └── functions │ │ ├── migrations_spec.rb │ │ └── revert_spec.rb ├── generators │ └── fx │ │ ├── function │ │ └── function_generator_spec.rb │ │ ├── trigger │ │ └── trigger_generator_spec.rb │ │ ├── migration_helper_spec.rb │ │ ├── name_helper_spec.rb │ │ └── version_helper_spec.rb ├── acceptance │ ├── user_manages_functions_spec.rb │ └── user_manages_triggers_spec.rb └── acceptance_helper.rb ├── .rspec ├── .standard.yml ├── lib ├── fx │ ├── version.rb │ ├── railtie.rb │ ├── trigger.rb │ ├── function.rb │ ├── configuration.rb │ ├── adapters │ │ ├── postgres │ │ │ ├── query_executor.rb │ │ │ ├── connection.rb │ │ │ ├── triggers.rb │ │ │ └── functions.rb │ │ └── postgres.rb │ ├── schema_dumper.rb │ ├── definition.rb │ ├── command_recorder.rb │ └── statements.rb ├── generators │ └── fx │ │ ├── function │ │ ├── templates │ │ │ └── db │ │ │ │ └── migrate │ │ │ │ ├── create_function.erb │ │ │ │ └── update_function.erb │ │ ├── USAGE │ │ └── function_generator.rb │ │ ├── trigger │ │ ├── templates │ │ │ └── db │ │ │ │ └── migrate │ │ │ │ ├── create_trigger.erb │ │ │ │ └── update_trigger.erb │ │ ├── USAGE │ │ └── trigger_generator.rb │ │ ├── name_helper.rb │ │ ├── migration_helper.rb │ │ └── version_helper.rb ├── generators.rb └── fx.rb ├── .yardopts ├── .gitignore ├── bin ├── setup ├── console ├── rake ├── yard ├── rspec └── standardrb ├── Gemfile ├── Rakefile ├── CONTRIBUTING.md ├── LICENSE ├── fx.gemspec ├── .github └── workflows │ └── ci.yml ├── README.md └── CHANGELOG.md /spec/dummy/db/migrate/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --no-color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /.standard.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - 'spec/dummy/db/schema.rb' 3 | - '**/tmp/*' 4 | -------------------------------------------------------------------------------- /lib/fx/version.rb: -------------------------------------------------------------------------------- 1 | module Fx 2 | # @api private 3 | VERSION = "0.10.0" 4 | end 5 | -------------------------------------------------------------------------------- /spec/support/warning_helper.rb: -------------------------------------------------------------------------------- 1 | require "warning" 2 | 3 | Warning.process do |_| 4 | :raise 5 | end 6 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --hide-api private 2 | --exclude templates 3 | --markup markdown 4 | --markup-provider redcarpet 5 | -------------------------------------------------------------------------------- /spec/dummy/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative "../config/boot" 3 | require "rake" 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | -------------------------------------------------------------------------------- /spec/dummy/bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", __FILE__) 3 | load Gem.bin_path("bundler", "bundle") 4 | -------------------------------------------------------------------------------- /spec/dummy/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path("../../config/application", __FILE__) 3 | require_relative "../config/boot" 4 | require "rails/commands" 5 | -------------------------------------------------------------------------------- /spec/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require ::File.expand_path("../config/environment", __FILE__) 4 | run Rails.application 5 | -------------------------------------------------------------------------------- /spec/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require File.expand_path("../application", __FILE__) 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | gem install bundler --conservative 6 | bundle check || bundle install 7 | 8 | bundle exec rake dummy:db:drop 9 | bundle exec rake dummy:db:create 10 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem "bundler", ">= 1.5" 6 | gem "pg" 7 | gem "pry" 8 | gem "rake" 9 | gem "redcarpet" 10 | gem "rspec", ">= 3.3" 11 | gem "standardrb" 12 | gem "yard" 13 | gem "warning" 14 | -------------------------------------------------------------------------------- /lib/generators/fx/function/templates/db/migrate/create_function.erb: -------------------------------------------------------------------------------- 1 | class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>] 2 | def change 3 | create_function <%= formatted_name %> 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | # Set up gems listed in the Gemfile. 2 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../../Gemfile", __FILE__) 3 | 4 | require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) 5 | $LOAD_PATH.unshift File.expand_path("../../../../lib", __FILE__) 6 | -------------------------------------------------------------------------------- /lib/generators/fx/trigger/templates/db/migrate/create_trigger.erb: -------------------------------------------------------------------------------- 1 | class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>] 2 | def change 3 | create_trigger <%= formatted_name %>, on: <%= formatted_table_name %> 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | development: &default 2 | adapter: postgresql 3 | host: 127.0.0.1 4 | user: <%= ENV['POSTGRES_USER'] %> 5 | database: dummy_development 6 | encoding: unicode 7 | pool: 5 8 | 9 | test: 10 | <<: *default 11 | database: dummy_test 12 | -------------------------------------------------------------------------------- /lib/generators/fx/function/templates/db/migrate/update_function.erb: -------------------------------------------------------------------------------- 1 | class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>] 2 | def change 3 | update_function <%= formatted_name %>, version: <%= version %>, revert_to_version: <%= previous_version %> 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/generators/fx/trigger/templates/db/migrate/update_trigger.erb: -------------------------------------------------------------------------------- 1 | class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>] 2 | def change 3 | update_trigger <%= formatted_name %>, on: <%= formatted_table_name %>, version: <%= version %>, revert_to_version: <%= previous_version %> 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/generators.rb: -------------------------------------------------------------------------------- 1 | module Fx 2 | # Fx provides generators for creating and updating functions and triggers. 3 | # 4 | # See: 5 | # 6 | # * {file:lib/generators/fx/function/USAGE Function Generator} 7 | # * {file:lib/generators/fx/trigger/USAGE Trigger Generator} 8 | # * {file:README.md README} 9 | module Generators 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/fx/railtie.rb: -------------------------------------------------------------------------------- 1 | require "rails/railtie" 2 | 3 | module Fx 4 | # Automatically initializes Fx in the context of a Rails application when 5 | # ActiveRecord is loaded. 6 | # 7 | # @see Fx.load 8 | class Railtie < Rails::Railtie 9 | initializer "fx.load" do 10 | ActiveSupport.on_load :active_record do 11 | Fx.load 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "fx" 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 15 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rake' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | require "rubygems" 14 | require "bundler/setup" 15 | 16 | load Gem.bin_path("rake", "rake") 17 | -------------------------------------------------------------------------------- /lib/generators/fx/function/USAGE: -------------------------------------------------------------------------------- 1 | Description: 2 | Create a new database function for your application. This will create a new 3 | function definition file and the accompanying migration. 4 | 5 | When --no-migration is passed, skips generating a migration. 6 | 7 | Examples: 8 | rails generate fx:function test 9 | 10 | create: db/functions/test_v01.sql 11 | create: db/migrate/[TIMESTAMP]_create_test.rb 12 | -------------------------------------------------------------------------------- /spec/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../boot", __FILE__) 2 | 3 | # Pick the frameworks you want: 4 | require "active_record/railtie" 5 | 6 | Bundler.require(*Rails.groups) 7 | require "fx" 8 | 9 | module Dummy 10 | class Application < Rails::Application 11 | config.cache_classes = true 12 | config.eager_load = false 13 | config.active_support.deprecation = :stderr 14 | 15 | config.load_defaults 7.2 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/dummy/Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require File.expand_path("../config/application", __FILE__) 5 | 6 | Rails.application.load_tasks 7 | 8 | unless Rake::Task.task_defined?("db:environment:set") 9 | desc "dummy task for rails versions where this task does not exist" 10 | task "db:environment:set" do 11 | # no-op 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/dummy/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files. 2 | # 3 | # If you find yourself ignoring temporary files generated by your text editor 4 | # or operating system, you probably want to add a global ignore instead: 5 | # git config --global core.excludesfile '~/.gitignore_global' 6 | 7 | # Ignore bundler config. 8 | /.bundle 9 | 10 | # Ignore the default SQLite database. 11 | /db/*.sqlite3 12 | /db/*.sqlite3-journal 13 | 14 | # Ignore all logfiles and tempfiles. 15 | /log/*.log 16 | /tmp 17 | -------------------------------------------------------------------------------- /lib/fx/trigger.rb: -------------------------------------------------------------------------------- 1 | module Fx 2 | # @api private 3 | class Trigger 4 | include Comparable 5 | 6 | attr_reader :name, :definition 7 | delegate :<=>, to: :name 8 | 9 | def initialize(row) 10 | @name = row.fetch("name") 11 | @definition = row.fetch("definition") 12 | end 13 | 14 | def ==(other) 15 | name == other.name && definition == other.definition 16 | end 17 | 18 | def to_schema 19 | <<-SCHEMA 20 | create_trigger :#{name}, sql_definition: <<-\SQL 21 | #{definition} 22 | SQL 23 | SCHEMA 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/fx/function.rb: -------------------------------------------------------------------------------- 1 | module Fx 2 | # @api private 3 | class Function 4 | include Comparable 5 | 6 | attr_reader :name, :definition 7 | delegate :<=>, to: :name 8 | 9 | def initialize(row) 10 | @name = row.fetch("name") 11 | @definition = row.fetch("definition") 12 | end 13 | 14 | def ==(other) 15 | name == other.name && definition == other.definition 16 | end 17 | 18 | def to_schema 19 | <<~SCHEMA.indent(2) 20 | create_function :#{name}, sql_definition: <<-'SQL' 21 | #{definition.indent(4).rstrip} 22 | SQL 23 | SCHEMA 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/support/migration_helpers.rb: -------------------------------------------------------------------------------- 1 | module MigrationsHelper 2 | def run_migration(migration, directions) 3 | silence_stream($stdout) do 4 | Array.wrap(directions).each do |direction| 5 | migration.migrate(direction) 6 | end 7 | end 8 | end 9 | 10 | def migration_class 11 | if Rails::VERSION::MAJOR >= 5 12 | ::ActiveRecord::Migration[5.0] 13 | else 14 | ::ActiveRecord::Migration 15 | end 16 | end 17 | 18 | def connection 19 | @_connection ||= ActiveRecord::Base.connection 20 | end 21 | end 22 | 23 | RSpec.configure do |config| 24 | config.include MigrationsHelper 25 | end 26 | -------------------------------------------------------------------------------- /spec/support/database_reset.rb: -------------------------------------------------------------------------------- 1 | module DatabaseReset 2 | def self.call 3 | connection = ActiveRecord::Base.connection 4 | connection.execute("SET search_path TO DEFAULT;") 5 | 6 | connection.execute <<~SQL 7 | DO $$ 8 | DECLARE 9 | schema_name TEXT; 10 | BEGIN 11 | FOR schema_name IN 12 | SELECT nspname FROM pg_namespace 13 | WHERE nspname NOT LIKE 'pg_%' 14 | AND nspname != 'information_schema' 15 | LOOP 16 | EXECUTE format('DROP SCHEMA IF EXISTS %I CASCADE', schema_name); 17 | END LOOP; 18 | END $$; 19 | SQL 20 | 21 | connection.execute("CREATE SCHEMA public;") 22 | connection.schema_search_path = "public" 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | require "standard/rake" 4 | 5 | namespace :dummy do 6 | require_relative "spec/dummy/config/application" 7 | Dummy::Application.load_tasks 8 | end 9 | 10 | task(:spec).clear 11 | desc "Run specs other than spec/acceptance" 12 | RSpec::Core::RakeTask.new("spec") do |task| 13 | task.exclude_pattern = "spec/acceptance/**/*_spec.rb" 14 | task.verbose = false 15 | end 16 | 17 | desc "Run acceptance specs in spec/acceptance" 18 | RSpec::Core::RakeTask.new("spec:acceptance") do |task| 19 | task.pattern = "spec/acceptance/**/*_spec.rb" 20 | task.verbose = false 21 | end 22 | 23 | desc "Run the specs and acceptance tests" 24 | task default: %w[spec spec:acceptance standard] 25 | -------------------------------------------------------------------------------- /lib/generators/fx/trigger/USAGE: -------------------------------------------------------------------------------- 1 | Description: 2 | Create a new database trigger for your application. This will create a new 3 | trigger definition file and the accompanying migration. 4 | 5 | If a trigger of the given name already exists, create a new version of the 6 | trigger and a migration to replace the old version with the new. 7 | 8 | When --no-migration is passed, skips generating a migration. 9 | 10 | Examples: 11 | 12 | rails generate fx:trigger test 13 | 14 | create: db/triggers/test_v01.sql 15 | create: db/migrate/[TIMESTAMP]_create_trigger_test.rb 16 | 17 | rails generate fx:trigger test 18 | 19 | create: db/triggers/test_v02.sql 20 | create: db/migrate/[TIMESTAMP]_update_trigger_test_to_version_2.rb 21 | -------------------------------------------------------------------------------- /lib/fx/configuration.rb: -------------------------------------------------------------------------------- 1 | module Fx 2 | # F(x)'s configuration object. 3 | class Configuration 4 | # The F(x) database adapter instance to use when executing SQL. 5 | # 6 | # Defaults to an instance of {Fx::Adapters::Postgres} 7 | # @return [Fx::Adapters::Postgres] Fx adapter 8 | attr_accessor :database 9 | 10 | # Prioritizes the order in the schema.rb of functions before other 11 | # statements in order to make directly schema load work when using functions 12 | # in statements below, i.e.: default column values. 13 | # 14 | # Defaults to false 15 | # @return [Boolean] Boolean 16 | attr_accessor :dump_functions_at_beginning_of_schema 17 | 18 | def initialize 19 | @database = Fx::Adapters::Postgres.new 20 | @dump_functions_at_beginning_of_schema = false 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /bin/yard: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'yard' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("yard", "yard") 28 | -------------------------------------------------------------------------------- /bin/rspec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rspec' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("rspec-core", "rspec") 28 | -------------------------------------------------------------------------------- /bin/standardrb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'standardrb' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("standard", "standardrb") 28 | -------------------------------------------------------------------------------- /lib/generators/fx/name_helper.rb: -------------------------------------------------------------------------------- 1 | module Fx 2 | module Generators 3 | # @api private 4 | class NameHelper 5 | def self.format_for_migration(name) 6 | if name.include?(".") 7 | "\"#{name}\"" 8 | else 9 | ":#{name}" 10 | end 11 | end 12 | 13 | def self.format_table_name_from_hash(table_hash) 14 | name = table_hash["table_name"] || table_hash["on"] 15 | 16 | if name.nil? 17 | raise( 18 | ArgumentError, 19 | "Either `table_name:NAME` or `on:NAME` must be specified" 20 | ) 21 | end 22 | 23 | format_for_migration(name) 24 | end 25 | 26 | def self.validate_and_format(name) 27 | raise ArgumentError, "Name cannot be blank" if name.blank? 28 | 29 | format_for_migration(name) 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | ENV["RAILS_ENV"] = "test" 2 | 3 | require File.expand_path("../dummy/config/environment", __FILE__) 4 | Dir["spec/support/**/*.rb"].sort.each { |file| load file } 5 | 6 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 7 | require "fx" 8 | 9 | RSpec.configure do |config| 10 | config.order = "random" 11 | config.disable_monkey_patching! 12 | 13 | config.define_derived_metadata(file_path: %r{spec/(fx|features)/}) do |metadata| 14 | metadata[:db] = true 15 | end 16 | 17 | config.before(:suite) do 18 | DatabaseReset.call 19 | end 20 | 21 | config.around(:each, db: true) do |example| 22 | DatabaseReset.call 23 | 24 | example.run 25 | 26 | DatabaseReset.call 27 | end 28 | 29 | unless defined?(silence_stream) 30 | require "active_support/testing/stream" 31 | config.include ActiveSupport::Testing::Stream 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/fx_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe Fx do 4 | it "has a version number" do 5 | expect(Fx::VERSION).to be_present 6 | end 7 | 8 | it "loads fx into ActiveRecord" do 9 | expect(Fx.load).to eq(true) 10 | expect(ActiveRecord::Migration::CommandRecorder).to include(Fx::CommandRecorder) 11 | expect(ActiveRecord::ConnectionAdapters::AbstractAdapter).to include(Fx::Statements) 12 | expect(ActiveRecord::SchemaDumper).to include(Fx::SchemaDumper) 13 | end 14 | 15 | it "allows configuration" do 16 | adapter = double("Fx Adapter") 17 | 18 | Fx.configure do |config| 19 | config.database = adapter 20 | config.dump_functions_at_beginning_of_schema = true 21 | end 22 | 23 | expect(Fx.configuration.database).to eq(adapter) 24 | expect(Fx.configuration.dump_functions_at_beginning_of_schema).to eq(true) 25 | 26 | Fx.configuration = nil 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/fx/adapters/postgres/query_executor.rb: -------------------------------------------------------------------------------- 1 | module Fx 2 | module Adapters 3 | class Postgres 4 | # Executes database queries and maps results to domain objects. 5 | # @api private 6 | class QueryExecutor 7 | def self.call(...) 8 | new(...).call 9 | end 10 | 11 | def initialize(connection:, query:, model_class:) 12 | @connection = connection 13 | @query = query 14 | @model_class = model_class 15 | end 16 | 17 | # Executes the query and maps results to domain objects. 18 | # 19 | # @return [Array] Array of domain objects (Functions or Triggers) 20 | def call 21 | results_from_postgres.map { |result| model_class.new(result) } 22 | end 23 | 24 | private 25 | 26 | attr_reader :connection, :query, :model_class 27 | 28 | def results_from_postgres 29 | connection.execute(query) 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/support/definition_helpers.rb: -------------------------------------------------------------------------------- 1 | module DefinitionHelpers 2 | def with_function_definition(name:, sql_definition:, version: 1, &block) 3 | definition = Fx::Definition.function(name: name, version: version) 4 | 5 | with_definition( 6 | definition: definition, 7 | sql_definition: sql_definition, 8 | block: block 9 | ) 10 | end 11 | 12 | def with_trigger_definition(name:, sql_definition:, version: 1, &block) 13 | definition = Fx::Definition.trigger(name: name, version: version) 14 | 15 | with_definition( 16 | definition: definition, 17 | sql_definition: sql_definition, 18 | block: block 19 | ) 20 | end 21 | 22 | def with_definition(definition:, sql_definition:, block:) 23 | FileUtils.mkdir_p(File.dirname(definition.full_path)) 24 | File.write(definition.full_path, sql_definition) 25 | block.call 26 | ensure 27 | File.delete definition.full_path 28 | end 29 | end 30 | 31 | RSpec.configure do |config| 32 | config.include DefinitionHelpers 33 | end 34 | -------------------------------------------------------------------------------- /spec/fx/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe Fx::Configuration do 4 | it "defaults the database adapter to postgres" do 5 | configuration = Fx::Configuration.new 6 | 7 | expect(configuration.database).to be_a(Fx::Adapters::Postgres) 8 | end 9 | 10 | it "defaults `dump_functions_at_beginning_of_schema` to false" do 11 | configuration = Fx::Configuration.new 12 | 13 | expect(configuration.dump_functions_at_beginning_of_schema).to eq(false) 14 | end 15 | 16 | it "allows the database adapter to be set" do 17 | configuration = Fx::Configuration.new 18 | adapter = double("Fx Adapter") 19 | 20 | configuration.database = adapter 21 | 22 | expect(configuration.database).to eq(adapter) 23 | end 24 | 25 | it "allows `dump_functions_at_beginning_of_schema` to be set" do 26 | configuration = Fx::Configuration.new 27 | 28 | configuration.dump_functions_at_beginning_of_schema = true 29 | 30 | expect(configuration.dump_functions_at_beginning_of_schema).to eq(true) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We love contributions from everyone. By participating in this project, you 4 | agree to abide by our [code of conduct]. 5 | 6 | [code of conduct]: https://thoughtbot.com/open-source-code-of-conduct 7 | 8 | ## Contributing Code 9 | 10 | 1. Fork the repository. 11 | 2. Run `bin/setup`, which will install dependencies and create the dummy 12 | application database. 13 | 3. Run `rake` to verify that the tests pass against the version of Rails you are 14 | running locally. 15 | 4. Make your change with new passing tests, following existing style. 16 | 5. Write a [good commit message], push your fork, and submit a pull request. 17 | 6. CI will run the test suite on all configured versions of Ruby and Rails. 18 | Address any failures. 19 | 20 | [good commit message]: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html 21 | 22 | Others will give constructive feedback. This is a time for discussion and 23 | improvements, and making the necessary changes will be required before we can 24 | merge the contribution. 25 | -------------------------------------------------------------------------------- /lib/fx/schema_dumper.rb: -------------------------------------------------------------------------------- 1 | module Fx 2 | # @api private 3 | module SchemaDumper 4 | def tables(stream) 5 | if Fx.configuration.dump_functions_at_beginning_of_schema 6 | functions(stream) 7 | end 8 | 9 | super 10 | 11 | unless Fx.configuration.dump_functions_at_beginning_of_schema 12 | functions(stream) 13 | end 14 | 15 | triggers(stream) 16 | end 17 | 18 | private 19 | 20 | def functions(stream) 21 | dumpable_functions_in_database = Fx.database.functions 22 | 23 | dumpable_functions_in_database.each do |function| 24 | stream.puts(function.to_schema) 25 | end 26 | 27 | stream.puts if dumpable_functions_in_database.any? 28 | end 29 | 30 | def triggers(stream) 31 | dumpable_triggers_in_database = Fx.database.triggers 32 | 33 | if dumpable_triggers_in_database.any? 34 | stream.puts 35 | end 36 | 37 | dumpable_triggers_in_database.each do |trigger| 38 | stream.puts(trigger.to_schema) 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2016 Teo Ljungberg 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /fx.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path("../lib", __FILE__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require "fx/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "fx" 7 | spec.version = Fx::VERSION 8 | spec.authors = ["Teo Ljungberg"] 9 | spec.email = ["teo@teoljungberg.com"] 10 | spec.summary = "Support for database functions and triggers in Rails migrations" 11 | spec.description = <<~DESCRIPTION 12 | Adds methods to ActiveRecord::Migration to create and manage database functions 13 | and triggers in Rails 14 | DESCRIPTION 15 | spec.homepage = "https://github.com/teoljungberg/fx" 16 | spec.license = "MIT" 17 | spec.metadata = { 18 | "bug_tracker_uri" => "#{spec.homepage}/issues", 19 | "changelog_uri" => "#{spec.homepage}/blob/v#{spec.version}/CHANGELOG.md", 20 | "homepage_uri" => spec.homepage, 21 | "source_code_uri" => spec.homepage 22 | } 23 | 24 | spec.files = `git ls-files -z`.split("\x0") 25 | spec.require_paths = ["lib"] 26 | 27 | spec.add_dependency "activerecord", ">= 7.2", "< 8.2" 28 | spec.add_dependency "railties", ">= 7.2", "< 8.2" 29 | 30 | spec.required_ruby_version = ">= 3.2" 31 | end 32 | -------------------------------------------------------------------------------- /lib/fx/adapters/postgres/connection.rb: -------------------------------------------------------------------------------- 1 | module Fx 2 | module Adapters 3 | class Postgres 4 | # Decorates an ActiveRecord connection with methods that help determine 5 | # the connections capabilities. 6 | # 7 | # Every attempt is made to use the versions of these methods defined by 8 | # Rails where they are available and public before falling back to our own 9 | # implementations for older Rails versions. 10 | # 11 | # @api private 12 | class Connection < SimpleDelegator 13 | # PostgreSQL version constants for feature support 14 | POSTGRES_VERSIONS = { 15 | # PostgreSQL 10.0 - introduced DROP FUNCTION without args 16 | # https://www.postgresql.org/docs/10/sql-dropfunction.html 17 | v10: 10_00_00 18 | }.freeze 19 | 20 | def support_drop_function_without_args 21 | server_version >= POSTGRES_VERSIONS[:v10] 22 | end 23 | 24 | private 25 | 26 | def server_version 27 | undecorated_connection.raw_connection.server_version 28 | end 29 | 30 | def undecorated_connection 31 | __getobj__ 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/fx/adapters/postgres/functions_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe Fx::Adapters::Postgres::Functions, :db do 4 | describe ".all" do 5 | it "returns `Function` objects" do 6 | connection = ActiveRecord::Base.connection 7 | connection.execute <<~SQL 8 | CREATE OR REPLACE FUNCTION test() 9 | RETURNS text AS $$ 10 | BEGIN 11 | RETURN 'test'; 12 | END; 13 | $$ LANGUAGE plpgsql; 14 | SQL 15 | 16 | functions = Fx::Adapters::Postgres::Functions.all(connection) 17 | 18 | first = functions.first 19 | expect(functions.size).to eq(1) 20 | expect(first.name).to eq("test") 21 | expect(first.definition).to eq(<<~SQL) 22 | CREATE OR REPLACE FUNCTION public.test() 23 | RETURNS text 24 | LANGUAGE plpgsql 25 | AS $function$ 26 | BEGIN 27 | RETURN 'test'; 28 | END; 29 | $function$ 30 | SQL 31 | 32 | connection.execute "CREATE SCHEMA IF NOT EXISTS other;" 33 | connection.execute "SET search_path = 'other';" 34 | 35 | functions = Fx::Adapters::Postgres::Functions.all(connection) 36 | 37 | expect(functions).to be_empty 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/generators/fx/migration_helper.rb: -------------------------------------------------------------------------------- 1 | module Fx 2 | module Generators 3 | # @api private 4 | class MigrationHelper 5 | def initialize(options) 6 | @options = options 7 | end 8 | 9 | def skip_creation? 10 | !should_create_migration? 11 | end 12 | 13 | def update_migration_class_name(object_type:, class_name:, version:) 14 | "Update#{object_type.capitalize}#{class_name}ToVersion#{version}" 15 | end 16 | 17 | def migration_template_info( 18 | object_type:, 19 | file_name:, 20 | updating_existing:, 21 | version: 22 | ) 23 | if updating_existing 24 | { 25 | template: "db/migrate/update_#{object_type}.erb", 26 | filename: "db/migrate/update_#{object_type}_#{file_name}_to_version_#{version}.rb" 27 | } 28 | else 29 | { 30 | template: "db/migrate/create_#{object_type}.erb", 31 | filename: "db/migrate/create_#{object_type}_#{file_name}.rb" 32 | } 33 | end 34 | end 35 | 36 | private 37 | 38 | attr_reader :options 39 | 40 | def should_create_migration? 41 | options.fetch(:migration, true) 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: master 6 | pull_request: 7 | branches: "*" 8 | 9 | jobs: 10 | tests: 11 | name: Ruby ${{ matrix.ruby }}, Rails ${{ matrix.rails }} 12 | runs-on: ubuntu-latest 13 | continue-on-error: ${{ matrix.ruby == 'head' }} 14 | 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | ruby: ["3.2", "3.3", "3.4", "head"] 19 | rails: ["7.2", "8.0", "8.1"] 20 | 21 | services: 22 | postgres: 23 | image: postgres:14 24 | env: 25 | POSTGRES_USER: postgres 26 | POSTGRES_HOST_AUTH_METHOD: trust 27 | ports: 28 | - 5432:5432 29 | # Set health checks to wait until postgres has started 30 | options: >- 31 | --health-cmd pg_isready 32 | --health-interval 10s 33 | --health-timeout 5s 34 | --health-retries 5 35 | 36 | env: 37 | POSTGRES_USER: postgres 38 | 39 | steps: 40 | - uses: actions/checkout@v3 41 | 42 | - name: Set up Ruby 43 | uses: ruby/setup-ruby@v1 44 | with: 45 | ruby-version: ${{ matrix.ruby }} 46 | bundler-cache: true 47 | rubygems: latest 48 | 49 | - name: Setup environment 50 | run: bin/setup 51 | 52 | - name: Run tests 53 | run: bundle exec rake 54 | -------------------------------------------------------------------------------- /lib/fx/adapters/postgres/triggers.rb: -------------------------------------------------------------------------------- 1 | require "fx/trigger" 2 | require "fx/adapters/postgres/query_executor" 3 | 4 | module Fx 5 | module Adapters 6 | class Postgres 7 | # Fetches defined triggers from the postgres connection. 8 | # @api private 9 | class Triggers 10 | # The SQL query used by F(x) to retrieve the triggers considered 11 | # dumpable into `db/schema.rb`. 12 | TRIGGERS_WITH_DEFINITIONS_QUERY = <<~SQL.freeze 13 | SELECT 14 | pt.tgname AS name, 15 | pg_get_triggerdef(pt.oid) AS definition 16 | FROM pg_trigger pt 17 | JOIN pg_class pc 18 | ON (pc.oid = pt.tgrelid) 19 | JOIN pg_proc pp 20 | ON (pp.oid = pt.tgfoid) 21 | JOIN pg_namespace pn 22 | ON pn.oid = pc.relnamespace 23 | WHERE pn.nspname = ANY (current_schemas(false)) 24 | AND pt.tgname NOT ILIKE '%constraint%' 25 | AND pt.tgname NOT ILIKE 'pg%' 26 | ORDER BY pc.oid; 27 | SQL 28 | 29 | # Wraps #all as a static facade. 30 | # 31 | # @return [Array] 32 | def self.all(connection) 33 | Fx::Adapters::Postgres::QueryExecutor.call( 34 | connection: connection, 35 | query: TRIGGERS_WITH_DEFINITIONS_QUERY, 36 | model_class: Fx::Trigger 37 | ) 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/fx/adapters/postgres/functions.rb: -------------------------------------------------------------------------------- 1 | require "fx/function" 2 | require "fx/adapters/postgres/query_executor" 3 | 4 | module Fx 5 | module Adapters 6 | class Postgres 7 | # Fetches defined functions from the postgres connection. 8 | # @api private 9 | class Functions 10 | # The SQL query used by F(x) to retrieve the functions considered 11 | # dumpable into `db/schema.rb`. 12 | FUNCTIONS_WITH_DEFINITIONS_QUERY = <<~SQL.freeze 13 | SELECT 14 | pp.proname AS name, 15 | pg_get_functiondef(pp.oid) AS definition 16 | FROM pg_proc pp 17 | JOIN pg_namespace pn 18 | ON pn.oid = pp.pronamespace 19 | LEFT JOIN pg_depend pd 20 | ON pd.objid = pp.oid AND pd.deptype = 'e' 21 | LEFT JOIN pg_aggregate pa 22 | ON pa.aggfnoid = pp.oid 23 | WHERE pn.nspname = ANY (current_schemas(false)) 24 | AND pd.objid IS NULL 25 | AND pa.aggfnoid IS NULL 26 | ORDER BY pp.oid; 27 | SQL 28 | 29 | # Wraps #all as a static facade. 30 | # 31 | # @return [Array] 32 | def self.all(connection) 33 | Fx::Adapters::Postgres::QueryExecutor.call( 34 | connection: connection, 35 | query: FUNCTIONS_WITH_DEFINITIONS_QUERY, 36 | model_class: Fx::Function 37 | ) 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/generators/fx/version_helper.rb: -------------------------------------------------------------------------------- 1 | module Fx 2 | module Generators 3 | # @api private 4 | class VersionHelper 5 | def initialize(file_name:, definition_path:) 6 | @file_name = file_name 7 | @definition_path = definition_path 8 | end 9 | 10 | def previous_version 11 | @previous_version ||= existing_versions.max || 0 12 | end 13 | 14 | def current_version 15 | previous_version.next 16 | end 17 | 18 | def updating_existing? 19 | previous_version > 0 20 | end 21 | 22 | def creating_new? 23 | previous_version == 0 24 | end 25 | 26 | def definition_for_version(version:, type:) 27 | case type 28 | when :function 29 | Fx::Definition.function(name: file_name, version: version) 30 | when :trigger 31 | Fx::Definition.trigger(name: file_name, version: version) 32 | else 33 | raise( 34 | ArgumentError, 35 | "Unknown type: #{type}. Must be :function or :trigger" 36 | ) 37 | end 38 | end 39 | 40 | private 41 | 42 | VERSION_PATTERN = /v(\d+)/ 43 | private_constant :VERSION_PATTERN 44 | 45 | attr_reader :file_name, :definition_path 46 | 47 | def existing_versions 48 | Dir 49 | .glob("#{file_name}_v*.sql", base: definition_path) 50 | .map { |file| file[VERSION_PATTERN, 1].to_i } 51 | .compact 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/fx/definition.rb: -------------------------------------------------------------------------------- 1 | module Fx 2 | # @api private 3 | class Definition 4 | FUNCTION = "function".freeze 5 | TRIGGER = "trigger".freeze 6 | 7 | def self.function(name:, version:) 8 | new(name: name, version: version, type: FUNCTION) 9 | end 10 | 11 | def self.trigger(name:, version:) 12 | new(name: name, version: version, type: TRIGGER) 13 | end 14 | 15 | def initialize(name:, version:, type:) 16 | @name = name 17 | @version_number = version.to_i 18 | @type = type 19 | end 20 | 21 | def to_sql 22 | content = File.read(find_file || full_path) 23 | raise "Define #{type} in #{path} before migrating." if content.empty? 24 | 25 | content 26 | end 27 | 28 | def full_path 29 | Rails.root.join(path) 30 | end 31 | 32 | def path 33 | @_path ||= File.join("db", type.pluralize, filename) 34 | end 35 | 36 | def version 37 | version_number.to_s.rjust(2, "0") 38 | end 39 | 40 | private 41 | 42 | attr_reader :name, :version_number, :type 43 | 44 | def filename 45 | @_filename ||= "#{name}_v#{version}.sql" 46 | end 47 | 48 | def find_file 49 | migration_paths.lazy 50 | .map { |migration_path| File.expand_path(File.join("..", "..", path), migration_path) } 51 | .find { |definition_path| File.exist?(definition_path) } 52 | end 53 | 54 | def migration_paths 55 | Rails.application.config.paths["db/migrate"].expanded 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /spec/fx/trigger_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe Fx::Trigger do 4 | describe "#<=>" do 5 | it "delegates to `name`" do 6 | trigger_a = Fx::Trigger.new( 7 | "name" => "name_a", 8 | "definition" => "some definition" 9 | ) 10 | trigger_b = Fx::Trigger.new( 11 | "name" => "name_b", 12 | "definition" => "some definition" 13 | ) 14 | trigger_c = Fx::Trigger.new( 15 | "name" => "name_c", 16 | "definition" => "some definition" 17 | ) 18 | 19 | expect(trigger_b).to be_between(trigger_a, trigger_c) 20 | end 21 | end 22 | 23 | describe "#==" do 24 | it "compares `name` and `definition`" do 25 | trigger_a = Fx::Trigger.new( 26 | "name" => "name_a", 27 | "definition" => "some definition" 28 | ) 29 | trigger_b = Fx::Trigger.new( 30 | "name" => "name_b", 31 | "definition" => "some other definition" 32 | ) 33 | 34 | expect(trigger_a).not_to eq(trigger_b) 35 | end 36 | end 37 | 38 | describe "#to_schema" do 39 | it "returns a schema compatible version of the trigger" do 40 | trigger = Fx::Trigger.new( 41 | "name" => "uppercase_users_name", 42 | "definition" => "CREATE TRIGGER uppercase_users_name ..." 43 | ) 44 | 45 | expect(trigger.to_schema).to eq(<<-EOS) 46 | create_trigger :uppercase_users_name, sql_definition: <<-\SQL 47 | CREATE TRIGGER uppercase_users_name ... 48 | SQL 49 | EOS 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /spec/features/triggers/migrations_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe "Trigger migrations", :db do 4 | around do |example| 5 | connection.execute <<~SQL 6 | CREATE TABLE users ( 7 | id int PRIMARY KEY, 8 | name varchar(256), 9 | upper_name varchar(256) 10 | ); 11 | SQL 12 | Fx.database.create_function <<~SQL 13 | CREATE OR REPLACE FUNCTION uppercase_users_name() 14 | RETURNS trigger AS $$ 15 | BEGIN 16 | NEW.upper_name = UPPER(NEW.name); 17 | RETURN NEW; 18 | END; 19 | $$ LANGUAGE plpgsql; 20 | SQL 21 | sql_definition = <<~SQL 22 | CREATE TRIGGER uppercase_users_name 23 | BEFORE INSERT ON users 24 | FOR EACH ROW 25 | EXECUTE FUNCTION uppercase_users_name(); 26 | SQL 27 | with_trigger_definition( 28 | name: :uppercase_users_name, 29 | sql_definition: sql_definition 30 | ) do 31 | example.run 32 | end 33 | end 34 | 35 | it "can run migrations that create triggers" do 36 | migration = Class.new(migration_class) do 37 | def up 38 | create_trigger :uppercase_users_name 39 | end 40 | end 41 | 42 | expect { run_migration(migration, :up) }.not_to raise_error 43 | end 44 | 45 | it "can run migrations that drop triggers" do 46 | connection.create_trigger(:uppercase_users_name) 47 | 48 | migration = Class.new(migration_class) do 49 | def up 50 | drop_trigger :uppercase_users_name, on: :users 51 | end 52 | end 53 | 54 | expect { run_migration(migration, :up) }.not_to raise_error 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /spec/generators/fx/function/function_generator_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "generators/fx/function/function_generator" 3 | 4 | RSpec.describe Fx::Generators::FunctionGenerator, :generator do 5 | it "creates a function definition file, and a migration" do 6 | migration = file("db/migrate/create_function_test.rb") 7 | function_definition = file("db/functions/test_v01.sql") 8 | 9 | run_generator(described_class, ["test"]) 10 | 11 | expect(function_definition).to exist 12 | expect_to_be_a_migration(migration) 13 | expect(migration_content(migration)).to include("CreateFunctionTest") 14 | end 15 | 16 | context "when passed --no-migration" do 17 | it "creates a only function definition file" do 18 | migration = file("db/migrate/create_function_test.rb") 19 | function_definition = file("db/functions/test_v01.sql") 20 | 21 | run_generator(described_class, ["test"], {migration: false}) 22 | 23 | expect(function_definition).to exist 24 | expect(migration).not_to exist 25 | end 26 | end 27 | 28 | it "updates an existing function" do 29 | with_function_definition( 30 | name: "test", 31 | version: 1, 32 | sql_definition: "hello" 33 | ) do 34 | migration = file("db/migrate/update_function_test_to_version_2.rb") 35 | function_definition = file("db/functions/test_v02.sql") 36 | 37 | run_generator(described_class, ["test"]) 38 | 39 | expect(function_definition).to exist 40 | expect_to_be_a_migration(migration) 41 | expect(migration_content(migration)).to include( 42 | "UpdateFunctionTestToVersion2" 43 | ) 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/fx/adapters/postgres/triggers_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe Fx::Adapters::Postgres::Triggers, :db do 4 | describe ".all" do 5 | it "returns `Trigger` objects" do 6 | connection = ActiveRecord::Base.connection 7 | connection.execute <<~SQL 8 | CREATE TABLE users ( 9 | id int PRIMARY KEY, 10 | name varchar(256), 11 | upper_name varchar(256) 12 | ); 13 | SQL 14 | connection.execute <<~SQL 15 | CREATE OR REPLACE FUNCTION uppercase_users_name() 16 | RETURNS trigger AS $$ 17 | BEGIN 18 | NEW.upper_name = UPPER(NEW.name); 19 | RETURN NEW; 20 | END; 21 | $$ LANGUAGE plpgsql; 22 | SQL 23 | connection.execute <<~SQL 24 | CREATE TRIGGER uppercase_users_name 25 | BEFORE INSERT ON users 26 | FOR EACH ROW 27 | EXECUTE FUNCTION uppercase_users_name(); 28 | SQL 29 | 30 | triggers = Fx::Adapters::Postgres::Triggers.all(connection) 31 | 32 | first = triggers.first 33 | expect(triggers.size).to eq(1) 34 | expect(first.name).to eq("uppercase_users_name") 35 | expect(first.definition).to include("BEFORE INSERT") 36 | expect(first.definition).to match(/ON [public.ser|]/) 37 | expect(first.definition).to include("FOR EACH ROW") 38 | expect(first.definition).to include("EXECUTE FUNCTION uppercase_users_name()") 39 | 40 | connection.execute "CREATE SCHEMA IF NOT EXISTS other;" 41 | connection.execute "SET search_path = 'other';" 42 | 43 | triggers = Fx::Adapters::Postgres::Triggers.all(connection) 44 | 45 | expect(triggers).to be_empty 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/features/functions/migrations_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe "Function migrations", :db do 4 | around do |example| 5 | sql_definition = <<~SQL 6 | CREATE OR REPLACE FUNCTION test() 7 | RETURNS text AS $$ 8 | BEGIN 9 | RETURN 'test'; 10 | END; 11 | $$ LANGUAGE plpgsql; 12 | SQL 13 | with_function_definition(name: :test, sql_definition: sql_definition) do 14 | example.run 15 | end 16 | end 17 | 18 | it "can run migrations that create functions" do 19 | migration = Class.new(migration_class) do 20 | def up 21 | create_function :test 22 | end 23 | end 24 | 25 | expect { run_migration(migration, :up) }.not_to raise_error 26 | end 27 | 28 | it "can run migrations that drop functions" do 29 | connection.create_function(:test) 30 | 31 | migration = Class.new(migration_class) do 32 | def up 33 | drop_function :test 34 | end 35 | end 36 | 37 | expect { run_migration(migration, :up) }.not_to raise_error 38 | end 39 | 40 | it "can run migrations that updates functions" do 41 | connection.create_function(:test) 42 | 43 | sql_definition = <<~SQL 44 | CREATE OR REPLACE FUNCTION test() 45 | RETURNS text AS $$ 46 | BEGIN 47 | RETURN 'testest'; 48 | END; 49 | $$ LANGUAGE plpgsql; 50 | SQL 51 | with_function_definition( 52 | name: :test, 53 | version: 2, 54 | sql_definition: sql_definition 55 | ) do 56 | migration = Class.new(migration_class) do 57 | def change 58 | update_function :test, version: 2, revert_to_version: 1 59 | end 60 | end 61 | 62 | expect { run_migration(migration, :change) }.not_to raise_error 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/fx.rb: -------------------------------------------------------------------------------- 1 | require "rails" 2 | 3 | require "fx/version" 4 | require "fx/adapters/postgres" 5 | require "fx/command_recorder" 6 | require "fx/configuration" 7 | require "fx/definition" 8 | require "fx/function" 9 | require "fx/statements" 10 | require "fx/schema_dumper" 11 | require "fx/trigger" 12 | require "fx/railtie" 13 | 14 | # F(x) adds methods `ActiveRecord::Migration` to create and manage database 15 | # triggers and functions in Rails applications. 16 | module Fx 17 | # Hooks Fx into Rails. 18 | # 19 | # Enables fx migration methods, migration reversability, and `schema.rb` 20 | # dumping. 21 | def self.load 22 | ActiveRecord::Migration::CommandRecorder.include(Fx::CommandRecorder) 23 | ActiveRecord::ConnectionAdapters::AbstractAdapter.include(Fx::Statements) 24 | ActiveRecord::SchemaDumper.prepend(Fx::SchemaDumper) 25 | 26 | true 27 | end 28 | 29 | # @return [Fx::Configuration] F(x)'s current configuration 30 | def self.configuration 31 | @_configuration ||= Fx::Configuration.new 32 | end 33 | 34 | # Set F(x)'s configuration 35 | # 36 | # @param config [Fx::Configuration] 37 | def self.configuration=(config) 38 | @_configuration = config 39 | end 40 | 41 | # Modify F(x)'s current configuration 42 | # 43 | # @yieldparam [Fx::Configuration] config current F(x) config 44 | # ``` 45 | # Fx.configure do |config| 46 | # config.database = Fx::Adapters::Postgres 47 | # config.dump_functions_at_beginning_of_schema = true 48 | # end 49 | # ``` 50 | def self.configure 51 | yield configuration 52 | end 53 | 54 | # The current database adapter used by F(x). 55 | # 56 | # This defaults to {Fx::Adapters::Postgres} but can be overridden 57 | # via {Configuration}. 58 | def self.database 59 | configuration.database 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /spec/acceptance/user_manages_functions_spec.rb: -------------------------------------------------------------------------------- 1 | require "acceptance_helper" 2 | 3 | RSpec.describe "User manages functions" do 4 | it "handles simple functions" do 5 | successfully "rails generate fx:function test" 6 | write_function_definition "test_v01", <<~SQL 7 | CREATE OR REPLACE FUNCTION test() 8 | RETURNS text AS $$ 9 | BEGIN 10 | RETURN 'test'; 11 | END; 12 | $$ LANGUAGE plpgsql; 13 | SQL 14 | successfully "rake db:migrate" 15 | 16 | result = execute("SELECT * FROM test() AS result") 17 | expect(result).to eq("result" => "test") 18 | 19 | successfully "rails generate fx:function test" 20 | verify_identical_definitions( 21 | "db/functions/test_v01.sql", 22 | "db/functions/test_v02.sql" 23 | ) 24 | write_function_definition "test_v02", <<~SQL 25 | CREATE OR REPLACE FUNCTION test() 26 | RETURNS text AS $$ 27 | BEGIN 28 | RETURN 'testest'; 29 | END; 30 | $$ LANGUAGE plpgsql; 31 | SQL 32 | successfully "rake db:migrate" 33 | 34 | result = execute("SELECT * FROM test() AS result") 35 | expect(result).to eq("result" => "testest") 36 | end 37 | 38 | it "handles functions with arguments" do 39 | successfully "rails generate fx:function adder" 40 | write_function_definition "adder_v01", <<~SQL 41 | CREATE FUNCTION adder(x int, y int) 42 | RETURNS int AS $$ 43 | BEGIN 44 | RETURN $1 + $2; 45 | END; 46 | $$ LANGUAGE plpgsql; 47 | SQL 48 | successfully "rake db:migrate" 49 | 50 | result = execute("SELECT * FROM adder(1, 2) AS result") 51 | result["result"] = result["result"].to_i 52 | expect(result).to eq("result" => 3) 53 | 54 | successfully "rails destroy fx:function adder" 55 | successfully "rake db:migrate" 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /spec/acceptance_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler" 2 | 3 | ENV["RAILS_ENV"] = "test" 4 | 5 | RSpec.configure do |config| 6 | config.around(:each) do |example| 7 | Dir.chdir("spec/dummy") do 8 | DatabaseReset.call 9 | 10 | example.run 11 | 12 | DatabaseReset.call 13 | end 14 | end 15 | 16 | config.before(:suite) do 17 | Dir.chdir("spec/dummy") do 18 | system [ 19 | "git init -b master 1>/dev/null", 20 | "git config user.email 'fx@example.com'", 21 | "git config user.name 'Fx'", 22 | "git add -A", 23 | "git commit --no-gpg-sign --message 'initial' 1>/dev/null" 24 | ].join(" && ") 25 | end 26 | end 27 | 28 | config.after(:suite) do 29 | Dir.chdir("spec/dummy") do 30 | ActiveRecord::Base.connection.disconnect! 31 | DatabaseReset.call 32 | system [ 33 | "git add -A", 34 | "git reset --hard HEAD 1>/dev/null", 35 | "rm -rf .git/ 1>/dev/null" 36 | ].join(" && ") 37 | end 38 | end 39 | 40 | def successfully(command) 41 | `RAILS_ENV=test #{command}` 42 | expect($?.exitstatus).to eq(0), "'#{command}' was unsuccessful" 43 | end 44 | 45 | def write_function_definition(file, contents) 46 | write_definition(file, contents, "functions") 47 | end 48 | 49 | def write_trigger_definition(file, contents) 50 | write_definition(file, contents, "triggers") 51 | end 52 | 53 | def write_definition(file, contents, directory) 54 | File.open("db/#{directory}/#{file}.sql", File::WRONLY) do |definition| 55 | definition.truncate(0) 56 | definition.write(contents) 57 | end 58 | end 59 | 60 | def verify_identical_definitions(def_a, def_b) 61 | successfully "cmp #{def_a} #{def_b}" 62 | end 63 | 64 | def execute(command) 65 | ActiveRecord::Base.connection.execute(command).first 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /spec/support/generator_setup.rb: -------------------------------------------------------------------------------- 1 | module GeneratorSetup 2 | RAILS_ROOT = Pathname.new(File.expand_path("../../../tmp/dummy", __dir__)).freeze 3 | MIGRATION_TIMESTAMP_PATTERN = /\A\d{14}_/ 4 | 5 | def run_generator(generator_class, args = [], options = {}) 6 | allow(Rails).to receive(:root).and_return(RAILS_ROOT) 7 | generator = generator_class.new(args, options, destination_root: RAILS_ROOT) 8 | 9 | silence_stream($stdout) do 10 | generator.invoke_all 11 | end 12 | end 13 | 14 | def file(relative_path) 15 | RAILS_ROOT.join(relative_path) 16 | end 17 | 18 | def migration_content(file_path) 19 | migration_path = find_migration_files(file_path).first 20 | return if migration_path.nil? 21 | 22 | Pathname.new(migration_path).read 23 | end 24 | 25 | def find_migration_files(file_path) 26 | directory = File.dirname(file_path) 27 | basename = File.basename(file_path, ".rb") 28 | Dir.glob(File.join(directory, "*#{basename}.rb")) 29 | end 30 | 31 | def expect_to_be_a_migration(pathname) 32 | migration_files = find_migration_files(pathname) 33 | 34 | expect(migration_files).to be_present, 35 | "expected #{pathname} to be a migration file" 36 | first_migration = migration_files.first 37 | migration_basename = File.basename(first_migration) 38 | expect(migration_basename).to match(MIGRATION_TIMESTAMP_PATTERN), 39 | "expected #{migration_basename} to have timestamp prefix (format: YYYYMMDDHHMMSS_)" 40 | end 41 | end 42 | 43 | RSpec.configure do |config| 44 | config.include GeneratorSetup, :generator 45 | 46 | config.before(:each, :generator) do 47 | FileUtils.rm_rf(GeneratorSetup::RAILS_ROOT) if File.exist?(GeneratorSetup::RAILS_ROOT) 48 | FileUtils.mkdir_p(GeneratorSetup::RAILS_ROOT) 49 | 50 | allow(Rails).to receive(:root).and_return(Pathname.new(GeneratorSetup::RAILS_ROOT)) 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /spec/acceptance/user_manages_triggers_spec.rb: -------------------------------------------------------------------------------- 1 | require "acceptance_helper" 2 | 3 | RSpec.describe "User manages triggers" do 4 | it "handles simple triggers" do 5 | successfully "rails generate model user name:string upper_name:string" 6 | successfully "rails generate fx:function uppercase_users_name" 7 | write_function_definition "uppercase_users_name_v01", <<~SQL 8 | CREATE OR REPLACE FUNCTION uppercase_users_name() 9 | RETURNS trigger AS $$ 10 | BEGIN 11 | NEW.upper_name = UPPER(NEW.name); 12 | RETURN NEW; 13 | END; 14 | $$ LANGUAGE plpgsql; 15 | SQL 16 | successfully "rails generate fx:trigger uppercase_users_name table_name:users" 17 | write_trigger_definition "uppercase_users_name_v01", <<~SQL 18 | CREATE TRIGGER uppercase_users_name 19 | BEFORE INSERT ON users 20 | FOR EACH ROW 21 | EXECUTE FUNCTION uppercase_users_name(); 22 | SQL 23 | successfully "rake db:migrate" 24 | 25 | execute <<~SQL 26 | INSERT INTO users 27 | (name, created_at, updated_at) 28 | VALUES 29 | ('Bob', NOW(), NOW()); 30 | SQL 31 | result = execute("SELECT upper_name FROM users WHERE name = 'Bob';") 32 | expect(result).to eq("upper_name" => "BOB") 33 | 34 | successfully "rails generate fx:trigger uppercase_users_name table_name:users" 35 | write_trigger_definition "uppercase_users_name_v02", <<~SQL 36 | CREATE TRIGGER uppercase_users_name 37 | BEFORE UPDATE ON users 38 | FOR EACH ROW 39 | EXECUTE FUNCTION uppercase_users_name(); 40 | SQL 41 | successfully "rake db:migrate" 42 | execute <<~SQL 43 | UPDATE users 44 | SET name = 'Alice' 45 | WHERE id = 1; 46 | SQL 47 | 48 | result = execute("SELECT upper_name FROM users WHERE name = 'Alice';") 49 | expect(result).to eq("upper_name" => "ALICE") 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/fx/function_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe Fx::Function do 4 | describe "#<=>" do 5 | it "delegates to `name`" do 6 | function_a = Fx::Function.new( 7 | "name" => "name_a", 8 | "definition" => "some definition" 9 | ) 10 | function_b = Fx::Function.new( 11 | "name" => "name_b", 12 | "definition" => "some definition" 13 | ) 14 | function_c = Fx::Function.new( 15 | "name" => "name_c", 16 | "definition" => "some definition" 17 | ) 18 | 19 | expect(function_b).to be_between(function_a, function_c) 20 | end 21 | end 22 | 23 | describe "#==" do 24 | it "compares `name` and `definition`" do 25 | function_a = Fx::Function.new( 26 | "name" => "name_a", 27 | "definition" => "some definition" 28 | ) 29 | function_b = Fx::Function.new( 30 | "name" => "name_b", 31 | "definition" => "some other definition" 32 | ) 33 | 34 | expect(function_a).not_to eq(function_b) 35 | end 36 | end 37 | 38 | describe "#to_schema" do 39 | it "returns a schema compatible version of the function" do 40 | function = Fx::Function.new( 41 | "name" => "uppercase_users_name", 42 | "definition" => "CREATE OR REPLACE TRIGGER uppercase_users_name ..." 43 | ) 44 | 45 | expect(function.to_schema).to eq(<<-EOS) 46 | create_function :uppercase_users_name, sql_definition: <<-'SQL' 47 | CREATE OR REPLACE TRIGGER uppercase_users_name ... 48 | SQL 49 | EOS 50 | end 51 | 52 | it "maintains backslashes" do 53 | function = Fx::Function.new( 54 | "name" => "regex", 55 | "definition" => "CREATE OR REPLACE FUNCTION regex \\1" 56 | ) 57 | 58 | expect(function.to_schema).to eq(<<-'EOS') 59 | create_function :regex, sql_definition: <<-'SQL' 60 | CREATE OR REPLACE FUNCTION regex \1 61 | SQL 62 | EOS 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /spec/fx/adapters/postgres/query_executor_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe Fx::Adapters::Postgres::QueryExecutor, :db do 4 | it "executes the query and maps results to objects" do 5 | connection = ActiveRecord::Base.connection 6 | query = "SELECT 'Hello World' as message, 'english' as language" 7 | greeter = Class.new do 8 | attr_reader :message, :language 9 | 10 | def initialize(row) 11 | @message = row.fetch("message") 12 | @language = row.fetch("language") 13 | end 14 | end 15 | 16 | results = described_class.call( 17 | connection: connection, 18 | query: query, 19 | model_class: greeter 20 | ) 21 | 22 | expect(results.size).to eq(1) 23 | expect(results.first).to be_a(greeter) 24 | expect(results.first.message).to eq("Hello World") 25 | expect(results.first.language).to eq("english") 26 | end 27 | 28 | it "executes query with multiple results" do 29 | connection = ActiveRecord::Base.connection 30 | query = <<~SQL 31 | SELECT 'first' as name 32 | UNION ALL 33 | SELECT 'second' as name 34 | ORDER BY name 35 | SQL 36 | simple_name = Class.new do 37 | attr_reader :name 38 | 39 | def initialize(row) 40 | @name = row.fetch("name") 41 | end 42 | end 43 | 44 | results = described_class.call( 45 | connection: connection, 46 | query: query, 47 | model_class: simple_name 48 | ) 49 | 50 | expect(results.size).to eq(2) 51 | expect(results).to all(be_a(simple_name)) 52 | expect(results.first.name).to eq("first") 53 | expect(results.last.name).to eq("second") 54 | end 55 | 56 | it "returns an empty array when query returns no results" do 57 | connection = ActiveRecord::Base.connection 58 | query = "SELECT 'test' as name WHERE 1 = 0" 59 | simple_name = Class.new do 60 | attr_reader :name 61 | 62 | def initialize(row) 63 | @name = row.fetch("name") 64 | end 65 | end 66 | 67 | results = described_class.call( 68 | connection: connection, 69 | query: query, 70 | model_class: simple_name 71 | ) 72 | 73 | expect(results).to eq([]) 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /spec/features/functions/revert_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe "Reverting migrations", :db do 4 | around do |example| 5 | sql_definition = <<~SQL 6 | CREATE OR REPLACE FUNCTION test() 7 | RETURNS text AS $$ 8 | BEGIN 9 | RETURN 'test'; 10 | END; 11 | $$ LANGUAGE plpgsql; 12 | SQL 13 | with_function_definition(name: :test, sql_definition: sql_definition) do 14 | example.run 15 | end 16 | end 17 | 18 | it "can run reversible migrations for creating functions" do 19 | migration = Class.new(migration_class) do 20 | def change 21 | create_function :test 22 | end 23 | end 24 | 25 | expect { run_migration(migration, [:up, :down]) }.not_to raise_error 26 | end 27 | 28 | it "can run reversible migrations for dropping functions" do 29 | connection.create_function(:test) 30 | 31 | good_migration = Class.new(migration_class) do 32 | def change 33 | drop_function :test, revert_to_version: 1 34 | end 35 | end 36 | bad_migration = Class.new(migration_class) do 37 | def change 38 | drop_function :test 39 | end 40 | end 41 | 42 | expect { run_migration(good_migration, [:up, :down]) }.not_to raise_error 43 | expect { run_migration(bad_migration, [:up, :down]) } 44 | .to raise_error( 45 | ActiveRecord::IrreversibleMigration, 46 | /`create_function` is reversible only if given a `revert_to_version`/ 47 | ) 48 | end 49 | 50 | it "can run reversible migrations for updating functions" do 51 | connection.create_function(:test) 52 | 53 | sql_definition = <<~SQL 54 | CREATE OR REPLACE FUNCTION test() 55 | RETURNS text AS $$ 56 | BEGIN 57 | RETURN 'bar'; 58 | END; 59 | $$ LANGUAGE plpgsql; 60 | SQL 61 | with_function_definition( 62 | name: :test, 63 | version: 2, 64 | sql_definition: sql_definition 65 | ) do 66 | migration = Class.new(migration_class) do 67 | def change 68 | update_function :test, version: 2, revert_to_version: 1 69 | end 70 | end 71 | 72 | expect { run_migration(migration, [:up, :down]) }.not_to raise_error 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/fx/command_recorder.rb: -------------------------------------------------------------------------------- 1 | module Fx 2 | # @api private 3 | module CommandRecorder 4 | def create_function(*args) 5 | record(:create_function, args) 6 | end 7 | 8 | def drop_function(*args) 9 | record(:drop_function, args) 10 | end 11 | 12 | def update_function(*args) 13 | record(:update_function, args) 14 | end 15 | 16 | def invert_create_function(args) 17 | [:drop_function, args] 18 | end 19 | 20 | def invert_drop_function(args) 21 | perform_inversion(:create_function, args) 22 | end 23 | 24 | def invert_update_function(args) 25 | perform_inversion(:update_function, args) 26 | end 27 | 28 | def create_trigger(*args) 29 | record(:create_trigger, args) 30 | end 31 | 32 | def drop_trigger(*args) 33 | record(:drop_trigger, args) 34 | end 35 | 36 | def update_trigger(*args) 37 | record(:update_trigger, args) 38 | end 39 | 40 | def invert_create_trigger(args) 41 | [:drop_trigger, args] 42 | end 43 | 44 | def invert_drop_trigger(args) 45 | perform_inversion(:create_trigger, args) 46 | end 47 | 48 | def invert_update_trigger(args) 49 | perform_inversion(:update_trigger, args) 50 | end 51 | 52 | private 53 | 54 | def perform_inversion(method, args) 55 | arguments = Arguments.new(args) 56 | 57 | if arguments.revert_to_version.nil? 58 | message = "`#{method}` is reversible only if given a `revert_to_version`" 59 | raise ActiveRecord::IrreversibleMigration, message 60 | end 61 | 62 | [method, arguments.invert_version.to_a] 63 | end 64 | 65 | class Arguments 66 | def initialize(args) 67 | @args = args.freeze 68 | end 69 | 70 | def function 71 | args[0] 72 | end 73 | 74 | def version 75 | options[:version] 76 | end 77 | 78 | def revert_to_version 79 | options[:revert_to_version] 80 | end 81 | 82 | def invert_version 83 | Arguments.new([function, options_for_revert]) 84 | end 85 | 86 | def to_a 87 | args.to_a 88 | end 89 | 90 | private 91 | 92 | attr_reader :args 93 | 94 | def options 95 | @options ||= args[1] || {} 96 | end 97 | 98 | def options_for_revert 99 | options.clone.tap do |revert_options| 100 | revert_options[:version] = revert_to_version 101 | revert_options.delete(:revert_to_version) 102 | end 103 | end 104 | end 105 | private_constant :Arguments 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /spec/generators/fx/trigger/trigger_generator_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "generators/fx/trigger/trigger_generator" 3 | 4 | RSpec.describe Fx::Generators::TriggerGenerator, :generator do 5 | it "creates a trigger definition file, and a migration" do 6 | migration = file("db/migrate/create_trigger_test.rb") 7 | trigger_definition = file("db/triggers/test_v01.sql") 8 | 9 | run_generator( 10 | described_class, 11 | ["test", {"table_name" => "some_table"}] 12 | ) 13 | 14 | expect(trigger_definition).to exist 15 | expect_to_be_a_migration(migration) 16 | expect(migration_content(migration)).to include("CreateTriggerTest") 17 | expect(migration_content(migration)).to include("on: :some_table") 18 | end 19 | 20 | context "when passed --no-migration" do 21 | it "creates a only trigger definition file" do 22 | migration = file("db/migrate/create_trigger_test.rb") 23 | trigger_definition = file("db/triggers/test_v01.sql") 24 | 25 | run_generator( 26 | described_class, 27 | ["test", {"table_name" => "some_table"}], 28 | {migration: false} 29 | ) 30 | 31 | expect(trigger_definition).to exist 32 | expect(migration).not_to exist 33 | end 34 | end 35 | 36 | it "supports naming the table as `on` aswell as `table_name`" do 37 | migration = file("db/migrate/create_trigger_test.rb") 38 | trigger_definition = file("db/triggers/test_v01.sql") 39 | 40 | run_generator( 41 | described_class, 42 | ["test", {"on" => "some_table"}] 43 | ) 44 | 45 | expect(trigger_definition).to exist 46 | expect_to_be_a_migration(migration) 47 | expect(migration_content(migration)).to include("CreateTriggerTest") 48 | expect(migration_content(migration)).to include("on: :some_table") 49 | end 50 | 51 | it "requires `table_name` or `on` to be specified" do 52 | expect do 53 | run_generator( 54 | described_class, 55 | ["test", {"foo" => "some_table"}] 56 | ) 57 | end.to raise_error(ArgumentError) 58 | end 59 | 60 | it "updates an existing trigger" do 61 | with_trigger_definition( 62 | name: "test", 63 | version: 1, 64 | sql_definition: "hello" 65 | ) do 66 | migration = file("db/migrate/update_trigger_test_to_version_2.rb") 67 | trigger_definition = file("db/triggers/test_v02.sql") 68 | 69 | run_generator( 70 | described_class, 71 | ["test", {"table_name" => "some_table"}] 72 | ) 73 | 74 | expect(trigger_definition).to exist 75 | expect_to_be_a_migration(migration) 76 | expect(migration_content(migration)).to include( 77 | "UpdateTriggerTestToVersion2" 78 | ) 79 | expect(migration_content(migration)).to include("on: :some_table") 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /spec/features/triggers/revert_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe "Reverting migrations", :db do 4 | around do |example| 5 | connection.execute <<~SQL 6 | CREATE TABLE users ( 7 | id int PRIMARY KEY, 8 | name varchar(256), 9 | upper_name varchar(256) 10 | ); 11 | SQL 12 | Fx.database.create_function <<~SQL 13 | CREATE OR REPLACE FUNCTION uppercase_users_name() 14 | RETURNS trigger AS $$ 15 | BEGIN 16 | NEW.upper_name = UPPER(NEW.name); 17 | RETURN NEW; 18 | END; 19 | $$ LANGUAGE plpgsql; 20 | SQL 21 | sql_definition = <<~SQL 22 | CREATE TRIGGER uppercase_users_name 23 | BEFORE INSERT ON users 24 | FOR EACH ROW 25 | EXECUTE FUNCTION uppercase_users_name(); 26 | SQL 27 | with_trigger_definition( 28 | name: :uppercase_users_name, 29 | sql_definition: sql_definition 30 | ) do 31 | example.run 32 | end 33 | end 34 | 35 | it "can run reversible migrations for creating triggers" do 36 | migration = Class.new(migration_class) do 37 | def change 38 | create_trigger :uppercase_users_name, on: :users 39 | end 40 | end 41 | 42 | expect { run_migration(migration, [:up, :down]) }.not_to raise_error 43 | end 44 | 45 | it "can run reversible migrations for dropping triggers" do 46 | connection.create_trigger(:uppercase_users_name, on: :users) 47 | 48 | good_migration = Class.new(migration_class) do 49 | def change 50 | drop_trigger :uppercase_users_name, on: :users, revert_to_version: 1 51 | end 52 | end 53 | bad_migration = Class.new(migration_class) do 54 | def change 55 | drop_trigger :uppercase_users_name, on: :users 56 | end 57 | end 58 | 59 | expect { run_migration(good_migration, [:up, :down]) }.not_to raise_error 60 | expect { run_migration(bad_migration, [:up, :down]) } 61 | .to raise_error( 62 | ActiveRecord::IrreversibleMigration, 63 | /`create_trigger` is reversible only if given a `revert_to_version`/ 64 | ) 65 | end 66 | 67 | it "can run reversible migrations for updating triggers" do 68 | connection.create_trigger(:uppercase_users_name) 69 | 70 | sql_definition = <<~SQL 71 | CREATE TRIGGER uppercase_users_name 72 | BEFORE UPDATE ON users 73 | FOR EACH ROW 74 | EXECUTE FUNCTION uppercase_users_name(); 75 | SQL 76 | with_trigger_definition( 77 | name: :uppercase_users_name, 78 | sql_definition: sql_definition, 79 | version: 2 80 | ) do 81 | migration = Class.new(migration_class) do 82 | def change 83 | update_trigger( 84 | :uppercase_users_name, 85 | on: :users, 86 | version: 2, 87 | revert_to_version: 1 88 | ) 89 | end 90 | end 91 | 92 | expect { run_migration(migration, [:up, :down]) }.not_to raise_error 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /lib/generators/fx/trigger/trigger_generator.rb: -------------------------------------------------------------------------------- 1 | require "rails/generators" 2 | require "rails/generators/active_record" 3 | require "generators/fx/version_helper" 4 | require "generators/fx/migration_helper" 5 | require "generators/fx/name_helper" 6 | 7 | module Fx 8 | module Generators 9 | # @api private 10 | class TriggerGenerator < Rails::Generators::NamedBase 11 | include Rails::Generators::Migration 12 | 13 | source_root File.expand_path("../templates", __FILE__) 14 | argument :table_name, type: :hash, required: true 15 | 16 | DEFINITION_PATH = %w[db triggers].freeze 17 | 18 | class_option :migration, type: :boolean 19 | 20 | def create_triggers_directory 21 | return if trigger_definition_path.exist? 22 | 23 | empty_directory(trigger_definition_path) 24 | end 25 | 26 | def create_trigger_definition 27 | create_file(definition.path) 28 | end 29 | 30 | def create_migration_file 31 | return if migration_helper.skip_creation? 32 | 33 | template_info = migration_helper.migration_template_info( 34 | object_type: :trigger, 35 | file_name: file_name, 36 | updating_existing: version_helper.updating_existing?, 37 | version: version_helper.current_version 38 | ) 39 | 40 | migration_template(template_info[:template], template_info[:filename]) 41 | end 42 | 43 | def self.next_migration_number(dir) 44 | ::ActiveRecord::Generators::Base.next_migration_number(dir) 45 | end 46 | 47 | no_tasks do 48 | def previous_version 49 | version_helper.previous_version 50 | end 51 | 52 | def version 53 | version_helper.current_version 54 | end 55 | 56 | def migration_class_name 57 | if version_helper.updating_existing? 58 | migration_helper.update_migration_class_name( 59 | object_type: :trigger, 60 | class_name: class_name, 61 | version: version 62 | ) 63 | else 64 | super 65 | end 66 | end 67 | 68 | def formatted_name 69 | Fx::Generators::NameHelper.format_for_migration(singular_name) 70 | end 71 | 72 | def formatted_table_name 73 | Fx::Generators::NameHelper.format_table_name_from_hash(table_name) 74 | end 75 | end 76 | 77 | private 78 | 79 | def trigger_definition_path 80 | @_trigger_definition_path ||= Rails.root.join(*DEFINITION_PATH) 81 | end 82 | 83 | def version_helper 84 | @_version_helper ||= Fx::Generators::VersionHelper.new( 85 | file_name: file_name, 86 | definition_path: trigger_definition_path 87 | ) 88 | end 89 | 90 | def migration_helper 91 | @_migration_helper ||= Fx::Generators::MigrationHelper.new(options) 92 | end 93 | 94 | def definition 95 | version_helper.definition_for_version(version: version, type: :trigger) 96 | end 97 | 98 | def updating_existing_trigger? 99 | version_helper.updating_existing? 100 | end 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /spec/generators/fx/migration_helper_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "generators/fx/migration_helper" 3 | 4 | RSpec.describe Fx::Generators::MigrationHelper do 5 | describe "#skip_creation?" do 6 | it "returns false by default" do 7 | helper = described_class.new({}) 8 | 9 | expect(helper.skip_creation?).to be(false) 10 | end 11 | 12 | it "returns true when migration option is false" do 13 | helper = described_class.new(migration: false) 14 | 15 | expect(helper.skip_creation?).to be(true) 16 | end 17 | end 18 | 19 | describe "#update_migration_class_name" do 20 | it "generates correct class name for functions" do 21 | helper = described_class.new({}) 22 | 23 | result = helper.update_migration_class_name( 24 | object_type: :function, 25 | class_name: "TestFunction", 26 | version: 3 27 | ) 28 | 29 | expect(result).to eq("UpdateFunctionTestFunctionToVersion3") 30 | end 31 | 32 | it "generates correct class name for triggers" do 33 | helper = described_class.new({}) 34 | 35 | result = helper.update_migration_class_name( 36 | object_type: :trigger, 37 | class_name: "TestTrigger", 38 | version: 2 39 | ) 40 | 41 | expect(result).to eq("UpdateTriggerTestTriggerToVersion2") 42 | end 43 | end 44 | 45 | describe "#migration_template_info" do 46 | it "returns create template info for new objects" do 47 | helper = described_class.new({}) 48 | 49 | result = helper.migration_template_info( 50 | object_type: :function, 51 | file_name: "test_func", 52 | updating_existing: false, 53 | version: 1 54 | ) 55 | 56 | expect(result).to eq({ 57 | template: "db/migrate/create_function.erb", 58 | filename: "db/migrate/create_function_test_func.rb" 59 | }) 60 | end 61 | 62 | it "returns update template info for existing objects" do 63 | helper = described_class.new({}) 64 | 65 | result = helper.migration_template_info( 66 | object_type: :trigger, 67 | file_name: "test_trigger", 68 | updating_existing: true, 69 | version: 3 70 | ) 71 | 72 | expect(result).to eq({ 73 | template: "db/migrate/update_trigger.erb", 74 | filename: "db/migrate/update_trigger_test_trigger_to_version_3.rb" 75 | }) 76 | end 77 | 78 | it "handles different object types correctly" do 79 | helper = described_class.new({}) 80 | 81 | function_result = helper.migration_template_info( 82 | object_type: :function, 83 | file_name: "my_function", 84 | updating_existing: true, 85 | version: 2 86 | ) 87 | trigger_result = helper.migration_template_info( 88 | object_type: :trigger, 89 | file_name: "my_trigger", 90 | updating_existing: true, 91 | version: 2 92 | ) 93 | 94 | expect(function_result.fetch(:template)).to eq( 95 | "db/migrate/update_function.erb" 96 | ) 97 | expect(trigger_result.fetch(:template)).to eq( 98 | "db/migrate/update_trigger.erb" 99 | ) 100 | expect(function_result.fetch(:filename)).to eq( 101 | "db/migrate/update_function_my_function_to_version_2.rb" 102 | ) 103 | expect(trigger_result.fetch(:filename)).to eq( 104 | "db/migrate/update_trigger_my_trigger_to_version_2.rb" 105 | ) 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /lib/generators/fx/function/function_generator.rb: -------------------------------------------------------------------------------- 1 | require "rails/generators" 2 | require "rails/generators/active_record" 3 | require "generators/fx/version_helper" 4 | require "generators/fx/migration_helper" 5 | require "generators/fx/name_helper" 6 | 7 | module Fx 8 | module Generators 9 | # @api private 10 | class FunctionGenerator < Rails::Generators::NamedBase 11 | include Rails::Generators::Migration 12 | 13 | source_root File.expand_path("../templates", __FILE__) 14 | 15 | DEFINITION_PATH = %w[db functions].freeze 16 | 17 | class_option :migration, type: :boolean 18 | 19 | def create_functions_directory 20 | return if function_definition_path.exist? 21 | 22 | empty_directory(function_definition_path) 23 | end 24 | 25 | def create_function_definition 26 | if version_helper.creating_new? 27 | create_file(definition.path) 28 | else 29 | copy_file(previous_definition.full_path, definition.full_path) 30 | end 31 | end 32 | 33 | def create_migration_file 34 | return if migration_helper.skip_creation? 35 | 36 | template_info = migration_helper.migration_template_info( 37 | object_type: :function, 38 | file_name: file_name, 39 | updating_existing: version_helper.updating_existing?, 40 | version: version_helper.current_version 41 | ) 42 | 43 | migration_template( 44 | template_info.fetch(:template), 45 | template_info.fetch(:filename) 46 | ) 47 | end 48 | 49 | def self.next_migration_number(dir) 50 | ::ActiveRecord::Generators::Base.next_migration_number(dir) 51 | end 52 | 53 | no_tasks do 54 | def previous_version 55 | version_helper.previous_version 56 | end 57 | 58 | def version 59 | version_helper.current_version 60 | end 61 | 62 | def migration_class_name 63 | if version_helper.updating_existing? 64 | migration_helper.update_migration_class_name( 65 | object_type: :function, 66 | class_name: class_name, 67 | version: version 68 | ) 69 | else 70 | super 71 | end 72 | end 73 | 74 | def formatted_name 75 | Fx::Generators::NameHelper.format_for_migration(singular_name) 76 | end 77 | end 78 | 79 | private 80 | 81 | def function_definition_path 82 | @_function_definition_path ||= Rails.root.join(*DEFINITION_PATH) 83 | end 84 | 85 | def version_helper 86 | @_version_helper ||= Fx::Generators::VersionHelper.new( 87 | file_name: file_name, 88 | definition_path: function_definition_path 89 | ) 90 | end 91 | 92 | def migration_helper 93 | @_migration_helper ||= Fx::Generators::MigrationHelper.new(options) 94 | end 95 | 96 | def definition 97 | version_helper.definition_for_version(version: version, type: :function) 98 | end 99 | 100 | def previous_definition 101 | version_helper.definition_for_version(version: previous_version, type: :function) 102 | end 103 | 104 | def updating_existing_function? 105 | version_helper.updating_existing? 106 | end 107 | 108 | def creating_new_function? 109 | version_helper.creating_new? 110 | end 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /spec/generators/fx/name_helper_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "generators/fx/name_helper" 3 | 4 | RSpec.describe Fx::Generators::NameHelper do 5 | describe ".format_for_migration" do 6 | it "returns symbol format for simple names" do 7 | result = described_class.format_for_migration("simple_name") 8 | 9 | expect(result).to eq(":simple_name") 10 | end 11 | 12 | it "returns quoted string format for schema-qualified names" do 13 | result = described_class.format_for_migration("schema.function_name") 14 | 15 | expect(result).to eq("\"schema.function_name\"") 16 | end 17 | 18 | it "handles names with multiple dots" do 19 | result = described_class.format_for_migration("db.schema.function") 20 | 21 | expect(result).to eq("\"db.schema.function\"") 22 | end 23 | 24 | it "handles empty names" do 25 | result = described_class.format_for_migration("") 26 | 27 | expect(result).to eq(":") 28 | end 29 | end 30 | 31 | describe ".format_table_name_from_hash" do 32 | it "formats table_name key correctly" do 33 | table_hash = {"table_name" => "users"} 34 | 35 | result = described_class.format_table_name_from_hash(table_hash) 36 | 37 | expect(result).to eq(":users") 38 | end 39 | 40 | it "formats on key correctly" do 41 | table_hash = {"on" => "posts"} 42 | 43 | result = described_class.format_table_name_from_hash(table_hash) 44 | 45 | expect(result).to eq(":posts") 46 | end 47 | 48 | it "prefers table_name over on when both are present" do 49 | table_hash = {"table_name" => "users", "on" => "posts"} 50 | 51 | result = described_class.format_table_name_from_hash(table_hash) 52 | 53 | expect(result).to eq(":users") 54 | end 55 | 56 | it "handles schema-qualified table names" do 57 | table_hash = {"table_name" => "public.users"} 58 | 59 | result = described_class.format_table_name_from_hash(table_hash) 60 | 61 | expect(result).to eq("\"public.users\"") 62 | end 63 | 64 | it "raises error when neither key is present" do 65 | table_hash = {"something_else" => "value"} 66 | 67 | expect { 68 | described_class.format_table_name_from_hash(table_hash) 69 | }.to raise_error( 70 | ArgumentError, 71 | "Either `table_name:NAME` or `on:NAME` must be specified" 72 | ) 73 | end 74 | 75 | it "raises error when both keys have nil values" do 76 | table_hash = {"table_name" => nil, "on" => nil} 77 | 78 | expect { 79 | described_class.format_table_name_from_hash(table_hash) 80 | }.to raise_error( 81 | ArgumentError, 82 | "Either `table_name:NAME` or `on:NAME` must be specified" 83 | ) 84 | end 85 | 86 | it "uses on key when table_name is nil" do 87 | table_hash = {"table_name" => nil, "on" => "comments"} 88 | 89 | result = described_class.format_table_name_from_hash(table_hash) 90 | 91 | expect(result).to eq(":comments") 92 | end 93 | end 94 | 95 | describe ".validate_and_format" do 96 | it "formats valid names correctly" do 97 | result = described_class.validate_and_format("test_function") 98 | 99 | expect(result).to eq(":test_function") 100 | end 101 | 102 | it "formats schema-qualified names correctly" do 103 | result = described_class.validate_and_format("schema.test") 104 | 105 | expect(result).to eq("\"schema.test\"") 106 | end 107 | 108 | it "raises error for blank names" do 109 | expect { 110 | described_class.validate_and_format("") 111 | }.to raise_error(ArgumentError, "Name cannot be blank") 112 | end 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /spec/fx/definition_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe Fx::Definition do 4 | describe "#to_sql" do 5 | context "representing a function definition" do 6 | it "returns the content of a function definition" do 7 | sql_definition = <<~SQL 8 | CREATE OR REPLACE FUNCTION test() 9 | RETURNS text AS $$ 10 | BEGIN 11 | RETURN 'test'; 12 | END; 13 | $$ LANGUAGE plpgsql; 14 | SQL 15 | allow(File).to receive(:read).and_return(sql_definition) 16 | 17 | definition = Fx::Definition.function(name: "test", version: 1) 18 | 19 | expect(definition.to_sql).to eq(sql_definition) 20 | end 21 | 22 | it "raises an error if the file is empty" do 23 | allow(File).to receive(:read).and_return("") 24 | definition = Fx::Definition.function(name: "test", version: 1) 25 | 26 | expect do 27 | definition.to_sql 28 | end.to raise_error( 29 | RuntimeError, 30 | %r{Define function in db/functions/test_v01.sql before migrating} 31 | ) 32 | end 33 | 34 | context "when definition is at Rails engine" do 35 | it "returns the content of a function definition" do 36 | sql_definition = <<~SQL 37 | CREATE OR REPLACE FUNCTION test() 38 | RETURNS text AS $$ 39 | BEGIN 40 | RETURN 'test'; 41 | END; 42 | $$ LANGUAGE plpgsql; 43 | SQL 44 | engine_path = Rails.root.join("tmp", "engine") 45 | FileUtils.mkdir_p(engine_path.join("db", "functions")) 46 | File.write(engine_path.join("db", "functions", "custom_test_v01.sql"), sql_definition) 47 | Rails.application.config.paths["db/migrate"].push(engine_path.join("db", "migrate")) 48 | 49 | definition = Fx::Definition.function(name: "custom_test", version: 1) 50 | 51 | expect(definition.to_sql).to eq(sql_definition) 52 | 53 | FileUtils.rm_rf(engine_path) 54 | end 55 | end 56 | end 57 | 58 | context "representing a trigger definition" do 59 | it "returns the content of a trigger definition" do 60 | sql_definition = <<~SQL 61 | CREATE TRIGGER check_update 62 | BEFORE UPDATE ON accounts 63 | FOR EACH ROW 64 | EXECUTE FUNCTION check_account_update(); 65 | SQL 66 | allow(File).to receive(:read).and_return(sql_definition) 67 | 68 | definition = Fx::Definition.trigger(name: "test", version: 1) 69 | 70 | expect(definition.to_sql).to eq(sql_definition) 71 | end 72 | 73 | it "raises an error if the file is empty" do 74 | allow(File).to receive(:read).and_return("") 75 | definition = Fx::Definition.trigger(name: "test", version: 1) 76 | 77 | expect do 78 | definition.to_sql 79 | end.to raise_error( 80 | RuntimeError, 81 | %r{Define trigger in db/triggers/test_v01.sql before migrating} 82 | ) 83 | end 84 | end 85 | end 86 | 87 | describe "#path" do 88 | context "representing a function definition" do 89 | it "returns a sql file with padded version and function name" do 90 | definition = Fx::Definition.function(name: "test", version: 1) 91 | 92 | expect(definition.path).to eq("db/functions/test_v01.sql") 93 | end 94 | end 95 | 96 | context "representing a trigger definition" do 97 | it "returns a sql file with padded version and trigger name" do 98 | definition = Fx::Definition.trigger(name: "test", version: 1) 99 | 100 | expect(definition.path).to eq("db/triggers/test_v01.sql") 101 | end 102 | end 103 | end 104 | 105 | describe "#full_path" do 106 | it "joins the path with Rails.root" do 107 | definition = Fx::Definition.function(name: "test", version: 15) 108 | 109 | expect(definition.full_path).to eq(Rails.root.join(definition.path)) 110 | end 111 | end 112 | 113 | describe "#version" do 114 | it "pads the version number with 0" do 115 | definition = Fx::Definition.function(name: :_, version: 1) 116 | 117 | expect(definition.version).to eq("01") 118 | end 119 | 120 | it "does not pad more than 2 characters" do 121 | definition = Fx::Definition.function(name: :_, version: 15) 122 | 123 | expect(definition.version).to eq("15") 124 | end 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # F(x) 2 | 3 | [![Build Status](https://github.com/teoljungberg/fx/actions/workflows/ci.yml/badge.svg)](https://github.com/teoljungberg/fx/actions/workflows/ci.yml) 4 | [![Documentation Quality](http://inch-ci.org/github/teoljungberg/fx.svg?branch=master)](http://inch-ci.org/github/teoljungberg/fx) 5 | 6 | F(x) adds methods to `ActiveRecord::Migration` to create and manage database 7 | functions and triggers in Rails. 8 | 9 | Using F(x), you can bring the power of SQL functions and triggers to your Rails 10 | application without having to switch your schema format to SQL. F(x) provides 11 | a convention for versioning functions and triggers that keeps your migration 12 | history consistent and reversible and avoids having to duplicate SQL strings 13 | across migrations. As an added bonus, you define the structure of your function 14 | in a SQL file, meaning you get full SQL syntax highlighting in the editor of 15 | your choice and can easily test your SQL in the database console during 16 | development. 17 | 18 | F(x) ships with support for PostgreSQL. The adapter is configurable (see 19 | `Fx::Configuration`) and has a minimal interface (see 20 | `Fx::Adapters::Postgres`) that other gems can provide. 21 | 22 | ## Great, how do I create a trigger and a function? 23 | 24 | You've got this great idea for a function you'd like to call 25 | `uppercase_users_name`. You can create the migration and the corresponding 26 | definition file with the following command: 27 | 28 | ```sh 29 | % rails generate fx:function uppercase_users_name 30 | create db/functions/uppercase_users_name_v01.sql 31 | create db/migrate/[TIMESTAMP]_create_function_uppercase_users_name.rb 32 | ``` 33 | 34 | Edit the `db/functions/uppercase_users_name_v01.sql` file with the SQL statement 35 | that defines your function. 36 | 37 | Next, let's add a trigger called `uppercase_users_name` to call our new 38 | function each time we `INSERT` on the `users` table. 39 | 40 | ```sh 41 | % rails generate fx:trigger uppercase_users_name table_name:users 42 | create db/triggers/uppercase_users_name_v01.sql 43 | create db/migrate/[TIMESTAMP]_create_trigger_uppercase_users_name.rb 44 | ``` 45 | 46 | In our example, this might look something like this: 47 | 48 | ```sql 49 | CREATE TRIGGER uppercase_users_name 50 | BEFORE INSERT ON users 51 | FOR EACH ROW 52 | EXECUTE FUNCTION uppercase_users_name(); 53 | ``` 54 | 55 | The generated migrations contains `create_function` and `create_trigger` 56 | statements. The migration is reversible and the schema will be dumped into your 57 | `schema.rb` file. 58 | 59 | ```sh 60 | % rake db:migrate 61 | ``` 62 | 63 | ## Cool, but what if I need to change a trigger or function? 64 | 65 | Here's where F(x) really shines. Run that same function generator once more: 66 | 67 | ```sh 68 | % rails generate fx:function uppercase_users_name 69 | create db/functions/uppercase_users_name_v02.sql 70 | create db/migrate/[TIMESTAMP]_update_function_uppercase_users_name_to_version_2.rb 71 | ``` 72 | 73 | F(x) detected that we already had an existing `uppercase_users_name` function at 74 | version 1, created a copy of that definition as version 2, and created a 75 | migration to update to the version 2 schema. All that's left for you to do is 76 | tweak the schema in the new definition and run the `update_function` migration. 77 | 78 | ## I don't need this trigger or function anymore. Make it go away. 79 | 80 | F(x) gives you `drop_trigger` and `drop_function` too: 81 | 82 | ```ruby 83 | def change 84 | drop_function :uppercase_users_name, revert_to_version: 2 85 | end 86 | ``` 87 | 88 | ## What if I need to use a function as the default value of a column? 89 | 90 | You need to set F(x) to dump the functions in the beginning of db/schema.rb in a 91 | initializer: 92 | 93 | ```ruby 94 | # config/initializers/fx.rb 95 | Fx.configure do |config| 96 | config.dump_functions_at_beginning_of_schema = true 97 | end 98 | ``` 99 | 100 | And then you can use a lambda in your migration file: 101 | 102 | ```ruby 103 | create_table :my_table do |t| 104 | t.string :my_column, default: -> { "my_function()" } 105 | end 106 | ``` 107 | 108 | That's how you tell Rails to use the default as a literal SQL for the default 109 | column value instead of a plain string. 110 | 111 | ## Plugins/Adapters 112 | 113 | - [MySQL](https://github.com/f-mer/fx-adapters-mysql/) 114 | - [Oracle](https://github.com/zygotecnologia/fx-oracle-adapter) 115 | - [SQLserver](https://github.com/tarellel/fx-sqlserver-adapter) 116 | 117 | ## Contributing 118 | 119 | See [contributing](CONTRIBUTING.md) for more details. 120 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | The noteworthy changes for each version are included here. For a complete 4 | changelog, see the [commits] for each version via the version links. 5 | 6 | [commits]: https://github.com/teoljungberg/fx/commits/master 7 | 8 | ## [Unreleased] 9 | 10 | [Unreleased]: https://github.com/teoljungberg/fx/compare/v0.10.0..HEAD 11 | 12 | ## [0.10.0] 13 | 14 | [0.10.0]: https://github.com/teoljungberg/fx/compare/v0.9.0...v0.10.0 15 | 16 | - Scope functions and triggers to schema search path (#168) 17 | - Ensure multi-schema dumping in Rails 8.1.0 does not dump the same objects for 18 | all schemas (#177) 19 | - Require Ruby >= 3.2 (#166) 20 | - Require Ruby >= 3.1 (#162) 21 | - New version support 22 | - Add Rails 8.1 to the test matrix (#175) 23 | - Add Ruby 3.4 to the test matrix (#161) 24 | - Removed version support 25 | - Drop Rails 7.1 due to EOL (#176) 26 | - Drop EOL Ruby versions (3.0) (#162) 27 | - Drop EOL Rails versions (7.0) (#162) 28 | - Internal refactorings / improvements 29 | - Extract validation methods in `Statements` module (#184) 30 | - Refactor generators with helper classes and improvements (#183) 31 | - Extract `QueryExecutor` to remove duplication (#182) 32 | - Extract PostgreSQL version constants (#181) 33 | - YARD documentation improvements (#179) 34 | - Ensure fx has been loaded (#173) 35 | - Replace ammeter (#172) 36 | - Remove teardown phase (#171) 37 | 38 | ## [0.9.0] 39 | 40 | [0.9.0]: https://github.com/teoljungberg/fx/compare/v0.8.0...v0.9.0 41 | 42 | - Drop EOL Rails versions (6.2) 43 | - Add Ruby 3.4.0 preview's to the test matrix (#152) 44 | - Add Rails 8.0.0 to the test matrix (#152) 45 | - Add Rails 7.2 to the test matrix (#150) 46 | - Fix deprecation warnings in Rails (#148) 47 | - Mark `Fx::CommandRecorder::Arguments` as private. 48 | - Add Ruby 3.3 to the test matrix (#144) 49 | - Internal refactorings: 50 | - Move development dependencies to Gemfile (#145) 51 | - Inline `Fx::CommandRecorder::Arguments` 52 | - Inline `Fx::{CommandRecorder,SchemaDumper,Statements}::{Function,Trigger}` 53 | - Move configuration methods to `Fx` 54 | - Add `Fx::Definition.{function,trigger}` (#119) 55 | - Add Rails 7.1 to the test matrix (#136) 56 | - Add Rubygems metadata to gemspec (#132) 57 | - Disable RSpec's monkey patching (#121) 58 | - Raise on warnings (#124) 59 | - Require Ruby >= 3.0 (#128) 60 | - Require Rails >= 6.1 (#127) 61 | 62 | ## [0.8.0] 63 | 64 | [0.8.0]: https://github.com/teoljungberg/fx/compare/v0.7.0...v0.8.0 65 | 66 | - Replace Travis CI with GitHub Actions. 67 | - Bump minimum Ruby version to 2.7. 68 | - Ruby 2.7 will be dropped in end of March 2023, so a release to drop it will 69 | happen afterwards. 70 | - Bump minimum Rails version to 6.0.0 71 | - Rails 6.0 will be dropped in June 2023, so a release to drop it will happen 72 | afterwards 73 | - Adopt standard.rb 74 | - Contributing improvements 75 | - Test-suite improvements 76 | 77 | ## [0.7.0] 78 | 79 | [0.7.0]: https://github.com/teoljungberg/fx/compare/v0.6.2...v0.7.0 80 | 81 | - Support Ruby 3 (#76) 82 | - Preserve backslashes when dumping the schema (#71) 83 | - Add a link to F(x) SqlServer Adapter in the README (#80) 84 | 85 | ## [0.6.2] 86 | 87 | [0.6.2]: https://github.com/teoljungberg/fx/compare/v0.6.1...v0.6.2 88 | 89 | - Add support for Ruby 3 90 | 91 | ## [0.6.1] 92 | 93 | [0.6.1]: https://github.com/teoljungberg/fx/compare/v0.6.0...v0.6.1 94 | 95 | - Fix: Support --no-migration generator flag (#62) 96 | 97 | ## [0.6.0] 98 | 99 | [0.6.0]: https://github.com/teoljungberg/fx/compare/v0.5.0...v0.6.0 100 | 101 | - Support unique functions with parameters (#27) 102 | - Use db connection provided by Rails (#49) 103 | - Support `--no-migration` generator flag (#60) 104 | - Use current ActiveRecord version in migrations (#59) 105 | - Does not include aggregates when dumping schema (#50) 106 | - Dump functions in the beginning of the schema (#53) 107 | 108 | ## [0.5.0] 109 | 110 | [0.5.0]: https://github.com/teoljungberg/fx/compare/v0.4.0...v0.5.0 111 | 112 | - Drop EOL Ruby versions. 113 | - Drop EOL Rails versions. 114 | 115 | ## [0.4.0] 116 | 117 | [0.4.0]: https://github.com/teoljungberg/fx/compare/v0.3.1...v0.4.0 118 | 119 | - Add table_name to README (#15) 120 | - Reverse function/trigger order in README (#17) 121 | - Split up Trigger#definition test (#19) 122 | - Find definitions in engines (#18) 123 | 124 | ## [0.3.1] 125 | 126 | [0.3.1]: https://github.com/teoljungberg/fx/compare/v0.3.0...v0.3.1 127 | 128 | - Strip shared leading whitespace from sql_definitions (#13) 129 | - Update documentation for `drop_function` 130 | - Document `Fx::Adapters::Postgres#initialize` 131 | - Fix test suite issues: 132 | - Add unit test coverage for `Fx::Adapters::Triggers` 133 | - Add unit test coverage for `Fx::Adapters::Functions` 134 | - Add unit test coverage for `Fx::Trigger` 135 | - Add unit test coverage for `Fx::Function` 136 | 137 | ## [0.3.0] 138 | 139 | [0.3.0]: https://github.com/teoljungberg/fx/compare/v0.2.0...v0.3.0 140 | 141 | ## [0.2.0] 142 | 143 | [0.2.0]: https://github.com/teoljungberg/fx/compare/v0.1.0...v0.2.0 144 | 145 | ## [0.1.0] 146 | 147 | F(x) adds methods to `ActiveRecord::Migration` to create and manage database 148 | functions and triggers in Rails. 149 | 150 | [0.1.0]: https://github.com/teoljungberg/fx/compare/4ccf986643d9de82038977eff8c6b1a4a716d698...v0.1.0 151 | -------------------------------------------------------------------------------- /spec/generators/fx/version_helper_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "generators/fx/version_helper" 3 | 4 | RSpec.describe Fx::Generators::VersionHelper do 5 | describe "#previous_version" do 6 | it "returns 0 when no existing versions found" do 7 | with_temp_directory do |temp_dir| 8 | helper = described_class.new(file_name: "test_function", definition_path: temp_dir) 9 | 10 | expect(helper.previous_version).to eq(0) 11 | end 12 | end 13 | 14 | it "returns highest version number from existing files" do 15 | with_temp_directory do |temp_dir| 16 | create_version_file(temp_dir, "test_function", 1) 17 | create_version_file(temp_dir, "test_function", 3) 18 | create_version_file(temp_dir, "test_function", 2) 19 | 20 | helper = described_class.new(file_name: "test_function", definition_path: temp_dir) 21 | 22 | expect(helper.previous_version).to eq(3) 23 | end 24 | end 25 | 26 | it "ignores files that don't match the pattern" do 27 | with_temp_directory do |temp_dir| 28 | create_version_file(temp_dir, "test_function", 2) 29 | FileUtils.touch(temp_dir.join("other_function_v3.sql")) 30 | FileUtils.touch(temp_dir.join("test_function.sql")) 31 | 32 | helper = described_class.new(file_name: "test_function", definition_path: temp_dir) 33 | 34 | expect(helper.previous_version).to eq(2) 35 | end 36 | end 37 | end 38 | 39 | describe "#current_version" do 40 | it "returns previous version + 1" do 41 | with_temp_directory do |temp_dir| 42 | create_version_file(temp_dir, "test_function", 5) 43 | 44 | helper = described_class.new(file_name: "test_function", definition_path: temp_dir) 45 | 46 | expect(helper.current_version).to eq(6) 47 | end 48 | end 49 | 50 | it "returns 1 when no previous versions exist" do 51 | with_temp_directory do |temp_dir| 52 | helper = described_class.new(file_name: "test_function", definition_path: temp_dir) 53 | 54 | expect(helper.current_version).to eq(1) 55 | end 56 | end 57 | end 58 | 59 | describe "#updating_existing?" do 60 | it "returns false when no previous versions exist" do 61 | with_temp_directory do |temp_dir| 62 | helper = described_class.new(file_name: "test_function", definition_path: temp_dir) 63 | 64 | expect(helper.updating_existing?).to be false 65 | end 66 | end 67 | 68 | it "returns true when previous versions exist" do 69 | with_temp_directory do |temp_dir| 70 | create_version_file(temp_dir, "test_function", 1) 71 | 72 | helper = described_class.new(file_name: "test_function", definition_path: temp_dir) 73 | 74 | expect(helper.updating_existing?).to be true 75 | end 76 | end 77 | end 78 | 79 | describe "#creating_new?" do 80 | it "returns true when no previous versions exist" do 81 | with_temp_directory do |temp_dir| 82 | helper = described_class.new(file_name: "test_function", definition_path: temp_dir) 83 | 84 | expect(helper.creating_new?).to be true 85 | end 86 | end 87 | 88 | it "returns false when previous versions exist" do 89 | with_temp_directory do |temp_dir| 90 | create_version_file(temp_dir, "test_function", 1) 91 | 92 | helper = described_class.new(file_name: "test_function", definition_path: temp_dir) 93 | 94 | expect(helper.creating_new?).to be false 95 | end 96 | end 97 | end 98 | 99 | describe "#definition_for_version" do 100 | it "returns function definition for function type" do 101 | with_temp_directory do |temp_dir| 102 | helper = described_class.new(file_name: "test_function", definition_path: temp_dir) 103 | allow(Fx::Definition).to receive(:function) 104 | .and_return("function_definition") 105 | 106 | result = helper.definition_for_version(version: 2, type: :function) 107 | 108 | expect(result).to eq("function_definition") 109 | expect(Fx::Definition).to have_received(:function).with( 110 | name: "test_function", 111 | version: 2 112 | ) 113 | end 114 | end 115 | 116 | it "returns trigger definition for trigger type" do 117 | with_temp_directory do |temp_dir| 118 | helper = described_class.new(file_name: "test_trigger", definition_path: temp_dir) 119 | allow(Fx::Definition).to receive(:trigger) 120 | .and_return("trigger_definition") 121 | 122 | result = helper.definition_for_version(version: 3, type: :trigger) 123 | 124 | expect(result).to eq("trigger_definition") 125 | expect(Fx::Definition).to have_received(:trigger).with( 126 | name: "test_trigger", 127 | version: 3 128 | ) 129 | end 130 | end 131 | 132 | it "raises error for unknown type" do 133 | with_temp_directory do |temp_dir| 134 | helper = described_class.new(file_name: "test_function", definition_path: temp_dir) 135 | 136 | expect { 137 | helper.definition_for_version(version: 1, type: :unknown) 138 | }.to raise_error( 139 | ArgumentError, 140 | "Unknown type: unknown. Must be :function or :trigger" 141 | ) 142 | end 143 | end 144 | end 145 | 146 | private 147 | 148 | def with_temp_directory 149 | Dir.mktmpdir do |path| 150 | yield Pathname.new(path) 151 | end 152 | end 153 | 154 | def create_version_file(dir, name, version) 155 | FileUtils.touch(dir.join("#{name}_v#{version}.sql")) 156 | end 157 | end 158 | -------------------------------------------------------------------------------- /lib/fx/adapters/postgres.rb: -------------------------------------------------------------------------------- 1 | require "fx/adapters/postgres/connection" 2 | require "fx/adapters/postgres/functions" 3 | require "fx/adapters/postgres/triggers" 4 | 5 | module Fx 6 | # F(x) database adapters. 7 | # 8 | # F(x) ships with a Postgres adapter only but can be extended with 9 | # additional adapters. The {Fx::Adapters::Postgres} adapter provides the 10 | # interface. 11 | module Adapters 12 | # Creates an instance of the F(x) Postgres adapter. 13 | # 14 | # This is the default adapter for F(x). Configuring it via 15 | # {Fx.configure} is not required, but the example below shows how one 16 | # would explicitly set it. 17 | # 18 | # @param [#connection] connectable An object that returns the connection 19 | # for F(x) to use. Defaults to `ActiveRecord::Base`. 20 | # 21 | # @example 22 | # Fx.configure do |config| 23 | # config.adapter = Fx::Adapters::Postgres.new 24 | # end 25 | class Postgres 26 | # Creates an instance of the F(x) Postgres adapter. 27 | # 28 | # This is the default adapter for F(x). Configuring it via 29 | # {Fx.configure} is not required, but the example below shows how one 30 | # would explicitly set it. 31 | # 32 | # @param [#connection] connectable An object that returns the connection 33 | # for F(x) to use. Defaults to `ActiveRecord::Base`. 34 | # 35 | # @example 36 | # Fx.configure do |config| 37 | # config.adapter = Fx::Adapters::Postgres.new 38 | # end 39 | def initialize(connectable = ActiveRecord::Base) 40 | @connectable = connectable 41 | end 42 | 43 | # Returns an array of functions in the database. 44 | # 45 | # This collection of functions is used by the [Fx::SchemaDumper] to 46 | # populate the `schema.rb` file. 47 | # 48 | # @return [Array] 49 | def functions 50 | Fx::Adapters::Postgres::Functions.all(connection) 51 | end 52 | 53 | # Returns an array of triggers in the database. 54 | # 55 | # This collection of triggers is used by the [Fx::SchemaDumper] to 56 | # populate the `schema.rb` file. 57 | # 58 | # @return [Array] 59 | def triggers 60 | Fx::Adapters::Postgres::Triggers.all(connection) 61 | end 62 | 63 | # Creates a function in the database. 64 | # 65 | # This is typically called in a migration via 66 | # {Fx::Statements::Function#create_function}. 67 | # 68 | # @param sql_definition [String] The SQL schema for the function. 69 | # 70 | # @return [void] 71 | def create_function(sql_definition) 72 | execute(sql_definition) 73 | end 74 | 75 | # Creates a trigger in the database. 76 | # 77 | # This is typically called in a migration via 78 | # {Fx::Statements::Trigger#create_trigger}. 79 | # 80 | # @param sql_definition [String] The SQL schema for the trigger. 81 | # 82 | # @return [void] 83 | def create_trigger(sql_definition) 84 | execute(sql_definition) 85 | end 86 | 87 | # Updates a function in the database. 88 | # 89 | # This is typically called in a migration via 90 | # {Fx::Statements::Function#update_function}. 91 | # 92 | # @param name [String, Symbol] The name of the function. 93 | # @param sql_definition [String] The SQL schema for the function. 94 | # 95 | # @return [void] 96 | def update_function(name, sql_definition) 97 | drop_function(name) 98 | create_function(sql_definition) 99 | end 100 | 101 | # Updates a trigger in the database. 102 | # 103 | # The existing trigger is dropped and recreated using the supplied `on` 104 | # and `version` parameter. 105 | # 106 | # This is typically called in a migration via 107 | # {Fx::Statements::Function#update_trigger}. 108 | # 109 | # @param name [String, Symbol] The name of the trigger. 110 | # @param on [String, Symbol] The associated table for the trigger to update 111 | # @param sql_definition [String] The SQL schema for the trigger. 112 | # 113 | # @return [void] 114 | def update_trigger(name, on:, sql_definition:) 115 | drop_trigger(name, on: on) 116 | create_trigger(sql_definition) 117 | end 118 | 119 | # Drops the function from the database 120 | # 121 | # This is typically called in a migration via 122 | # {Fx::Statements::Function#drop_function}. 123 | # 124 | # @param name [String, Symbol] The name of the function to drop 125 | # 126 | # @return [void] 127 | def drop_function(name) 128 | if connection.support_drop_function_without_args 129 | execute("DROP FUNCTION #{name};") 130 | else 131 | execute("DROP FUNCTION #{name}();") 132 | end 133 | end 134 | 135 | # Drops the trigger from the database 136 | # 137 | # This is typically called in a migration via 138 | # {Fx::Statements::Trigger#drop_trigger}. 139 | # 140 | # @param name [String, Symbol] The name of the trigger to drop 141 | # @param on [String, Symbol] The associated table for the trigger to drop 142 | # 143 | # @return [void] 144 | def drop_trigger(name, on:) 145 | execute("DROP TRIGGER #{name} ON #{on};") 146 | end 147 | 148 | private 149 | 150 | attr_reader :connectable 151 | 152 | delegate :execute, to: :connection 153 | 154 | def connection 155 | Fx::Adapters::Postgres::Connection.new(connectable.connection) 156 | end 157 | end 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /spec/fx/schema_dumper_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe Fx::SchemaDumper, :db do 4 | it "dumps a create_function for a function in the database" do 5 | sql_definition = <<~SQL 6 | CREATE OR REPLACE FUNCTION my_function() 7 | RETURNS text AS $$ 8 | BEGIN 9 | RETURN 'test'; 10 | END; 11 | $$ LANGUAGE plpgsql; 12 | SQL 13 | connection.create_function :my_function, sql_definition: sql_definition 14 | connection.create_table :my_table 15 | stream = StringIO.new 16 | output = stream.string 17 | 18 | dump(connection: connection, stream: stream) 19 | 20 | expect(output).to match( 21 | /table "my_table".*function :my_function.*RETURN 'test';/m 22 | ) 23 | end 24 | 25 | it "dumps a create_function for a function in the database" do 26 | Fx.configuration.dump_functions_at_beginning_of_schema = true 27 | sql_definition = <<~SQL 28 | CREATE OR REPLACE FUNCTION my_function() 29 | RETURNS text AS $$ 30 | BEGIN 31 | RETURN 'test'; 32 | END; 33 | $$ LANGUAGE plpgsql; 34 | SQL 35 | connection.create_function :my_function, sql_definition: sql_definition 36 | connection.create_table :my_table 37 | stream = StringIO.new 38 | output = stream.string 39 | 40 | dump(connection: connection, stream: stream) 41 | 42 | expect(output).to( 43 | match(/function :my_function.*RETURN 'test';.*table "my_table"/m) 44 | ) 45 | 46 | Fx.configuration.dump_functions_at_beginning_of_schema = false 47 | end 48 | 49 | it "does not dump a create_function for aggregates in the database" do 50 | sql_definition = <<~SQL 51 | CREATE OR REPLACE FUNCTION test(text, text) 52 | RETURNS text AS $$ 53 | BEGIN 54 | RETURN 'test'; 55 | END; 56 | $$ LANGUAGE plpgsql; 57 | SQL 58 | 59 | aggregate_sql_definition = <<~SQL 60 | CREATE AGGREGATE aggregate_test(text) 61 | ( 62 | sfunc = test, 63 | stype = text 64 | ); 65 | SQL 66 | 67 | connection.create_function :test, sql_definition: sql_definition 68 | connection.execute aggregate_sql_definition 69 | stream = StringIO.new 70 | 71 | dump(connection: connection, stream: stream) 72 | 73 | output = stream.string 74 | expect(output).to include("create_function :test, sql_definition: <<-'SQL'") 75 | expect(output).to include("RETURN 'test';") 76 | expect(output).not_to include("aggregate_test") 77 | end 78 | 79 | it "dumps a create_trigger for a trigger in the database" do 80 | connection.execute <<~SQL 81 | CREATE TABLE users ( 82 | id int PRIMARY KEY, 83 | name varchar(256), 84 | upper_name varchar(256) 85 | ); 86 | SQL 87 | Fx.database.create_function <<~SQL 88 | CREATE OR REPLACE FUNCTION uppercase_users_name() 89 | RETURNS trigger AS $$ 90 | BEGIN 91 | NEW.upper_name = UPPER(NEW.name); 92 | RETURN NEW; 93 | END; 94 | $$ LANGUAGE plpgsql; 95 | SQL 96 | sql_definition = <<~SQL 97 | CREATE TRIGGER uppercase_users_name 98 | BEFORE INSERT ON users 99 | FOR EACH ROW 100 | EXECUTE FUNCTION uppercase_users_name(); 101 | SQL 102 | connection.create_trigger( 103 | :uppercase_users_name, 104 | sql_definition: sql_definition 105 | ) 106 | stream = StringIO.new 107 | 108 | dump(connection: connection, stream: stream) 109 | 110 | output = stream.string 111 | expect(output).to include("create_trigger :uppercase_users_name") 112 | expect(output).to include("sql_definition: <<-SQL") 113 | expect(output).to include("EXECUTE FUNCTION uppercase_users_name()") 114 | end 115 | 116 | it "dumps functions and triggers for multiple schemas" do 117 | connection.schema_search_path = "public,test_schema" 118 | connection.create_table :my_table 119 | connection.create_function :test1, sql_definition: <<~SQL 120 | CREATE OR REPLACE FUNCTION test_public_func() 121 | RETURNS TRIGGER AS $$ 122 | BEGIN 123 | RETURN 1; 124 | END; 125 | $$ LANGUAGE plpgsql; 126 | SQL 127 | connection.create_trigger :test1_trigger, sql_definition: <<~SQL 128 | CREATE TRIGGER test_public_trigger 129 | BEFORE INSERT ON my_table 130 | FOR EACH ROW 131 | EXECUTE FUNCTION test_public_func(); 132 | SQL 133 | connection.execute("CREATE SCHEMA test_schema;") 134 | connection.create_table "test_schema.my_table2" 135 | connection.execute <<~SQL 136 | CREATE OR REPLACE FUNCTION test_schema.test_schema_func() 137 | RETURNS TRIGGER AS $$ 138 | BEGIN 139 | RETURN 'test_schema'; 140 | END; 141 | $$ LANGUAGE plpgsql; 142 | SQL 143 | connection.execute <<~SQL 144 | CREATE TRIGGER test_schema_trigger 145 | BEFORE INSERT ON test_schema.my_table2 146 | FOR EACH ROW 147 | EXECUTE FUNCTION test_schema.test_schema_func(); 148 | SQL 149 | stream = StringIO.new 150 | output = stream.string 151 | 152 | dump(connection: connection, stream: stream) 153 | 154 | expect(output.scan("create_function :test_public_func").size).to eq(1) 155 | expect(output.scan("create_trigger :test_public_trigger").size).to eq(1) 156 | expect(output.scan("create_function :test_schema_func").size).to eq(1) 157 | expect(output.scan("create_trigger :test_schema_trigger").size).to eq(1) 158 | ensure 159 | connection.schema_search_path = "public" 160 | end 161 | 162 | def dump(connection:, stream:) 163 | if Rails.version >= "7.2" 164 | ActiveRecord::SchemaDumper.dump(connection.pool, stream) 165 | else 166 | ActiveRecord::SchemaDumper.dump(connection, stream) 167 | end 168 | end 169 | end 170 | -------------------------------------------------------------------------------- /spec/fx/adapters/postgres_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe Fx::Adapters::Postgres, :db do 4 | describe "#create_function" do 5 | it "successfully creates a function" do 6 | adapter = Fx::Adapters::Postgres.new 7 | adapter.create_function( 8 | <<~SQL 9 | CREATE OR REPLACE FUNCTION test() 10 | RETURNS text AS $$ 11 | BEGIN 12 | RETURN 'test'; 13 | END; 14 | $$ LANGUAGE plpgsql; 15 | SQL 16 | ) 17 | 18 | expect(adapter.functions.map(&:name)).to include("test") 19 | end 20 | end 21 | 22 | describe "#create_trigger" do 23 | it "successfully creates a trigger" do 24 | connection.execute <<~SQL 25 | CREATE TABLE users ( 26 | id int PRIMARY KEY, 27 | name varchar(256), 28 | upper_name varchar(256) 29 | ); 30 | SQL 31 | adapter = Fx::Adapters::Postgres.new 32 | adapter.create_function <<~SQL 33 | CREATE OR REPLACE FUNCTION uppercase_users_name() 34 | RETURNS trigger AS $$ 35 | BEGIN 36 | NEW.upper_name = UPPER(NEW.name); 37 | RETURN NEW; 38 | END; 39 | $$ LANGUAGE plpgsql; 40 | SQL 41 | adapter.create_trigger( 42 | <<~SQL 43 | CREATE TRIGGER uppercase_users_name 44 | BEFORE INSERT ON users 45 | FOR EACH ROW 46 | EXECUTE FUNCTION uppercase_users_name(); 47 | SQL 48 | ) 49 | 50 | expect(adapter.triggers.map(&:name)).to include("uppercase_users_name") 51 | end 52 | end 53 | 54 | describe "#drop_function" do 55 | context "when the function has arguments" do 56 | it "successfully drops a function with the entire function signature" do 57 | adapter = Fx::Adapters::Postgres.new 58 | adapter.create_function( 59 | <<~SQL 60 | CREATE FUNCTION adder(x int, y int) 61 | RETURNS int AS $$ 62 | BEGIN 63 | RETURN $1 + $2; 64 | END; 65 | $$ LANGUAGE plpgsql; 66 | SQL 67 | ) 68 | 69 | adapter.drop_function(:adder) 70 | 71 | expect(adapter.functions.map(&:name)).not_to include("adder") 72 | end 73 | end 74 | 75 | context "when the function does not have arguments" do 76 | it "successfully drops a function" do 77 | adapter = Fx::Adapters::Postgres.new 78 | adapter.create_function( 79 | <<~SQL 80 | CREATE OR REPLACE FUNCTION test() 81 | RETURNS text AS $$ 82 | BEGIN 83 | RETURN 'test'; 84 | END; 85 | $$ LANGUAGE plpgsql; 86 | SQL 87 | ) 88 | 89 | adapter.drop_function(:test) 90 | 91 | expect(adapter.functions.map(&:name)).not_to include("test") 92 | end 93 | end 94 | end 95 | 96 | describe "#functions" do 97 | it "finds functions and builds Fx::Function objects" do 98 | adapter = Fx::Adapters::Postgres.new 99 | adapter.create_function( 100 | <<~SQL 101 | CREATE OR REPLACE FUNCTION test() 102 | RETURNS text AS $$ 103 | BEGIN 104 | RETURN 'test'; 105 | END; 106 | $$ LANGUAGE plpgsql; 107 | SQL 108 | ) 109 | 110 | expect(adapter.functions.map(&:name)).to eq ["test"] 111 | end 112 | end 113 | 114 | describe "#triggers" do 115 | it "finds triggers and builds Fx::Trigger objects" do 116 | connection.execute <<~SQL 117 | CREATE TABLE users ( 118 | id int PRIMARY KEY, 119 | name varchar(256), 120 | upper_name varchar(256) 121 | ); 122 | SQL 123 | adapter = Fx::Adapters::Postgres.new 124 | adapter.create_function <<~SQL 125 | CREATE OR REPLACE FUNCTION uppercase_users_name() 126 | RETURNS trigger AS $$ 127 | BEGIN 128 | NEW.upper_name = UPPER(NEW.name); 129 | RETURN NEW; 130 | END; 131 | $$ LANGUAGE plpgsql; 132 | SQL 133 | sql_definition = <<~SQL 134 | CREATE TRIGGER uppercase_users_name 135 | BEFORE INSERT ON users 136 | FOR EACH ROW 137 | EXECUTE FUNCTION uppercase_users_name() 138 | SQL 139 | adapter.create_trigger(sql_definition) 140 | 141 | expect(adapter.triggers.map(&:name)).to eq ["uppercase_users_name"] 142 | end 143 | end 144 | 145 | describe "#support_drop_function_without_args" do 146 | it "returns true for PostgreSQL version 10.0" do 147 | adapter = Fx::Adapters::Postgres.new 148 | connection = adapter.send(:connection) 149 | allow(connection).to receive(:server_version).and_return(10_00_00) 150 | 151 | result = connection.support_drop_function_without_args 152 | 153 | expect(result).to be(true) 154 | end 155 | 156 | it "returns true for PostgreSQL version 11.0" do 157 | adapter = Fx::Adapters::Postgres.new 158 | connection = adapter.send(:connection) 159 | allow(connection).to receive(:server_version).and_return(11_00_00) 160 | 161 | result = connection.support_drop_function_without_args 162 | 163 | expect(result).to be(true) 164 | end 165 | 166 | it "returns false for PostgreSQL version 9.6" do 167 | adapter = Fx::Adapters::Postgres.new 168 | connection = adapter.send(:connection) 169 | allow(connection).to receive(:server_version).and_return(9_06_00) 170 | 171 | result = connection.support_drop_function_without_args 172 | 173 | expect(result).to be(false) 174 | end 175 | 176 | it "returns false for PostgreSQL version 9.5" do 177 | adapter = Fx::Adapters::Postgres.new 178 | connection = adapter.send(:connection) 179 | allow(connection).to receive(:server_version).and_return(9_05_00) 180 | 181 | result = connection.support_drop_function_without_args 182 | 183 | expect(result).to be(false) 184 | end 185 | end 186 | end 187 | -------------------------------------------------------------------------------- /spec/fx/command_recorder_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe Fx::CommandRecorder, :db do 4 | describe "#create_function" do 5 | it "records the created function" do 6 | recorder = ActiveRecord::Migration::CommandRecorder.new 7 | 8 | recorder.create_function :test 9 | 10 | expect(recorder.commands).to eq([[:create_function, [:test], nil]]) 11 | end 12 | 13 | it "reverts to drop_function" do 14 | recorder = ActiveRecord::Migration::CommandRecorder.new 15 | 16 | recorder.create_function :test 17 | 18 | expect(recorder.commands).to eq([[:create_function, [:test], nil]]) 19 | end 20 | 21 | it "reverts to drop_function" do 22 | recorder = ActiveRecord::Migration::CommandRecorder.new 23 | 24 | recorder.revert { recorder.create_function :test } 25 | 26 | expect(recorder.commands).to eq([[:drop_function, [:test]]]) 27 | end 28 | end 29 | 30 | describe "#drop_function" do 31 | it "records the dropped function" do 32 | recorder = ActiveRecord::Migration::CommandRecorder.new 33 | 34 | recorder.drop_function :test 35 | 36 | expect(recorder.commands).to eq([[:drop_function, [:test], nil]]) 37 | end 38 | 39 | it "reverts to create_function with specified revert_to_version" do 40 | recorder = ActiveRecord::Migration::CommandRecorder.new 41 | args = [:test, {revert_to_version: 3}] 42 | revert_args = [:test, {version: 3}] 43 | 44 | recorder.revert { recorder.drop_function(*args) } 45 | 46 | expect(recorder.commands).to eq([[:create_function, revert_args]]) 47 | end 48 | 49 | it "raises when reverting without revert_to_version set" do 50 | recorder = ActiveRecord::Migration::CommandRecorder.new 51 | args = [:test, {another_argument: 1}] 52 | 53 | expect do 54 | recorder.revert { recorder.drop_function(*args) } 55 | end.to raise_error(ActiveRecord::IrreversibleMigration) 56 | end 57 | end 58 | 59 | describe "#update_function" do 60 | it "records the updated function" do 61 | recorder = ActiveRecord::Migration::CommandRecorder.new 62 | args = [:test, {version: 2}] 63 | 64 | recorder.update_function(*args) 65 | 66 | expect(recorder.commands).to eq([[:update_function, args, nil]]) 67 | end 68 | 69 | it "reverts to update_function with the specified revert_to_version" do 70 | recorder = ActiveRecord::Migration::CommandRecorder.new 71 | args = [:test, {version: 2, revert_to_version: 1}] 72 | revert_args = [:test, {version: 1}] 73 | 74 | recorder.revert { recorder.update_function(*args) } 75 | 76 | expect(recorder.commands).to eq([[:update_function, revert_args]]) 77 | end 78 | 79 | it "raises when reverting without revert_to_version set" do 80 | recorder = ActiveRecord::Migration::CommandRecorder.new 81 | args = [:test, {version: 42, another_argument: 1}] 82 | 83 | expect do 84 | recorder.revert { recorder.update_function(*args) } 85 | end.to raise_error(ActiveRecord::IrreversibleMigration) 86 | end 87 | end 88 | 89 | describe "#create_trigger" do 90 | it "records the created trigger" do 91 | recorder = ActiveRecord::Migration::CommandRecorder.new 92 | 93 | recorder.create_trigger :greetings 94 | 95 | expect(recorder.commands).to eq([[:create_trigger, [:greetings], nil]]) 96 | end 97 | 98 | it "reverts to drop_trigger" do 99 | recorder = ActiveRecord::Migration::CommandRecorder.new 100 | 101 | recorder.create_trigger :greetings 102 | 103 | expect(recorder.commands).to eq([[:create_trigger, [:greetings], nil]]) 104 | end 105 | 106 | it "reverts to drop_trigger" do 107 | recorder = ActiveRecord::Migration::CommandRecorder.new 108 | 109 | recorder.revert { recorder.create_trigger :greetings } 110 | 111 | expect(recorder.commands).to eq([[:drop_trigger, [:greetings]]]) 112 | end 113 | end 114 | 115 | describe "#drop_trigger" do 116 | it "records the dropped trigger" do 117 | recorder = ActiveRecord::Migration::CommandRecorder.new 118 | 119 | recorder.drop_trigger :users 120 | 121 | expect(recorder.commands).to eq([[:drop_trigger, [:users], nil]]) 122 | end 123 | 124 | it "reverts to create_trigger with specified revert_to_version" do 125 | recorder = ActiveRecord::Migration::CommandRecorder.new 126 | args = [:users, {revert_to_version: 3}] 127 | revert_args = [:users, {version: 3}] 128 | 129 | recorder.revert { recorder.drop_trigger(*args) } 130 | 131 | expect(recorder.commands).to eq([[:create_trigger, revert_args]]) 132 | end 133 | 134 | it "raises when reverting without revert_to_version set" do 135 | recorder = ActiveRecord::Migration::CommandRecorder.new 136 | args = [:users, {another_argument: 1}] 137 | 138 | expect do 139 | recorder.revert { recorder.drop_trigger(*args) } 140 | end.to raise_error(ActiveRecord::IrreversibleMigration) 141 | end 142 | end 143 | 144 | describe "#update_trigger" do 145 | it "records the updated trigger" do 146 | recorder = ActiveRecord::Migration::CommandRecorder.new 147 | args = [:users, {version: 2}] 148 | 149 | recorder.update_trigger(*args) 150 | 151 | expect(recorder.commands).to eq([[:update_trigger, args, nil]]) 152 | end 153 | 154 | it "reverts to update_trigger with the specified revert_to_version" do 155 | recorder = ActiveRecord::Migration::CommandRecorder.new 156 | args = [:users, {version: 2, revert_to_version: 1}] 157 | revert_args = [:users, {version: 1}] 158 | 159 | recorder.revert { recorder.update_trigger(*args) } 160 | 161 | expect(recorder.commands).to eq([[:update_trigger, revert_args]]) 162 | end 163 | 164 | it "raises when reverting without revert_to_version set" do 165 | recorder = ActiveRecord::Migration::CommandRecorder.new 166 | args = [:users, {version: 42, another_argument: 1}] 167 | 168 | expect do 169 | recorder.revert { recorder.update_trigger(*args) } 170 | end.to raise_error(ActiveRecord::IrreversibleMigration) 171 | end 172 | end 173 | end 174 | -------------------------------------------------------------------------------- /spec/fx/statements_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe Fx::Statements, :db do 4 | describe "#create_function" do 5 | it "creates a function from a file" do 6 | database = stubbed_database 7 | definition = stubbed_definition 8 | 9 | connection.create_function(:test) 10 | 11 | expect(database).to have_received(:create_function) 12 | .with(definition.to_sql) 13 | expect(Fx::Definition).to have_received(:function) 14 | .with(name: :test, version: 1) 15 | end 16 | 17 | it "allows creating a function with a specific version" do 18 | database = stubbed_database 19 | definition = stubbed_definition 20 | 21 | connection.create_function(:test, version: 2) 22 | 23 | expect(database).to have_received(:create_function) 24 | .with(definition.to_sql) 25 | expect(Fx::Definition).to have_received(:function) 26 | .with(name: :test, version: 2) 27 | end 28 | 29 | it "raises an error if both arguments are nil" do 30 | expect do 31 | connection.create_function( 32 | :whatever, 33 | version: nil, 34 | sql_definition: nil 35 | ) 36 | end.to raise_error( 37 | ArgumentError, 38 | /version or sql_definition must be specified/ 39 | ) 40 | end 41 | end 42 | 43 | describe "#drop_function" do 44 | it "drops the function" do 45 | database = stubbed_database 46 | 47 | connection.drop_function(:test) 48 | 49 | expect(database).to have_received(:drop_function).with(:test) 50 | end 51 | end 52 | 53 | describe "#update_function" do 54 | it "updates the function" do 55 | database = stubbed_database 56 | definition = stubbed_definition 57 | 58 | connection.update_function(:test, version: 3) 59 | 60 | expect(database).to have_received(:update_function) 61 | .with(:test, definition.to_sql) 62 | expect(Fx::Definition).to have_received(:function) 63 | .with(name: :test, version: 3) 64 | end 65 | 66 | it "updates a function from a text definition" do 67 | database = stubbed_database 68 | 69 | connection.update_function(:test, sql_definition: "a definition") 70 | 71 | expect(database).to have_received(:update_function) 72 | .with(:test, "a definition") 73 | end 74 | 75 | it "raises an error if not supplied a version" do 76 | expect do 77 | connection.update_function( 78 | :whatever, 79 | version: nil, 80 | sql_definition: nil 81 | ) 82 | end.to raise_error( 83 | ArgumentError, 84 | /version or sql_definition must be specified/ 85 | ) 86 | end 87 | end 88 | 89 | describe "#create_trigger" do 90 | it "creates a trigger from a file" do 91 | database = stubbed_database 92 | definition = stubbed_definition 93 | 94 | connection.create_trigger(:test) 95 | 96 | expect(database).to have_received(:create_trigger) 97 | .with(definition.to_sql) 98 | expect(Fx::Definition).to have_received(:trigger) 99 | .with(name: :test, version: 1) 100 | end 101 | 102 | it "allows creating a trigger with a specific version" do 103 | database = stubbed_database 104 | definition = stubbed_definition 105 | 106 | connection.create_trigger(:test, version: 2) 107 | 108 | expect(database).to have_received(:create_trigger) 109 | .with(definition.to_sql) 110 | expect(Fx::Definition).to have_received(:trigger) 111 | .with(name: :test, version: 2) 112 | end 113 | 114 | it "raises an error if both arguments are set" do 115 | stubbed_database 116 | 117 | expect do 118 | connection.create_trigger( 119 | :whatever, 120 | version: 1, 121 | sql_definition: "a definition" 122 | ) 123 | end.to raise_error( 124 | ArgumentError, 125 | /cannot both be set/ 126 | ) 127 | end 128 | end 129 | 130 | describe "#drop_trigger" do 131 | it "drops the trigger" do 132 | database = stubbed_database 133 | 134 | connection.drop_trigger(:test, on: :users) 135 | 136 | expect(database).to have_received(:drop_trigger) 137 | .with(:test, on: :users) 138 | end 139 | end 140 | 141 | describe "#update_trigger" do 142 | it "updates the trigger" do 143 | database = stubbed_database 144 | definition = stubbed_definition 145 | 146 | connection.update_trigger(:test, on: :users, version: 3) 147 | 148 | expect(database).to have_received(:update_trigger).with( 149 | :test, 150 | on: :users, 151 | sql_definition: definition.to_sql 152 | ) 153 | expect(Fx::Definition).to have_received(:trigger).with( 154 | name: :test, 155 | version: 3 156 | ) 157 | end 158 | 159 | it "updates a trigger from a text definition" do 160 | database = stubbed_database 161 | 162 | connection.update_trigger( 163 | :test, 164 | on: :users, 165 | sql_definition: "a definition" 166 | ) 167 | 168 | expect(database).to have_received(:update_trigger).with( 169 | :test, 170 | on: :users, 171 | sql_definition: "a definition" 172 | ) 173 | end 174 | 175 | it "raises an error if not supplied a version" do 176 | expect do 177 | connection.update_trigger( 178 | :whatever, 179 | version: nil, 180 | sql_definition: nil 181 | ) 182 | end.to raise_error( 183 | ArgumentError, 184 | /version or sql_definition must be specified/ 185 | ) 186 | end 187 | 188 | it "raises an error if both arguments are set" do 189 | stubbed_database 190 | 191 | expect do 192 | connection.update_trigger( 193 | :whatever, 194 | version: 1, 195 | sql_definition: "a definition" 196 | ) 197 | end.to raise_error( 198 | ArgumentError, 199 | /cannot both be set/ 200 | ) 201 | end 202 | end 203 | 204 | def stubbed_definition 205 | instance_double("Fx::Definition", to_sql: nil).tap do |stubbed_definition| 206 | allow(Fx::Definition).to receive(:function).and_return(stubbed_definition) 207 | allow(Fx::Definition).to receive(:trigger).and_return(stubbed_definition) 208 | end 209 | end 210 | 211 | def stubbed_database 212 | database = instance_spy("StubbedDatabase") 213 | allow(Fx).to receive(:database).and_return(database) 214 | 215 | database 216 | end 217 | end 218 | -------------------------------------------------------------------------------- /lib/fx/statements.rb: -------------------------------------------------------------------------------- 1 | module Fx 2 | # @api private 3 | module Statements 4 | # Create a new database function. 5 | # 6 | # @param name [String, Symbol] The name of the database function. 7 | # @param version [Integer] The version number of the function, used to 8 | # find the definition file in `db/functions`. This defaults to `1` if 9 | # not provided. 10 | # @param sql_definition [String] The SQL query for the function schema. 11 | # If both `sql_definition` and `version` are provided, 12 | # `sql_definition` takes precedence. 13 | # @return [void] The database response from executing the create statement. 14 | # 15 | # @example Create from `db/functions/uppercase_users_name_v02.sql` 16 | # create_function(:uppercase_users_name, version: 2) 17 | # 18 | # @example Create from provided SQL string 19 | # create_function(:uppercase_users_name, sql_definition: <<~SQL) 20 | # CREATE OR REPLACE FUNCTION uppercase_users_name() 21 | # RETURNS trigger AS $$ 22 | # BEGIN 23 | # NEW.upper_name = UPPER(NEW.name); 24 | # RETURN NEW; 25 | # END; 26 | # $$ LANGUAGE plpgsql; 27 | # SQL 28 | # 29 | def create_function(name, options = {}) 30 | version = options.fetch(:version, 1) 31 | sql_definition = options[:sql_definition] 32 | 33 | validate_version_or_sql_definition_present!(version, sql_definition) 34 | sql_definition = resolve_sql_definition(sql_definition, name, version, :function) 35 | 36 | Fx.database.create_function(sql_definition) 37 | end 38 | 39 | # Drop a database function by name. 40 | # 41 | # @param name [String, Symbol] The name of the database function. 42 | # @param revert_to_version [Integer] Used to reverse the `drop_function` 43 | # command on `rake db:rollback`. The provided version will be passed as 44 | # the `version` argument to {#create_function}. 45 | # @return [void] The database response from executing the drop statement. 46 | # 47 | # @example Drop a function, rolling back to version 2 on rollback 48 | # drop_function(:uppercase_users_name, revert_to_version: 2) 49 | # 50 | def drop_function(name, options = {}) 51 | Fx.database.drop_function(name) 52 | end 53 | 54 | # Update a database function. 55 | # 56 | # @param name [String, Symbol] The name of the database function. 57 | # @param version [Integer] The version number of the function, used to 58 | # find the definition file in `db/functions`. This defaults to `1` if 59 | # not provided. 60 | # @param sql_definition [String] The SQL query for the function schema. 61 | # If both `sql_definition` and `version` are provided, 62 | # `sql_definition` takes precedence. 63 | # @return [void] The database response from executing the create statement. 64 | # 65 | # @example Update function to a given version 66 | # update_function( 67 | # :uppercase_users_name, 68 | # version: 3, 69 | # revert_to_version: 2, 70 | # ) 71 | # 72 | # @example Update function from provided SQL string 73 | # update_function(:uppercase_users_name, sql_definition: <<~SQL) 74 | # CREATE OR REPLACE FUNCTION uppercase_users_name() 75 | # RETURNS trigger AS $$ 76 | # BEGIN 77 | # NEW.upper_name = UPPER(NEW.name); 78 | # RETURN NEW; 79 | # END; 80 | # $$ LANGUAGE plpgsql; 81 | # SQL 82 | # 83 | def update_function(name, options = {}) 84 | version = options[:version] 85 | sql_definition = options[:sql_definition] 86 | 87 | validate_version_or_sql_definition_present!(version, sql_definition) 88 | 89 | sql_definition = resolve_sql_definition(sql_definition, name, version, :function) 90 | 91 | Fx.database.update_function(name, sql_definition) 92 | end 93 | 94 | # Create a new database trigger. 95 | # 96 | # @param name [String, Symbol] The name of the database trigger. 97 | # @param version [Integer] The version number of the trigger, used to 98 | # find the definition file in `db/triggers`. This defaults to `1` if 99 | # not provided. 100 | # @param sql_definition [String] The SQL query for the function. An error 101 | # will be raised if `sql_definition` and `version` are both set, 102 | # as they are mutually exclusive. 103 | # @return [void] The database response from executing the create statement. 104 | # 105 | # @example Create trigger from `db/triggers/uppercase_users_name_v01.sql` 106 | # create_trigger(:uppercase_users_name, version: 1) 107 | # 108 | # @example Create trigger from provided SQL string 109 | # create_trigger(:uppercase_users_name, sql_definition: <<~SQL) 110 | # CREATE TRIGGER uppercase_users_name 111 | # BEFORE INSERT ON users 112 | # FOR EACH ROW 113 | # EXECUTE FUNCTION uppercase_users_name(); 114 | # SQL 115 | # 116 | def create_trigger(name, options = {}) 117 | version = options[:version] 118 | sql_definition = options[:sql_definition] 119 | 120 | validate_version_and_sql_definition_exclusive!(version, sql_definition) 121 | 122 | version ||= 1 123 | 124 | sql_definition = resolve_sql_definition(sql_definition, name, version, :trigger) 125 | 126 | Fx.database.create_trigger(sql_definition) 127 | end 128 | 129 | # Drop a database trigger by name. 130 | # 131 | # @param name [String, Symbol] The name of the database trigger. 132 | # @param on [String, Symbol] The name of the table the database trigger 133 | # is associated with. 134 | # @param revert_to_version [Integer] Used to reverse the `drop_trigger` 135 | # command on `rake db:rollback`. The provided version will be passed as 136 | # the `version` argument to {#create_trigger}. 137 | # @return [void] The database response from executing the drop statement. 138 | # 139 | # @example Drop a trigger, rolling back to version 3 on rollback 140 | # drop_trigger(:log_inserts, on: :users, revert_to_version: 3) 141 | # 142 | def drop_trigger(name, options = {}) 143 | on = options.fetch(:on) 144 | Fx.database.drop_trigger(name, on: on) 145 | end 146 | 147 | # Update a database trigger to a new version. 148 | # 149 | # The existing trigger is dropped and recreated using the supplied `on` 150 | # and `version` parameter. 151 | # 152 | # @param name [String, Symbol] The name of the database trigger. 153 | # @param version [Integer] The version number of the trigger. 154 | # @param on [String, Symbol] The name of the table the database trigger 155 | # is associated with. 156 | # @param sql_definition [String] The SQL query for the function. An error 157 | # will be raised if `sql_definition` and `version` are both set, 158 | # as they are mutually exclusive. 159 | # @param revert_to_version [Integer] The version number to rollback to on 160 | # `rake db rollback` 161 | # @return [void] The database response from executing the create statement. 162 | # 163 | # @example Update trigger to a given version 164 | # update_trigger( 165 | # :log_inserts, 166 | # on: :users, 167 | # version: 3, 168 | # revert_to_version: 2, 169 | # ) 170 | # 171 | # @example Update trigger from provided SQL string 172 | # update_trigger(:uppercase_users_name, sql_definition: <<~SQL) 173 | # CREATE TRIGGER uppercase_users_name 174 | # BEFORE INSERT ON users 175 | # FOR EACH ROW 176 | # EXECUTE FUNCTION uppercase_users_name(); 177 | # SQL 178 | # 179 | def update_trigger(name, options = {}) 180 | version = options[:version] 181 | on = options[:on] 182 | sql_definition = options[:sql_definition] 183 | 184 | validate_version_or_sql_definition_present!(version, sql_definition) 185 | validate_version_and_sql_definition_exclusive!(version, sql_definition) 186 | 187 | if on.nil? 188 | raise ArgumentError, "on is required" 189 | end 190 | 191 | sql_definition = resolve_sql_definition(sql_definition, name, version, :trigger) 192 | 193 | Fx.database.update_trigger( 194 | name, 195 | on: on, 196 | sql_definition: sql_definition 197 | ) 198 | end 199 | 200 | private 201 | 202 | VERSION_OR_SQL_DEFINITION_REQUIRED = "version or sql_definition must be specified".freeze 203 | private_constant :VERSION_OR_SQL_DEFINITION_REQUIRED 204 | 205 | VERSION_AND_SQL_DEFINITION_EXCLUSIVE = "sql_definition and version cannot both be set".freeze 206 | private_constant :VERSION_AND_SQL_DEFINITION_EXCLUSIVE 207 | 208 | def validate_version_or_sql_definition_present!(version, sql_definition) 209 | if version.nil? && sql_definition.nil? 210 | raise ArgumentError, VERSION_OR_SQL_DEFINITION_REQUIRED, caller 211 | end 212 | end 213 | 214 | def validate_version_and_sql_definition_exclusive!(version, sql_definition) 215 | if version.present? && sql_definition.present? 216 | raise ArgumentError, VERSION_AND_SQL_DEFINITION_EXCLUSIVE, caller 217 | end 218 | end 219 | 220 | def resolve_sql_definition(sql_definition, name, version, type) 221 | return sql_definition.strip_heredoc if sql_definition 222 | 223 | definition = 224 | case type 225 | when :function 226 | Fx::Definition.function(name: name, version: version) 227 | when :trigger 228 | Fx::Definition.trigger(name: name, version: version) 229 | else 230 | raise ArgumentError, "Unknown type: #{type}. Must be :function or :trigger", caller 231 | end 232 | 233 | definition.to_sql 234 | end 235 | end 236 | end 237 | --------------------------------------------------------------------------------