fx-aggregate 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e151a7544a00f2a530deac87732962dd53a23de1cede96e75ef638356bda8179
4
+ data.tar.gz: 3997927b6b491bbada4786486a96f83832be7e5dc6bd7f7a3d0ec69166507466
5
+ SHA512:
6
+ metadata.gz: 041fa0fff61293ec4ed61020c3c5e6fe36b1300c3ce0ff885ae6679acb791b83b4c3ebb77139c7b06041bd5fea42db0236e786c32adb090007db65c6d11cb5ee
7
+ data.tar.gz: b53284c7853da505f78621622bd43e24043b273b07fca485244079e8675e1eb2cd36dc0658f1d8da3c90895b38b6a53bcdbdbd2291498a2dc9ee4e0f2ec3fc9e
@@ -0,0 +1,53 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: main
6
+ pull_request:
7
+ branches: "*"
8
+
9
+ jobs:
10
+ tests:
11
+ name: Ruby ${{ matrix.ruby }}, Rails ${{ matrix.rails }}
12
+ runs-on: ubuntu-latest
13
+
14
+ strategy:
15
+ fail-fast: false
16
+ matrix:
17
+ ruby: ["3.0", "3.1", "3.2", "3.3", "3.4"]
18
+ rails: ["7.0", "7.1", "7.2", "8.0.0"]
19
+ continue-on-error: [false]
20
+
21
+ services:
22
+ postgres:
23
+ image: postgres:13
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
data/.gitignore ADDED
@@ -0,0 +1,13 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+
10
+ # rspec failure tracking
11
+ .rspec_status
12
+
13
+ /Gemfile.lock
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --require spec_helper
data/.standard.yml ADDED
@@ -0,0 +1,3 @@
1
+ ignore:
2
+ - 'spec/dummy/db/schema.rb'
3
+ - '**/tmp/*'
data/.tool-versions ADDED
@@ -0,0 +1 @@
1
+ ruby 3.3.1
data/Gemfile ADDED
@@ -0,0 +1,16 @@
1
+ source "https://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in fx-aggregate.gemspec
4
+ gemspec
5
+
6
+ gem "ammeter", ">= 1.1.3"
7
+ gem "bundler", ">= 1.5"
8
+ gem "database_cleaner"
9
+ gem "pg"
10
+ gem "pry"
11
+ gem "rake"
12
+ gem "redcarpet"
13
+ gem "rspec", ">= 3.3"
14
+ gem "standardrb"
15
+ gem "yard"
16
+ gem "warning"
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Agustin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # Fx::Aggregate
2
+
3
+ Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/fx/aggregate`. To experiment with that code, run `bin/console` for an interactive prompt.
4
+
5
+ TODO: Delete this and the text above, and describe your gem
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem 'fx-aggregate'
13
+ ```
14
+
15
+ And then execute:
16
+
17
+ $ bundle install
18
+
19
+ Or install it yourself as:
20
+
21
+ $ gem install fx-aggregate
22
+
23
+ ## Usage
24
+
25
+ TODO: Write usage instructions here
26
+
27
+ ## Development
28
+
29
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
30
+
31
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
32
+
33
+ ## Contributing
34
+
35
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/fx-aggregate. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/fx-aggregate/blob/master/CODE_OF_CONDUCT.md).
36
+
37
+
38
+ ## License
39
+
40
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
41
+
42
+ ## Code of Conduct
43
+
44
+ Everyone interacting in the Fx::Aggregate project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/fx-aggregate/blob/master/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,24 @@
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]
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "fx-aggregate"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ require "pry"
11
+ Pry.start
12
+
13
+ # require "irb"
14
+ # IRB.start(__FILE__)
data/bin/rake ADDED
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+ #
4
+ # This file was generated by Bundler.
5
+ #
6
+ # The application 'rake' is installed as part of a gem, and
7
+ # this file is here to facilitate running it.
8
+ #
9
+
10
+ require "pathname"
11
+ ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
12
+ Pathname.new(__FILE__).realpath)
13
+
14
+ require "rubygems"
15
+ require "bundler/setup"
16
+
17
+ load Gem.bin_path("rake", "rake")
data/bin/rspec ADDED
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+ #
4
+ # This file was generated by Bundler.
5
+ #
6
+ # The application 'rspec' is installed as part of a gem, and
7
+ # this file is here to facilitate running it.
8
+ #
9
+
10
+ require "pathname"
11
+ ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
12
+ Pathname.new(__FILE__).realpath)
13
+
14
+ require "rubygems"
15
+ require "bundler/setup"
16
+
17
+ load Gem.bin_path("rspec-core", "rspec")
data/bin/setup ADDED
@@ -0,0 +1,9 @@
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
data/bin/standardrb ADDED
@@ -0,0 +1,27 @@
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")
@@ -0,0 +1,34 @@
1
+ require_relative "lib/fx-aggregate/version"
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "fx-aggregate"
5
+ spec.version = FxAggregate::VERSION
6
+ spec.authors = ["Agustin"]
7
+ spec.email = ["agustin@mailbutler.io"]
8
+
9
+ spec.summary = "An extension for the fx gem to manage PostgreSQL aggregate functions in Rails migrations."
10
+ spec.description = <<~DESCRIPTION
11
+ Adds methods to ActiveRecord::Migration to create and manage database aggregates
12
+ functions in Rails
13
+ DESCRIPTION
14
+ spec.homepage = "https://github.com/agustin/fx-aggregate"
15
+ spec.license = "MIT"
16
+
17
+ spec.required_ruby_version = Gem::Requirement.new(">= 2.7.0")
18
+
19
+ spec.metadata = {
20
+ "bug_tracker_uri" => "#{spec.homepage}/issues",
21
+ "changelog_uri" => "#{spec.homepage}/blob/v#{spec.version}/CHANGELOG.md",
22
+ "homepage_uri" => spec.homepage,
23
+ "source_code_uri" => spec.homepage
24
+ }
25
+
26
+ # Specify which files should be added to the gem when it is released.
27
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
28
+ spec.files = Dir.chdir(File.expand_path("..", __FILE__)) do
29
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
30
+ end
31
+ spec.require_paths = ["lib"]
32
+
33
+ spec.add_dependency "fx", ">= 0.9"
34
+ end
@@ -0,0 +1,61 @@
1
+ require "fx-aggregate/aggregate"
2
+
3
+ module Fx
4
+ module Adapters
5
+ class Postgres
6
+ # Fetches defined aggregates from the postgres connection.
7
+ # @api private
8
+ class Aggregates
9
+ # The SQL query used by F(x) to retrieve the aggregates considered
10
+ # dumpable into `db/schema.rb`.
11
+ AGGREGATES_WITH_DEFINITIONS_QUERY = <<-EOS.freeze
12
+ SELECT
13
+ pp.proname AS name,
14
+ pg_get_function_identity_arguments(pp.oid) AS arguments,
15
+ pa.*,
16
+ format_type(pa.aggtranstype, null) AS aggtranstype,
17
+ format_type(pa.aggmtranstype, null) AS aggmtranstype
18
+ FROM pg_proc pp
19
+ JOIN pg_aggregate pa
20
+ ON pa.aggfnoid = pp.oid
21
+ JOIN pg_namespace pn
22
+ ON pn.oid = pp.pronamespace
23
+ LEFT JOIN pg_depend pd
24
+ ON pd.objid = pp.oid AND pd.deptype = 'e'
25
+ WHERE pn.nspname = 'public' AND pd.objid IS NULL
26
+ ORDER BY pp.oid;
27
+ EOS
28
+
29
+ # Wraps #all as a static facade.
30
+ #
31
+ # @return [Array<Fx::Aggregate>]
32
+ def self.all(*args)
33
+ new(*args).all
34
+ end
35
+
36
+ def initialize(connection)
37
+ @connection = connection
38
+ end
39
+
40
+ # All of the aggregates that this connection has defined.
41
+ #
42
+ # @return [Array<Fx::Aggregate>]
43
+ def all
44
+ aggregates_from_postgres.map { |aggregate| to_fx_aggregate(aggregate) }
45
+ end
46
+
47
+ private
48
+
49
+ attr_reader :connection
50
+
51
+ def aggregates_from_postgres
52
+ connection.execute(AGGREGATES_WITH_DEFINITIONS_QUERY)
53
+ end
54
+
55
+ def to_fx_aggregate(result)
56
+ Fx::Aggregate.new(result)
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,60 @@
1
+ require "fx-aggregate/adapters/postgres/aggregates"
2
+
3
+ module FxAggregate
4
+ module Adapters
5
+ module Postgres
6
+ # Returns an array of aggregates in the database.
7
+ #
8
+ # This collection of aggregates is used by the [Fx::SchemaDumper] to
9
+ # populate the `schema.rb` file.
10
+ #
11
+ # @return [Array<Fx::Aggregate>]
12
+ def aggregates
13
+ ::Fx::Adapters::Postgres::Aggregates.all(connection)
14
+ end
15
+
16
+ # Creates an aggregate in the database.
17
+ #
18
+ # This is typically called in a migration via
19
+ # {Fx::Statements::Aggregate#create_aggregate}.
20
+ #
21
+ # @param sql_definition The SQL schema for the aggregate.
22
+ #
23
+ # @return [void]
24
+ def create_aggregate(sql_definition)
25
+ execute(sql_definition)
26
+ end
27
+
28
+ # # Updates an aggregate in the database.
29
+ #
30
+ # This is typically called in a migration via
31
+ # {Fx::Statements::Aggregate#update_aggregate}.
32
+ #
33
+ # @param name The name of the aggregate.
34
+ # @param sql_definition The SQL schema for the aggregate.
35
+ #
36
+ # @return [void]
37
+ def update_aggregate(name, sql_definition)
38
+ drop_aggregate(name)
39
+ create_aggregate(sql_definition)
40
+ end
41
+
42
+ # Drops the aggregate from the database
43
+ #
44
+ # This is typically called in a migration via
45
+ # {Fx::Statements::Aggregate#drop_aggregate}.
46
+ #
47
+ # @param name The name of the aggregate to drop
48
+ #
49
+ # @return [void]
50
+ def drop_aggregate(name)
51
+ defs = aggregates.select { |aggregate| aggregate.name == name.to_s }
52
+
53
+ defs.each do |aggregate|
54
+ execute "DROP AGGREGATE #{name}(#{aggregate.arguments});"
55
+ end
56
+ # execute "DROP AGGREGATE #{name}();"
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,75 @@
1
+ module Fx
2
+ class Aggregate
3
+ include Comparable
4
+
5
+ attr_reader :name, :arguments, :definition
6
+ delegate :<=>, to: :name
7
+
8
+ def initialize(aggregate_row)
9
+ @name = aggregate_row.fetch("name")
10
+ @arguments = aggregate_row.fetch("arguments", "")
11
+ @definition = aggregate_row.except("name", "arguments")
12
+ end
13
+
14
+ def ==(other)
15
+ other.is_a?(self.class) &&
16
+ name == other.name &&
17
+ arguments == other.arguments &&
18
+ definition == other.definition
19
+ end
20
+
21
+ def to_schema
22
+ <<-SCHEMA
23
+ create_aggregate :#{name}, sql_definition: <<-\SQL
24
+ CREATE AGGREGATE #{name}(#{arguments})(
25
+ #{options_for_create_statement.join(",\n").indent(6).lstrip}
26
+ );
27
+ SQL
28
+ SCHEMA
29
+ end
30
+
31
+ private
32
+
33
+ Field = Struct.new(:option, :type)
34
+ private_constant :Field
35
+
36
+ # Maps pg_aggregate columns to their definition field and type.
37
+ FIELDS = {
38
+ "aggtransfn" => Field.new("SFUNC", "raw"),
39
+ "aggtranstype" => Field.new("STYPE", "raw"),
40
+ "aggtransspace" => Field.new("SSPACE", "int"),
41
+ "aggfinalfn" => Field.new("FINALFUNC", "raw"),
42
+ "aggfinalextra" => Field.new("FINALFUNC_EXTRA", "bool"),
43
+ "agginitval" => Field.new("INITCOND", "string")
44
+ }.freeze
45
+
46
+ def options_for_create_statement
47
+ FIELDS.map do |key, field|
48
+ value = format_value(field.type, @definition[key])
49
+ next if !value
50
+
51
+ if value == true
52
+ field.option
53
+ else
54
+ "#{field.option} = #{value}"
55
+ end
56
+ end.compact
57
+ end
58
+
59
+ def format_value(type, value)
60
+ return if value.nil?
61
+ return if value == "-"
62
+
63
+ case type
64
+ when "bool"
65
+ return value == "t"
66
+ when "int"
67
+ return if value.to_i == 0
68
+ when "string"
69
+ return "'#{value}'"
70
+ end
71
+
72
+ value
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,27 @@
1
+ module FxAggregate
2
+ module CommandRecorder
3
+ def create_aggregate(*args)
4
+ record(:create_aggregate, args)
5
+ end
6
+
7
+ def drop_aggregate(*args)
8
+ record(:drop_aggregate, args)
9
+ end
10
+
11
+ def update_aggregate(*args)
12
+ record(:update_aggregate, args)
13
+ end
14
+
15
+ def invert_create_aggregate(args)
16
+ [:drop_aggregate, args]
17
+ end
18
+
19
+ def invert_drop_aggregate(args)
20
+ perform_inversion(:create_aggregate, args)
21
+ end
22
+
23
+ def invert_update_aggregate(args)
24
+ perform_inversion(:update_aggregate, args)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,15 @@
1
+ module FxAggregate
2
+ module Definition
3
+ AGGREGATE = "aggregate".freeze
4
+
5
+ def self.included(base)
6
+ base.extend(ClassMethods)
7
+ end
8
+
9
+ module ClassMethods
10
+ def aggregate(name:, version:)
11
+ new(name: name, version: version, type: AGGREGATE)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ require "rails/railtie"
2
+
3
+ module FxAggregate
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-aggregate.load", after: "fx.load" do
10
+ ActiveSupport.on_load :active_record do
11
+ FxAggregate.load
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,24 @@
1
+ module FxAggregate
2
+ module SchemaDumper
3
+ def tables(stream)
4
+ super
5
+ aggregates(stream)
6
+ end
7
+
8
+ private
9
+
10
+ def aggregates(stream)
11
+ if dumpable_aggregates_in_database.any?
12
+ stream.puts
13
+ end
14
+
15
+ dumpable_aggregates_in_database.each do |aggregate|
16
+ stream.puts(aggregate.to_schema)
17
+ end
18
+ end
19
+
20
+ def dumpable_aggregates_in_database
21
+ @_dumpable_aggregates_in_database ||= Fx.database.aggregates
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,104 @@
1
+ module FxAggregate
2
+ module Statements
3
+ # Create a new database aggregate.
4
+ #
5
+ # @param name [String, Symbol] The name of the database aggregate.
6
+ # @param version [Fixnum] The version number of the aggregate, used to
7
+ # find the definition file in `db/aggregates`. This defaults to `1` if
8
+ # not provided.
9
+ # @param sql_definition [String] The SQL query for the aggregate schema.
10
+ # If both `sql_definition` and `version` are provided,
11
+ # `sql_definition` takes prescedence.
12
+ # @return The database response from executing the create statement.
13
+ #
14
+ # @example Create from `db/aggregates/median_v2.sql`
15
+ # create_aggregate(:median, version: 2)
16
+ #
17
+ # @example Create from provided SQL string
18
+ # create_aggregate(:median, sql_definition: <<-SQL)
19
+ # CREATE AGGREGATE median(NUMERIC)(
20
+ # SFUNC = array_append,
21
+ # STYPE = NUMERIC[],
22
+ # FINALFUNC = array_median,
23
+ # INITCOND = '{}'
24
+ # )
25
+ # SQL
26
+ #
27
+ def create_aggregate(name, options = {})
28
+ version = options.fetch(:version, 1)
29
+ sql_definition = options[:sql_definition]
30
+
31
+ if version.nil? && sql_definition.nil?
32
+ raise(
33
+ ArgumentError,
34
+ "version or sql_definition must be specified"
35
+ )
36
+ end
37
+
38
+ sql_definition = sql_definition.strip_heredoc if sql_definition
39
+ sql_definition ||= Fx::Definition.aggregate(name: name, version: version).to_sql
40
+
41
+ Fx.database.create_aggregate(sql_definition)
42
+ end
43
+
44
+ # Drop a database aggregate by name.
45
+ #
46
+ # @param name [String, Symbol] The name of the database aggregate.
47
+ # @param revert_to_version [Fixnum] Used to reverse the `drop_aggregate`
48
+ # command on `rake db:rollback`. The provided version will be passed as
49
+ # the `version` argument to {#create_aggregate}.
50
+ # @return The database response from executing the drop statement.
51
+ #
52
+ # @example Drop a aggregate, rolling back to version 2 on rollback
53
+ # drop_aggregate(:median, revert_to_version: 2)
54
+ #
55
+ def drop_aggregate(name, options = {})
56
+ Fx.database.drop_aggregate(name)
57
+ end
58
+
59
+ # Update a database aggregate.
60
+ #
61
+ # @param name [String, Symbol] The name of the database aggregate.
62
+ # @param version [Fixnum] The version number of the aggregate, used to
63
+ # find the definition file in `db/aggregates`. This defaults to `1` if
64
+ # not provided.
65
+ # @param sql_definition [String] The SQL query for the aggregate schema.
66
+ # If both `sql_definition` and `version` are provided,
67
+ # `sql_definition` takes prescedence.
68
+ # @return The database response from executing the create statement.
69
+ #
70
+ # @example Update aggregate to a given version
71
+ # update_aggregate(
72
+ # :median,
73
+ # version: 3,
74
+ # revert_to_version: 2,
75
+ # )
76
+ #
77
+ # @example Update aggregate from provided SQL string
78
+ # update_aggregate(:median, sql_definition: <<-SQL)
79
+ # CREATE AGGREGATE median(NUMERIC)(
80
+ # SFUNC = array_append,
81
+ # STYPE = NUMERIC[],
82
+ # FINALFUNC = array_median,
83
+ # INITCOND = '{}'
84
+ # )
85
+ # SQL
86
+ #
87
+ def update_aggregate(name, options = {})
88
+ version = options[:version]
89
+ sql_definition = options[:sql_definition]
90
+
91
+ if version.nil? && sql_definition.nil?
92
+ raise(
93
+ ArgumentError,
94
+ "version or sql_definition must be specified"
95
+ )
96
+ end
97
+
98
+ sql_definition = sql_definition.strip_heredoc if sql_definition
99
+ sql_definition ||= Fx::Definition.aggregate(name: name, version: version).to_sql
100
+
101
+ Fx.database.update_aggregate(name, sql_definition)
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,3 @@
1
+ module FxAggregate
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,22 @@
1
+ require "fx"
2
+
3
+ require "fx-aggregate/version"
4
+ require "fx-aggregate/adapters/postgres"
5
+ require "fx-aggregate/aggregate"
6
+ require "fx-aggregate/command_recorder"
7
+ require "fx-aggregate/definition"
8
+ require "fx-aggregate/statements"
9
+ require "fx-aggregate/schema_dumper"
10
+ require "fx-aggregate/railtie"
11
+
12
+ module FxAggregate
13
+ def self.load
14
+ Fx::Adapters::Postgres.include(FxAggregate::Adapters::Postgres)
15
+ Fx::CommandRecorder.include(FxAggregate::CommandRecorder)
16
+ Fx::Definition.include(FxAggregate::Definition)
17
+ Fx::SchemaDumper.include(FxAggregate::SchemaDumper)
18
+ Fx::Statements.include(FxAggregate::Statements)
19
+
20
+ true
21
+ end
22
+ end
@@ -0,0 +1,10 @@
1
+ Description:
2
+ Create a new database aggregate for your application. This will create a new aggregate definition file and the accompanying migration.
3
+
4
+ When --no-migration is passed, skips generating a migration.
5
+
6
+ Examples:
7
+ rails generate fx:aggregate test
8
+
9
+ create: db/aggregates/test_v01.sql
10
+ create: db/migrate/[TIMESTAMP]_create_test_aggregate.rb
@@ -0,0 +1,120 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+
4
+ module Fx
5
+ module Generators
6
+ # @api private
7
+ class AggregateGenerator < Rails::Generators::NamedBase
8
+ include Rails::Generators::Migration
9
+ source_root File.expand_path("../templates", __FILE__)
10
+
11
+ class_option :migration, type: :boolean
12
+
13
+ def create_aggregates_directory
14
+ unless aggregate_definition_path.exist?
15
+ empty_directory(aggregate_definition_path)
16
+ end
17
+ end
18
+
19
+ def create_aggregate_definition
20
+ if creating_new_aggregate?
21
+ create_file definition.path
22
+ else
23
+ copy_file previous_definition.full_path, definition.full_path
24
+ end
25
+ end
26
+
27
+ def create_migration_file
28
+ return if skip_migration_creation?
29
+ if updating_existing_aggregate?
30
+ migration_template(
31
+ "db/migrate/update_aggregate.erb",
32
+ "db/migrate/update_aggregate_#{file_name}_to_version_#{version}.rb"
33
+ )
34
+ else
35
+ migration_template(
36
+ "db/migrate/create_aggregate.erb",
37
+ "db/migrate/create_aggregate_#{file_name}.rb"
38
+ )
39
+ end
40
+ end
41
+
42
+ def self.next_migration_number(dir)
43
+ ::ActiveRecord::Generators::Base.next_migration_number(dir)
44
+ end
45
+
46
+ no_tasks do
47
+ def previous_version
48
+ @_previous_version ||= Dir.entries(aggregate_definition_path)
49
+ .map { |name| version_regex.match(name).try(:[], "version").to_i }
50
+ .max
51
+ end
52
+
53
+ def version
54
+ @_version ||= previous_version.next
55
+ end
56
+
57
+ def migration_class_name
58
+ if updating_existing_aggregate?
59
+ "UpdateAggregate#{class_name}ToVersion#{version}"
60
+ else
61
+ super
62
+ end
63
+ end
64
+
65
+ def activerecord_migration_class
66
+ if ActiveRecord::Migration.respond_to?(:current_version)
67
+ "ActiveRecord::Migration[#{ActiveRecord::Migration.current_version}]"
68
+ else
69
+ "ActiveRecord::Migration"
70
+ end
71
+ end
72
+
73
+ def formatted_name
74
+ if singular_name.include?(".")
75
+ "\"#{singular_name}\""
76
+ else
77
+ ":#{singular_name}"
78
+ end
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def aggregate_definition_path
85
+ @_aggregate_definition_path ||= Rails.root.join(*%w[db aggregates])
86
+ end
87
+
88
+ def version_regex
89
+ /\A#{file_name}_v(?<version>\d+)\.sql\z/
90
+ end
91
+
92
+ def updating_existing_aggregate?
93
+ previous_version > 0
94
+ end
95
+
96
+ def creating_new_aggregate?
97
+ previous_version == 0
98
+ end
99
+
100
+ def definition
101
+ Fx::Definition.new(name: file_name, version: version, type: "aggregate")
102
+ end
103
+
104
+ def previous_definition
105
+ Fx::Definition.new(name: file_name, version: previous_version, type: "aggregate")
106
+ end
107
+
108
+ # Skip creating migration file if:
109
+ # - migrations option is nil or false
110
+ def skip_migration_creation?
111
+ !migration
112
+ end
113
+
114
+ # True unless explicitly false
115
+ def migration
116
+ options[:migration] != false
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,5 @@
1
+ class <%= migration_class_name %> < <%= activerecord_migration_class %>
2
+ def change
3
+ create_aggregate <%= formatted_name %>
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ class <%= migration_class_name %> < <%= activerecord_migration_class %>
2
+ def change
3
+ update_aggregate <%= formatted_name %>, version: <%= version %>, revert_to_version: <%= previous_version %>
4
+ end
5
+ end
metadata ADDED
@@ -0,0 +1,93 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: fx-aggregate
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Agustin
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-01-03 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: fx
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0.9'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0.9'
27
+ description: |
28
+ Adds methods to ActiveRecord::Migration to create and manage database aggregates
29
+ functions in Rails
30
+ email:
31
+ - agustin@mailbutler.io
32
+ executables: []
33
+ extensions: []
34
+ extra_rdoc_files: []
35
+ files:
36
+ - ".github/workflows/ci.yml"
37
+ - ".gitignore"
38
+ - ".rspec"
39
+ - ".standard.yml"
40
+ - ".tool-versions"
41
+ - Gemfile
42
+ - LICENSE.txt
43
+ - README.md
44
+ - Rakefile
45
+ - bin/console
46
+ - bin/rake
47
+ - bin/rspec
48
+ - bin/setup
49
+ - bin/standardrb
50
+ - fx-aggregate.gemspec
51
+ - lib/fx-aggregate.rb
52
+ - lib/fx-aggregate/adapters/postgres.rb
53
+ - lib/fx-aggregate/adapters/postgres/aggregates.rb
54
+ - lib/fx-aggregate/aggregate.rb
55
+ - lib/fx-aggregate/command_recorder.rb
56
+ - lib/fx-aggregate/definition.rb
57
+ - lib/fx-aggregate/railtie.rb
58
+ - lib/fx-aggregate/schema_dumper.rb
59
+ - lib/fx-aggregate/statements.rb
60
+ - lib/fx-aggregate/version.rb
61
+ - lib/generators/fx/aggregate/USAGE
62
+ - lib/generators/fx/aggregate/aggregate_generator.rb
63
+ - lib/generators/fx/aggregate/templates/db/migrate/create_aggregate.erb
64
+ - lib/generators/fx/aggregate/templates/db/migrate/update_aggregate.erb
65
+ homepage: https://github.com/agustin/fx-aggregate
66
+ licenses:
67
+ - MIT
68
+ metadata:
69
+ bug_tracker_uri: https://github.com/agustin/fx-aggregate/issues
70
+ changelog_uri: https://github.com/agustin/fx-aggregate/blob/v0.1.0/CHANGELOG.md
71
+ homepage_uri: https://github.com/agustin/fx-aggregate
72
+ source_code_uri: https://github.com/agustin/fx-aggregate
73
+ post_install_message:
74
+ rdoc_options: []
75
+ require_paths:
76
+ - lib
77
+ required_ruby_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: 2.7.0
82
+ required_rubygems_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: '0'
87
+ requirements: []
88
+ rubygems_version: 3.5.9
89
+ signing_key:
90
+ specification_version: 4
91
+ summary: An extension for the fx gem to manage PostgreSQL aggregate functions in Rails
92
+ migrations.
93
+ test_files: []