├── .gitignore ├── lib ├── sequel-combine │ └── version.rb ├── sequel-combine.rb └── sequel │ └── extensions │ └── combine.rb ├── spec ├── spec_helper.rb └── sequel │ └── exstensions │ └── combine_spec.rb ├── Gemfile ├── CHANGELOG.md ├── .github └── workflows │ └── sequel-combine.yml ├── Rakefile ├── sequel-combine.gemspec ├── LICENSE ├── Gemfile.lock └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /sequel-combine-*.gem 3 | -------------------------------------------------------------------------------- /lib/sequel-combine/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SequelCombine 4 | VERSION = "1.0.2" 5 | end 6 | -------------------------------------------------------------------------------- /lib/sequel-combine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "sequel/extensions/combine" 4 | require "sequel-combine/version" 5 | 6 | module SequelCombine 7 | end 8 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | require "simplecov" 3 | SimpleCov.start 4 | 5 | require "sequel-combine" 6 | require "pry" 7 | 8 | Dir['./spec/support/*'].each(&method(:require)) 9 | 10 | RSpec.configure do |config| 11 | config.mock_framework = :rspec 12 | end 13 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in sequel-combine.gemspec 4 | gemspec 5 | 6 | group :test do 7 | gem "simplecov", ">= 0.13.0", require: false 8 | gem "rspec", ">= 3.7.0" 9 | gem "rake", ">= 13.0.6" 10 | gem "pry", ">= 0.10.4" 11 | end 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### 1.0.2 (2018.02.01) 2 | 3 | * Add raising error when #combine is used with unsupported adapter(wmaciejak) #6 4 | 5 | ### 1.0.1 (2018.01.31) 6 | 7 | * General refactor, provide more meaningful naming (Zip753) #5 8 | * Set minimal required version of Sequel to 3.48.0 (wmaciejak) 9 | 10 | ### 1.0.0 (2017.06.01) 11 | 12 | * Initial commit 13 | -------------------------------------------------------------------------------- /.github/workflows/sequel-combine.yml: -------------------------------------------------------------------------------- 1 | name: sequel-combine 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | name: Ruby ${{ matrix.ruby }} 14 | strategy: 15 | matrix: 16 | ruby: 17 | - '3.1.1' 18 | - '3.0.4' 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Set up Ruby 23 | uses: ruby/setup-ruby@v1 24 | with: 25 | ruby-version: ${{ matrix.ruby }} 26 | bundler-cache: true 27 | - name: Run tests 28 | run: bundle exec rake 29 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "rubygems" 2 | require "rake" 3 | require "rake/testtask" 4 | require "rake/clean" 5 | require "bundler" 6 | require 'rspec/core/rake_task' 7 | 8 | Bundler.require(:default, :test) 9 | 10 | RSpec::Core::RakeTask.new(:spec) 11 | 12 | task default: [:spec] 13 | 14 | NAME = "sequel-combine" 15 | VERSION = lambda do 16 | require File.expand_path("../lib/sequel-combine/version", __FILE__) 17 | SequelCombine::VERSION 18 | end 19 | 20 | # Gem packaging 21 | desc "Build the gem" 22 | task package: [:clean] do 23 | sh %{#{FileUtils::RUBY} -S gem build sequel-combine.gemspec} 24 | end 25 | 26 | desc "Publish the gem to rubygems.org" 27 | task release: [:package] do 28 | sh %{#{FileUtils::RUBY} -S gem push ./#{NAME}-#{VERSION.call}.gem} 29 | end 30 | -------------------------------------------------------------------------------- /sequel-combine.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | begin 4 | require "./lib/sequel-combine/version" 5 | rescue LoadError 6 | module SequelCombine; VERSION = "0"; end 7 | end 8 | 9 | Gem::Specification.new do |spec| 10 | spec.name = "sequel-combine" 11 | spec.version = SequelCombine::VERSION 12 | spec.authors = ["Wojciech Maciejak"] 13 | spec.email = "wojciech@maciejak.eu" 14 | spec.summary = "The Sequel extension which allow you to select object with many nested descendants" 15 | spec.description = "The Sequel extension which allow you to select object with many nested descendants" 16 | spec.homepage = "https://github.com/wmaciejak/sequel-combine" 17 | spec.license = "MIT" 18 | 19 | spec.require_paths = ["lib"] 20 | spec.files = Dir.glob("{bin,lib}/**/*") + \ 21 | %w(LICENSE README.md CHANGELOG.md) 22 | 23 | spec.add_runtime_dependency "sequel", ">= 3.48.0" 24 | end 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Wojciech Maciejak 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | sequel-combine (1.0.2) 5 | sequel (>= 3.48.0) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | coderay (1.1.3) 11 | diff-lcs (1.5.0) 12 | docile (1.4.0) 13 | method_source (1.0.0) 14 | pry (0.14.1) 15 | coderay (~> 1.1) 16 | method_source (~> 1.0) 17 | rake (13.0.6) 18 | rspec (3.12.0) 19 | rspec-core (~> 3.12.0) 20 | rspec-expectations (~> 3.12.0) 21 | rspec-mocks (~> 3.12.0) 22 | rspec-core (3.12.0) 23 | rspec-support (~> 3.12.0) 24 | rspec-expectations (3.12.0) 25 | diff-lcs (>= 1.2.0, < 2.0) 26 | rspec-support (~> 3.12.0) 27 | rspec-mocks (3.12.0) 28 | diff-lcs (>= 1.2.0, < 2.0) 29 | rspec-support (~> 3.12.0) 30 | rspec-support (3.12.0) 31 | sequel (5.63.0) 32 | simplecov (0.21.2) 33 | docile (~> 1.1) 34 | simplecov-html (~> 0.11) 35 | simplecov_json_formatter (~> 0.1) 36 | simplecov-html (0.12.3) 37 | simplecov_json_formatter (0.1.4) 38 | 39 | PLATFORMS 40 | ruby 41 | 42 | DEPENDENCIES 43 | pry (>= 0.10.4) 44 | rake (>= 13.0.6) 45 | rspec (>= 3.7.0) 46 | sequel-combine! 47 | simplecov (>= 0.13.0) 48 | 49 | BUNDLED WITH 50 | 1.16.1 51 | -------------------------------------------------------------------------------- /lib/sequel/extensions/combine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "sequel" 4 | 5 | module Sequel 6 | module Extensions 7 | module Combine 8 | ALLOWED_TYPES = %i[many one].freeze 9 | AGGREGATED_ROW_ALIAS = :ROW 10 | 11 | def combine(options) 12 | raise Sequel::DatabaseError, "Invalid adapter. PostgreSQL driver not found." unless Sequel::Postgres::USES_PG 13 | 14 | column_mappings = options.map { |type, relations| combine_columns(type, relations) } 15 | column_mapping = column_mappings.reduce({}, :merge) 16 | select_append do 17 | column_mapping.map { |column_name, query| query.as(column_name.to_sym) } 18 | end 19 | end 20 | 21 | private 22 | 23 | def combine_columns(type, relations) 24 | return {} unless ALLOWED_TYPES.include?(type) 25 | relations.each_with_object({}) do |(relation_name, (dataset, key_mappings)), columns| 26 | base_query = dataset.where(key_mappings).from_self(alias: AGGREGATED_ROW_ALIAS) 27 | case type 28 | when :many 29 | columns[relation_name] = aggregate_many(base_query) 30 | when :one 31 | columns[relation_name] = aggregate_one(base_query) 32 | end 33 | end 34 | end 35 | 36 | def aggregate_many(dataset) 37 | dataset.select { COALESCE(array_to_json(array_agg(row_to_json(AGGREGATED_ROW_ALIAS))), "[]") } 38 | end 39 | 40 | def aggregate_one(dataset) 41 | dataset.select { row_to_json(AGGREGATED_ROW_ALIAS) } 42 | end 43 | end 44 | end 45 | 46 | Dataset.register_extension(:combine, Extensions::Combine) 47 | end 48 | -------------------------------------------------------------------------------- /spec/sequel/exstensions/combine_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../spec_helper" 2 | 3 | Sequel.extension :combine 4 | 5 | describe Sequel::Extensions::Combine do 6 | let(:db) { Sequel.mock } 7 | 8 | before { Sequel::Dataset.send(:include, Sequel::Extensions::Combine) } 9 | 10 | context "when adapter is Postgres" do 11 | before { stub_const("Sequel::Postgres::USES_PG", true) } 12 | 13 | describe "one" do 14 | subject do 15 | db[:users].combine( 16 | one: { group: [db[:groups], group_id: :id]} 17 | ) 18 | end 19 | 20 | it "generates combining sql" do 21 | expect(subject.sql).to eql "SELECT *, (SELECT row_to_json(ROW) FROM (SELECT * FROM groups WHERE (group_id = id)) AS ROW) AS group FROM users" 22 | end 23 | 24 | describe "with multiple combines" do 25 | subject do 26 | db[:users].combine( 27 | one: { 28 | group: [db[:groups], group_id: :id], 29 | company: [db[:companies], company_id: :id], 30 | }, 31 | ) 32 | end 33 | 34 | it "generates proper query" do 35 | expect(subject.sql).to eql "SELECT *, (SELECT row_to_json(ROW) FROM (SELECT * FROM groups WHERE (group_id = id)) AS ROW) AS group, (SELECT row_to_json(ROW) FROM (SELECT * FROM companies WHERE (company_id = id)) AS ROW) AS company FROM users" 36 | end 37 | end 38 | end 39 | 40 | describe "many" do 41 | subject do 42 | db[:groups].combine( 43 | many: { users: [db[:users], id: :group_id] } 44 | ) 45 | end 46 | it "generates combining query" do 47 | expect(subject.sql).to eql "SELECT *, (SELECT COALESCE(array_to_json(array_agg(row_to_json(ROW))), '[]') FROM (SELECT * FROM users WHERE (id = group_id)) AS ROW) AS users FROM groups" 48 | end 49 | 50 | describe "with multiple combines" do 51 | subject do 52 | db[:users].combine( 53 | many: { 54 | tasks: [db[:tasks], id: :user_id], 55 | roles: [db[:roles], id: :user_id], 56 | }, 57 | ) 58 | end 59 | 60 | it "generates proper query" do 61 | expect(subject.sql).to eql "SELECT *, (SELECT COALESCE(array_to_json(array_agg(row_to_json(ROW))), '[]') FROM (SELECT * FROM tasks WHERE (id = user_id)) AS ROW) AS tasks, (SELECT COALESCE(array_to_json(array_agg(row_to_json(ROW))), '[]') FROM (SELECT * FROM roles WHERE (id = user_id)) AS ROW) AS roles FROM users" 62 | end 63 | end 64 | 65 | describe "with many and one in the same combine" do 66 | subject do 67 | db[:users].combine( 68 | one: { company: [db[:companies], company_id: :id]}, 69 | many: { roles: [db[:roles], id: :user_id]}, 70 | ) 71 | end 72 | 73 | it "generates proper query" do 74 | expect(subject.sql).to eql "SELECT *, (SELECT row_to_json(ROW) FROM (SELECT * FROM companies WHERE (company_id = id)) AS ROW) AS company, (SELECT COALESCE(array_to_json(array_agg(row_to_json(ROW))), '[]') FROM (SELECT * FROM roles WHERE (id = user_id)) AS ROW) AS roles FROM users" 75 | end 76 | end 77 | 78 | describe "with nested combines" do 79 | subject do 80 | db[:projects].combine( 81 | many: { 82 | users: [ 83 | db[:users].combine(one: { city: [db[:cities], city_id: :id] }), 84 | id: :project_id, 85 | ], 86 | }, 87 | ) 88 | end 89 | 90 | it "generates the proper query" do 91 | expect(subject.sql).to eql "SELECT *, (SELECT COALESCE(array_to_json(array_agg(row_to_json(ROW))), '[]') FROM (SELECT *, (SELECT row_to_json(ROW) FROM (SELECT * FROM cities WHERE (city_id = id)) AS ROW) AS city FROM users WHERE (id = project_id)) AS ROW) AS users FROM projects" 92 | end 93 | end 94 | end 95 | end 96 | 97 | context "when adapter is different" do 98 | subject do 99 | db[:users].combine( 100 | one: { group: [db[:groups], group_id: :id]} 101 | ) 102 | end 103 | 104 | before { stub_const("Sequel::Postgres::USES_PG", false) } 105 | 106 | it "raise error" do 107 | expect { subject }.to raise_error(Sequel::DatabaseError, "Invalid adapter. PostgreSQL driver not found.") 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SequelCombine 2 | [![CircleCI](https://circleci.com/gh/wmaciejak/sequel-combine/tree/master.svg?style=shield)](https://circleci.com/gh/wmaciejak/sequel-combine/tree/master) [![Gem Version](https://badge.fury.io/rb/sequel-combine.svg)](https://badge.fury.io/rb/sequel-combine) [![Code Climate](https://codeclimate.com/github/wmaciejak/sequel-combine/badges/gpa.svg)](https://codeclimate.com/github/wmaciejak/sequel-combine) 3 | 4 | This extension adds the `Sequel::Dataset#combine` method, which returns object from database composed with childrens, parents or any object where exists any relationship. Now it is possible in one query! 5 | 6 | ## Installation 7 | 8 | Add this line to your application's Gemfile: 9 | 10 | gem 'sequel-combine' 11 | 12 | And then execute: 13 | 14 | $ bundle 15 | 16 | Or install it yourself as: 17 | 18 | $ gem install sequel-combine 19 | 20 | The plugin needs to be initialized by the Sequel extension interface. The simplest way to configure plugin globally is adding this line to the initializer: 21 | 22 | ```ruby 23 | Sequel.extension :combine 24 | ``` 25 | or 26 | ```ruby 27 | Sequel::Database.extension :combine 28 | ``` 29 | 30 | But anyway I recommend reading more about [Sequel extensions system](https://github.com/jeremyevans/sequel/blob/master/doc/extensions.rdoc#sequel-extensions). 31 | 32 | ## Usage 33 | 34 | Remember! 35 | **Combined dataset** it's still a dataset so methods can be chained! 36 | 37 | Combining works only with **Postgres** adapter 38 | 39 | ```ruby 40 | dataset_first 41 | .combine(many: { attribute: [dataset_second, p_key_dataset_second: :f_key_dataset_first] }) 42 | .to_a 43 | ``` 44 | * `dataset_first`, `dataset_second` -> datasets which needs to be combined 45 | * `many` -> method used in combining. If relation is one-to-one recommended method is `one`(which return object or nil), in any other case I recommend to using method `many`(which return array of objects or empty array). 46 | * `attribute` -> attribute which will be an result of combine 47 | * `p_key_dataset_second: :f_key_dataset_first` -> relationship between tables 48 | 49 | ## Usage examples 50 | 51 | ### Combining many 52 | ```ruby 53 | DB[:groups].columns 54 | #=> [:id, :name] 55 | DB[:users].columns 56 | #=> [:id, :username, :email, :group_id] 57 | DB[:groups].combine(many: { users: [DB[:users], id: :group_id] }).to_a 58 | #=> [{:id=>1, 59 | # :name=>"Football", 60 | # :users=> 61 | # [{ 62 | # :id=> 1, 63 | # :username=> "leonardo", 64 | # :email=> "leonardo@fakemail.com", 65 | # :group_id=> 1, 66 | # }, 67 | # { 68 | # :id=> 2, 69 | # :username=> "leonardo2", 70 | # :email=> "leonardo2@fakemail.com", 71 | # :group_id=> 1, 72 | # }, 73 | # ] 74 | # }] 75 | ``` 76 | 77 | ### Combining one 78 | ```ruby 79 | DB[:groups].columns 80 | #=> [:id, :name] 81 | DB[:users].columns 82 | #=> [:id, :username, :email, :group_id] 83 | DB[:users].combine(one: { group: [DB[:groups], group_id: :id] }).to_a 84 | #=> [ 85 | # { 86 | # :id=> 1, 87 | # :username=> "leonardo", 88 | # :email=> "leonardo@fakemail.com", 89 | # :group=> { :id=> 1, :name=> "Football" }, 90 | # }, 91 | # { 92 | # :id=> 2, 93 | # :username=> "leonardo2", 94 | # :email=> "leonardo2@fakemail.com" 95 | # :group=> { :id=> 1, :name=> "Football" }, 96 | # } 97 | # ] 98 | ``` 99 | 100 | ### Combining one and many 101 | Also combining can be mixed and multiplied: 102 | ```ruby 103 | DB[:users].combine( 104 | one: { 105 | group: [DB[:groups], group_id: :id], 106 | company: [DB[:companies], company_id: :id], 107 | }, 108 | many: { 109 | tasks: [DB[:tasks], id: :user_id], 110 | roles: [DB[:roles], id: :user_id], 111 | }, 112 | ).to_a 113 | ``` 114 | 115 | ### Combining inside combine 116 | It can go deeper and deeper... 117 | ```ruby 118 | DB[:projects].combine( 119 | many: { 120 | users: [ 121 | DB[:users].combine(one: { city: [DB[:cities], city_id: :id] }), 122 | id: :project_id, 123 | ] 124 | } 125 | ).to_a 126 | ``` 127 | 128 | ### Self-combining and combining not by foreign_key 129 | ```ruby 130 | DB[:geolocations].combine(one: { parent: [DB[:geolocations], path: :parent_path] }).to_a 131 | ``` 132 | 133 | ### Combining more complex datasets 134 | Datasets used in combine might be of course chained with other `Sequel::Dataset` methods. 135 | ```ruby 136 | DB[:groups] 137 | .where(id: 1) 138 | .select(:id, :name) 139 | .order(:name) 140 | .combine( 141 | many: { 142 | users: [ 143 | DB[:users] 144 | .join(:groups) 145 | .select(:id, :username, :group_id, Sequel.qualify("groups", "name")), 146 | id: :group_id 147 | ] 148 | } 149 | ).to_a 150 | ``` 151 | 152 | ## Benchmark 153 | Tested on 2000 mocked records with children's or parents: 154 | 155 | 4 level of combine - 2,39 sec. 156 | 157 | 3 level - 1,12 sec. 158 | 159 | 2 level - 0,55 sec. 160 | 161 | 1 level - 0,22 sec. 162 | 163 | self-combining (the situation from geolocation, tested on real geolocations database, around 23000 records) - 4 sec 164 | 165 | ## Use cases 166 | 167 | * API directly in Postgresql 168 | * Exporting tree of objects 169 | * **deep clone** in Postgresql - very extreme case, but it's probably the most performance effective way of doing this operation 170 | * more, more, more... 171 | 172 | ## Contributing 173 | 174 | 1. Fork it 175 | 2. Create your feature branch (`git checkout -b my-new-feature`) 176 | 3. Commit your changes (`git commit -am 'Add some feature'`) 177 | 4. Push to the branch (`git push origin my-new-feature`) 178 | 5. Create new Pull Request 179 | --------------------------------------------------------------------------------