From 1288c8fc5716008958b2d7924343da61bf309501 Mon Sep 17 00:00:00 2001 From: Stef Schenkelaars Date: Tue, 3 Nov 2020 21:13:18 +0100 Subject: [PATCH] Initial commit --- .github/workflows/ci.yml | 33 +++++ .github/workflows/publish.yml | 54 +++++++ .gitignore | 17 +++ .reviewdog.yml | 3 + .rspec | 3 + .rubocop.yml | 32 ++++ .tool-versions | 1 + Gemfile | 8 + Gemfile.lock | 140 ++++++++++++++++++ LICENSE | 21 +++ README.md | 116 +++++++++++++++ bin/setup | 8 + lib/sql_attributes.rb | 54 +++++++ lib/sql_attributes/version.rb | 5 + .../internal/app/models/application_record.rb | 5 + spec/internal/app/models/invoice.rb | 22 +++ spec/internal/app/models/invoice_line.rb | 5 + spec/internal/app/models/payment.rb | 5 + spec/internal/config/database.yml | 3 + spec/internal/config/routes.rb | 4 + spec/internal/db/schema.rb | 20 +++ spec/internal/public/favicon.ico | 0 spec/spec_helper.rb | 27 ++++ spec/sql_attributes_spec.rb | 111 ++++++++++++++ spec/support/database_cleaner.rb | 9 ++ sql_attributes.gemspec | 37 +++++ 26 files changed, 743 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .gitignore create mode 100644 .reviewdog.yml create mode 100644 .rspec create mode 100644 .rubocop.yml create mode 100644 .tool-versions create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 LICENSE create mode 100644 README.md create mode 100755 bin/setup create mode 100644 lib/sql_attributes.rb create mode 100644 lib/sql_attributes/version.rb create mode 100644 spec/internal/app/models/application_record.rb create mode 100644 spec/internal/app/models/invoice.rb create mode 100644 spec/internal/app/models/invoice_line.rb create mode 100644 spec/internal/app/models/payment.rb create mode 100644 spec/internal/config/database.yml create mode 100644 spec/internal/config/routes.rb create mode 100644 spec/internal/db/schema.rb create mode 100644 spec/internal/public/favicon.ico create mode 100644 spec/spec_helper.rb create mode 100644 spec/sql_attributes_spec.rb create mode 100644 spec/support/database_cleaner.rb create mode 100644 sql_attributes.gemspec diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..125d9ab --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI +on: [push] +jobs: + reviewdog: + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v2 + + - uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Setup reviewdog + uses: reviewdog/action-setup@v1 + + - name: Run reviewdog + run: reviewdog -reporter=github-check + env: + REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + rspec: + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v2 + + - uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Run RSpec + run: bundle exec rspec --format RSpec::Github::Formatter diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..1ac2ba1 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,54 @@ +name: Publish +on: + release: + types: [published] + +jobs: + publish-github: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v2 + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + + - name: Setup GitHub Package Registry credentials + run: | + mkdir -p $HOME/.gem + touch $HOME/.gem/credentials + chmod 0600 $HOME/.gem/credentials + printf -- "---\n:github: Bearer ${{ secrets.GITHUB_TOKEN }}\n" > $HOME/.gem/credentials + + - name: Replace version by tag value + run: sed -i "s/'[0-9]\.[0-9]\..*'/'${GITHUB_REF##*/}'/" lib/sql_attributes/version.rb + + - name: Build and Publish gem to GitHub Package Registry + run: | + gem build --verbose *.gemspec + gem push --verbose --key github --host https://rubygems.pkg.github.com/${OWNER} *.gem + env: + OWNER: Drieam + + publish-rubygems: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v2 + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + + - name: Replace version by tag value + run: sed -i "s/'[0-9]\.[0-9]\..*'/'${GITHUB_REF##*/}'/" lib/sql_attributes/version.rb + + - name: Publish to RubyGems + run: | + mkdir -p $HOME/.gem + touch $HOME/.gem/credentials + chmod 0600 $HOME/.gem/credentials + printf -- "---\n:rubygems_api_key: ${GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials + gem build --verbose *.gemspec + gem push --verbose *.gem + env: + GEM_HOST_API_KEY: ${{secrets.RUBYGEMS_AUTH_TOKEN}} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6f8156 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +.bundle/ +log/*.log +pkg/ +spec/internal/db/*.sqlite3 +spec/internal/log/*.log +spec/internal/storage/ + +.yardoc +_yardoc/ +coverage/ +doc/ +spec/reports/ +tmp/ + +# rspec failure tracking +.rspec_status + diff --git a/.reviewdog.yml b/.reviewdog.yml new file mode 100644 index 0000000..79f4743 --- /dev/null +++ b/.reviewdog.yml @@ -0,0 +1,3 @@ +runner: + rubocop: + cmd: bundle exec rubocop diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..34c5164 --- /dev/null +++ b/.rspec @@ -0,0 +1,3 @@ +--format documentation +--color +--require spec_helper diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..8217742 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,32 @@ +require: + - rubocop-performance # Performance optimization analysis + - rubocop-rails # Rails-specific analysis + +AllCops: + TargetRubyVersion: 2.5 + NewCops: enable + Exclude: + - 'tmp/**/*' + - 'vendor/**/*' + +# We have a readme instead +Style/Documentation: + Enabled: false + +# We like our specs to use the {} syntax +Lint/AmbiguousBlockAssociation: + Exclude: + - 'spec/**/*.rb' + +# Well, lower would probably be better but it doesn't improve the readability of this gem +Metrics/MethodLength: + Max: 15 + +Metrics/BlockLength: + Exclude: + - 'spec/**/*.rb' # Specs just have large blocks + - '*.gemspec' # Is just one block + +# This is intentional and covered by the specs +Rails/SquishedSQLHeredocs: + Enabled: false diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..9eb38ed --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +ruby 2.7.2 diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..ce441aa --- /dev/null +++ b/Gemfile @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +# Declare your gem's dependencies in sql_attributes.gemspec. +# Bundler will treat runtime dependencies like base dependencies, and +# development dependencies will be added by default to the :development group. +gemspec diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..9467b88 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,140 @@ +PATH + remote: . + specs: + sql_attributes (0.1.0.develop) + activerecord (>= 6.0.3) + +GEM + remote: https://rubygems.org/ + specs: + actionpack (6.0.3.4) + actionview (= 6.0.3.4) + activesupport (= 6.0.3.4) + rack (~> 2.0, >= 2.0.8) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.0) + rails-html-sanitizer (~> 1.0, >= 1.2.0) + actionview (6.0.3.4) + activesupport (= 6.0.3.4) + builder (~> 3.1) + erubi (~> 1.4) + rails-dom-testing (~> 2.0) + rails-html-sanitizer (~> 1.1, >= 1.2.0) + activemodel (6.0.3.4) + activesupport (= 6.0.3.4) + activerecord (6.0.3.4) + activemodel (= 6.0.3.4) + activesupport (= 6.0.3.4) + activesupport (6.0.3.4) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (>= 0.7, < 2) + minitest (~> 5.1) + tzinfo (~> 1.1) + zeitwerk (~> 2.2, >= 2.2.2) + ast (2.4.1) + builder (3.2.4) + combustion (1.3.1) + activesupport (>= 3.0.0) + railties (>= 3.0.0) + thor (>= 0.14.6) + concurrent-ruby (1.1.7) + crass (1.0.6) + database_cleaner (1.8.5) + database_cleaner-active_record (1.8.0) + activerecord + database_cleaner (~> 1.8.0) + diff-lcs (1.4.4) + erubi (1.9.0) + i18n (1.8.5) + concurrent-ruby (~> 1.0) + loofah (2.7.0) + crass (~> 1.0.2) + nokogiri (>= 1.5.9) + method_source (1.0.0) + mini_portile2 (2.4.0) + minitest (5.14.2) + nokogiri (1.10.10) + mini_portile2 (~> 2.4.0) + parallel (1.19.2) + parser (2.7.2.0) + ast (~> 2.4.1) + rack (2.2.3) + rack-test (1.1.0) + rack (>= 1.0, < 3) + rails-dom-testing (2.0.3) + activesupport (>= 4.2.0) + nokogiri (>= 1.6) + rails-html-sanitizer (1.3.0) + loofah (~> 2.3) + railties (6.0.3.4) + actionpack (= 6.0.3.4) + activesupport (= 6.0.3.4) + method_source + rake (>= 0.8.7) + thor (>= 0.20.3, < 2.0) + rainbow (3.0.0) + rake (13.0.1) + regexp_parser (1.8.2) + rexml (3.2.4) + rspec-core (3.9.3) + rspec-support (~> 3.9.3) + rspec-expectations (3.9.3) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.9.0) + rspec-github (2.3.1) + rspec-core (~> 3.0) + rspec-mocks (3.9.1) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.9.0) + rspec-rails (4.0.1) + actionpack (>= 4.2) + activesupport (>= 4.2) + railties (>= 4.2) + rspec-core (~> 3.9) + rspec-expectations (~> 3.9) + rspec-mocks (~> 3.9) + rspec-support (~> 3.9) + rspec-support (3.9.4) + rubocop (1.0.0) + parallel (~> 1.10) + parser (>= 2.7.1.5) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 1.8) + rexml + rubocop-ast (>= 0.6.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 1.4.0, < 2.0) + rubocop-ast (1.1.0) + parser (>= 2.7.1.5) + rubocop-performance (1.8.1) + rubocop (>= 0.87.0) + rubocop-ast (>= 0.4.0) + rubocop-rails (2.8.1) + activesupport (>= 4.2.0) + rack (>= 1.1) + rubocop (>= 0.87.0) + ruby-progressbar (1.10.1) + sqlite3 (1.4.2) + thor (1.0.1) + thread_safe (0.3.6) + tzinfo (1.2.7) + thread_safe (~> 0.1) + unicode-display_width (1.7.0) + zeitwerk (2.4.0) + +PLATFORMS + ruby + +DEPENDENCIES + combustion + database_cleaner-active_record + rspec-github + rspec-rails + rubocop + rubocop-performance + rubocop-rails + sql_attributes! + sqlite3 + +BUNDLED WITH + 2.1.4 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..75c5f12 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2020 Stef Schenkelaars + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..14d64c6 --- /dev/null +++ b/README.md @@ -0,0 +1,116 @@ +# SQL Attributes +This gem is here to help you with fetching record specific data from the database using subqueries. You could also use this gem as an alternative for counter caches but of course you should think about this carefully since it can be read heavy. + +## Installation +Add this line to your application's Gemfile: + +```ruby +gem 'sql_attributes' +``` + +The of course run `bundle install`. + +## Usage +It starts by defining an SQL attribute on a class: + +```ruby +class Author < ApplicationRecord + sql_attribute :books_count, <<~SQL + SELECT COUNT(*) + FROM books + WHERE books.author_id = books.id + SQL + + sql_attribute :total_pages, <<~SQL + SELECT SUM(books.pages) + FROM books + WHERE books.author_id = books.id + SQL + + # Note that this aggregation method `GROUP_CONCAT` is different for other databases like Postgres + sql_attribute :publisher_names, <<~SQL + SELECT DISTINCT GROUP_CONCAT(publishers.name, ' - ') + FROM publishers + INNER JOIN books ON books.publisher_id = publishers.id + WHERE books.author_id = authors.id + GROUP BY books.author_id + SQL +end +``` + +Before you can access the attribute, you have to include it to the SQL query. An error will be raised if you dont: +```ruby +authors = Author.all +authors.map(&:books_count) # => raises SqlAttributes::NotLoaded +authors.map(&:total_pages) # => raises SqlAttributes::NotLoaded +``` + +You can tell ActiveRecord / Arel to include a specific attribute by using the `with_` helpers: +```ruby +authors = Author.with_books_count.all +authors.map(&:books_count) # => [1, 2] +authors.map(&:total_pages) # => raises SqlAttributes::NotLoaded +``` + +These methods are chainable and can be combined with normal scopes: +```ruby +authors = Author.where(publisher_id: 42).with_books_count.with_total_pages.all +authors.map(&:books_count) # => [1, 2] +authors.map(&:total_pages) # => [300, 500] +``` + +You can also load the attributes with the `with_sql_attributes` helper: +```ruby +authors = Author.with_sql_attributes(:books_count, :publisher_names) +authors.map(&:books_count) # => [1, 2] +authors.map(&:total_pages) # => raises SqlAttributes::NotLoaded +``` + +If you don't pass any argument, it will load all SQL attributes: +```ruby +authors = Author.with_sql_attributes +authors.map(&:books_count) # => [1, 2] +authors.map(&:total_pages) # => [300, 500] +``` + +## Releasing new version +Publishing a new version is handled by the publish workflow. This workflow publishes a GitHub release to rubygems and GitHub package registry with the version defined in the release. + +## Contributing +Bug reports and pull requests are welcome on GitHub at https://github.com/Drieam/sql_attributes. + +## License +The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). + +## Credits +A big inspration is [this blog](https://medium.com/@eric.programmer/the-sql-alternative-to-counter-caches-59e2098b7d7) about `The SQL Alternative To Counter Caches`. + +The most important example from this blog: + +```ruby +class Author < ApplicationRecord + scope :with_counts, -> { + select <<~SQL + authors.*, + ( + SELECT COUNT(books.id) FROM books + WHERE author_id = authors.id + ) AS books_count + SQL + } +end +``` + +With this gem, this can be rewritten to + +```ruby +class Author < ApplicationRecord + sql_attribute :books_count, <<~SQL + SELECT COUNT(books.id) + FROM books + WHERE author_id = authors.id + SQL +end +``` + +Also some ideas where 'stolen' from [this blog](https://engineering.culturehq.com/posts/2019-01-18-dynamic-activerecord-columns) about `Dynamic ActiveRecord columns`. diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..dce67d8 --- /dev/null +++ b/bin/setup @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' +set -vx + +bundle install + +# Do any other automated setup that you need to do here diff --git a/lib/sql_attributes.rb b/lib/sql_attributes.rb new file mode 100644 index 0000000..12c32ac --- /dev/null +++ b/lib/sql_attributes.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module SqlAttributes + class NotLoaded < StandardError; end + + class NotDefined < StandardError; end + + def sql_attributes + @sql_attributes ||= HashWithIndifferentAccess.new + end + + def with_sql_attributes(*attributes) + requested_attributes = + if attributes.length.positive? + Array.wrap(attributes).flatten.reject(&:nil?) + else + sql_attributes.keys + end + + requested_attributes.inject(all) do |scope, name| + unless sql_attributes.key?(name) + raise NotDefined, "You want to load dynamic SQL attribute `#{name}` but it is not defined." + end + + scope.public_send("with_#{name}") + end + end + + def sql_attribute(name, subquery) + sql_attributes[name] = subquery.squish + + scope "with_#{name}".to_sym, lambda { + select( + arel.projections, + "(#{subquery.squish}) as #{name}" + ) + } + + define_method(name) do + return read_attribute(name) if has_attribute?(name) + + raise NotLoaded, <<~MESSAGE + Dynamic SQL attribute `#{name}` not loaded from the database. + + Use the `with_sql_attributes` scope to load the attribute. + + MESSAGE + end + end +end + +ActiveSupport.on_load(:active_record) do + extend SqlAttributes +end diff --git a/lib/sql_attributes/version.rb b/lib/sql_attributes/version.rb new file mode 100644 index 0000000..e1b05af --- /dev/null +++ b/lib/sql_attributes/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module SqlAttributes + VERSION = '0.1.0.develop' +end diff --git a/spec/internal/app/models/application_record.rb b/spec/internal/app/models/application_record.rb new file mode 100644 index 0000000..71fbba5 --- /dev/null +++ b/spec/internal/app/models/application_record.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ApplicationRecord < ActiveRecord::Base + self.abstract_class = true +end diff --git a/spec/internal/app/models/invoice.rb b/spec/internal/app/models/invoice.rb new file mode 100644 index 0000000..2149c6d --- /dev/null +++ b/spec/internal/app/models/invoice.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class Invoice < ApplicationRecord + has_many :invoice_lines + has_many :payments + + sql_attribute :title, <<~SQL + number || ' | ' || reference + SQL + + sql_attribute :total_amount, <<~SQL + SELECT SUM(units * unit_price) + FROM invoice_lines + WHERE invoice_lines.invoice_id = invoices.id + SQL + + sql_attribute 'total_paid', <<~SQL + SELECT SUM(amount) + FROM payments + WHERE payments.invoice_id = invoices.id + SQL +end diff --git a/spec/internal/app/models/invoice_line.rb b/spec/internal/app/models/invoice_line.rb new file mode 100644 index 0000000..c300868 --- /dev/null +++ b/spec/internal/app/models/invoice_line.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class InvoiceLine < ApplicationRecord + belongs_to :invoice +end diff --git a/spec/internal/app/models/payment.rb b/spec/internal/app/models/payment.rb new file mode 100644 index 0000000..7631198 --- /dev/null +++ b/spec/internal/app/models/payment.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class Payment < ApplicationRecord + belongs_to :invoice +end diff --git a/spec/internal/config/database.yml b/spec/internal/config/database.yml new file mode 100644 index 0000000..e3be62b --- /dev/null +++ b/spec/internal/config/database.yml @@ -0,0 +1,3 @@ +test: + adapter: sqlite3 + database: db/sql_attributes_test.sqlite3 diff --git a/spec/internal/config/routes.rb b/spec/internal/config/routes.rb new file mode 100644 index 0000000..edf04d2 --- /dev/null +++ b/spec/internal/config/routes.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +Rails.application.routes.draw do +end diff --git a/spec/internal/db/schema.rb b/spec/internal/db/schema.rb new file mode 100644 index 0000000..0774644 --- /dev/null +++ b/spec/internal/db/schema.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +ActiveRecord::Schema.define do + create_table :invoices do |t| + t.string :number + t.string :reference + end + + create_table :invoice_lines do |t| + t.belongs_to :invoice + t.string :name + t.integer :units + t.decimal :unit_price + end + + create_table :payments do |t| + t.belongs_to :invoice + t.decimal :amount + end +end diff --git a/spec/internal/public/favicon.ico b/spec/internal/public/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..fe2e941 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +ENV['RAILS_ENV'] ||= 'test' + +require 'bundler/setup' +require 'combustion' + +Bundler.require(*Rails.groups) + +# Load the parts from rails we need with combustion +Combustion.initialize! :active_record + +require 'rspec/rails' + +# Load support files +Dir[File.join(File.dirname(__FILE__), 'support', '**', '*.rb')].sort.each { |f| require f } + +require 'sql_attributes' + +RSpec.configure do |config| + # Disable RSpec exposing methods globally on `Module` and `main` + config.disable_monkey_patching! + + config.expect_with :rspec do |c| + c.syntax = :expect + end +end diff --git a/spec/sql_attributes_spec.rb b/spec/sql_attributes_spec.rb new file mode 100644 index 0000000..031cd25 --- /dev/null +++ b/spec/sql_attributes_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +RSpec.describe SqlAttributes do + let!(:invoice) { Invoice.create!(number: '2020-0042', reference: 'My cool new course') } + let!(:invoice_lines) do + [ + invoice.invoice_lines.create!(name: 'Introduction', units: 1, unit_price: 52.25), + invoice.invoice_lines.create!(name: 'Books', units: 3, unit_price: 1.99) + ] + end + let!(:payments) do + [ + invoice.payments.create!(amount: 10.25), + invoice.payments.create!(amount: 10) + ] + end + + it 'has a version number' do + expect(described_class::VERSION).not_to be nil + end + + describe '#sql_attributes' do + it 'returns all defined SQL attributes with their squished subquery' do + expect(Invoice.sql_attributes.symbolize_keys).to eq( + title: "number || ' | ' || reference", + total_amount: 'SELECT SUM(units * unit_price) FROM invoice_lines WHERE invoice_lines.invoice_id = invoices.id', + total_paid: 'SELECT SUM(amount) FROM payments WHERE payments.invoice_id = invoices.id' + ) + end + + it 'can also be fetched by string' do + expect(Invoice.sql_attributes).to have_key(:title) + expect(Invoice.sql_attributes).to have_key('title') + expect(Invoice.sql_attributes).to have_key(:total_paid) + expect(Invoice.sql_attributes).to have_key('total_paid') + end + end + + describe '#with_ scopes' do + it 'loads the virtual string concatenated attribute' do + expect(Invoice.with_title.find(invoice.id).title).to eq '2020-0042 | My cool new course' + end + + it 'loads the virtual subquery attibute' do + expect(Invoice.with_total_amount.find(invoice.id).total_amount).to eq 58.22 + end + + it 'does not load other attributes' do + expect do + Invoice.with_title.find(invoice.id).total_amount + end.to raise_error SqlAttributes::NotLoaded + end + end + + describe '#with_sql_attributes' do + it 'loads the attribute if a single string is provided' do + expect(Invoice.with_sql_attributes('title').find(invoice.id).title).to eq '2020-0042 | My cool new course' + end + + it 'loads the attribute if a single symbol is provided' do + expect(Invoice.with_sql_attributes(:title).find(invoice.id).title).to eq '2020-0042 | My cool new course' + end + + it 'does not load other attributes if single attribute is provided' do + expect do + Invoice.with_sql_attributes(:title).find(invoice.id).total_amount + end.to raise_error SqlAttributes::NotLoaded + end + + it 'loads both attributes if two arguments are provided' do + invoice_with_attributes = Invoice.with_sql_attributes('total_amount', :title).find(invoice.id) + expect(invoice_with_attributes.title).to eq '2020-0042 | My cool new course' + expect(invoice_with_attributes.total_amount).to eq 58.22 + expect { invoice_with_attributes.total_paid }.to raise_error SqlAttributes::NotLoaded + end + + it 'loads all attributes if no argument provided' do + invoice_with_attributes = Invoice.with_sql_attributes.find(invoice.id) + expect(invoice_with_attributes.title).to eq '2020-0042 | My cool new course' + expect(invoice_with_attributes.total_amount).to eq 58.22 + expect(invoice_with_attributes.total_paid).to eq 20.25 + end + + it 'loads no attributes if argument is nil' do + invoice_with_attributes = Invoice.with_sql_attributes(nil).find(invoice.id) + expect { invoice_with_attributes.title }.to raise_error SqlAttributes::NotLoaded + expect { invoice_with_attributes.total_amount }.to raise_error SqlAttributes::NotLoaded + expect { invoice_with_attributes.total_paid }.to raise_error SqlAttributes::NotLoaded + end + + it 'loads no attributes if argument is empty array' do + invoice_with_attributes = Invoice.with_sql_attributes([]).find(invoice.id) + expect { invoice_with_attributes.title }.to raise_error SqlAttributes::NotLoaded + expect { invoice_with_attributes.total_amount }.to raise_error SqlAttributes::NotLoaded + expect { invoice_with_attributes.total_paid }.to raise_error SqlAttributes::NotLoaded + end + + it 'loads both attributes if two attributes are provided in an array' do + invoice_with_attributes = Invoice.with_sql_attributes(['total_amount', :title]).find(invoice.id) + expect(invoice_with_attributes.title).to eq '2020-0042 | My cool new course' + expect(invoice_with_attributes.total_amount).to eq 58.22 + expect { invoice_with_attributes.total_paid }.to raise_error SqlAttributes::NotLoaded + end + + it 'raises an error if attribute is unknown' do + expect do + Invoice.with_sql_attributes(:unknown_attribute).find(invoice.id) + end.to raise_error SqlAttributes::NotDefined + end + end +end diff --git a/spec/support/database_cleaner.rb b/spec/support/database_cleaner.rb new file mode 100644 index 0000000..cd48c06 --- /dev/null +++ b/spec/support/database_cleaner.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'database_cleaner-active_record' + +RSpec.configure do |config| + config.before(:suite) do + DatabaseCleaner.clean_with(:truncation) + end +end diff --git a/sql_attributes.gemspec b/sql_attributes.gemspec new file mode 100644 index 0000000..433e28e --- /dev/null +++ b/sql_attributes.gemspec @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +$LOAD_PATH.push File.expand_path('lib', __dir__) + +# Maintain your gem's version: +require 'sql_attributes/version' + +# Describe your gem and declare its dependencies: +Gem::Specification.new do |spec| + spec.name = 'sql_attributes' + spec.version = SqlAttributes::VERSION + spec.authors = ['Stef Schenkelaars'] + spec.email = ['stef.schenkelaars@gmail.com'] + spec.homepage = 'https://drieam.github.io/sql_attributes' + spec.summary = <<~MESSAGE + Add virtual attributes to an ActiveRecord model based on an SQL query. + MESSAGE + spec.description = spec.summary + spec.license = 'MIT' + spec.required_ruby_version = Gem::Requirement.new('>= 2.5.0') + + spec.metadata['homepage_uri'] = spec.homepage + spec.metadata['source_code_uri'] = 'https://github.com/Drieam/sql_attributes' + + spec.files = Dir['lib/**/*', 'LICENSE', 'README.md'] + + spec.add_dependency 'activerecord', '>= 6.0.3' # Depend on activerecord as ORM + + spec.add_development_dependency 'combustion' # Test rails engines + spec.add_development_dependency 'database_cleaner-active_record' # Ensure clean state for testing + spec.add_development_dependency 'rspec-github' # RSpec formatter for GitHub Actions + spec.add_development_dependency 'rspec-rails' # Testing framework + spec.add_development_dependency 'rubocop' # Linter + spec.add_development_dependency 'rubocop-performance' # Linter for Performance optimization analysis + spec.add_development_dependency 'rubocop-rails' # Linter for Rails-specific analysis + spec.add_development_dependency 'sqlite3' # Database adapter +end