From df13f658be4c72e380973cc98ee947d8d9517e29 Mon Sep 17 00:00:00 2001 From: Andy Pike Date: Thu, 7 Apr 2016 18:09:39 +0100 Subject: [PATCH] Add initial support for Query Objects A new main component of Rectify is the Query Object. It allows a database query to be encapsulated into a class. This allows logic that is specific to the query as well as the query itself to be removed from the ActiveRecord model as another strategy to reducing their size. Query Objects need to be derived from `Rectify::Query` and must implement the `query` method that returns either an `ActiveRecord::Relation` or an array (via `ActiveRecord::Quering#find_by_sql`) This commit also adds a simple way to stub Query Objects with the `stub_query` RSpec helper. See the readme for full details of how to use Query Objects in a Rails app. --- .gitignore | 1 + Gemfile.lock | 6 +- Rakefile | 50 +++ lib/rectify.rb | 2 + lib/rectify/errors.rb | 11 + lib/rectify/query.rb | 65 ++++ lib/rectify/rspec.rb | 3 + lib/rectify/rspec/helpers.rb | 10 + lib/rectify/rspec/matchers.rb | 51 +++ lib/rectify/rspec/stub_query.rb | 13 + lib/rectify/version.rb | 2 +- readme.md | 319 ++++++++++++++++-- rectify.gemspec | 2 + spec/config/database.yml | 4 + spec/db/migrate/20160407175608_add_user.rb | 10 + .../20160407192025_add_active_to_users.rb | 5 + spec/db/schema.rb | 24 ++ spec/fixtures/models/user.rb | 11 +- spec/fixtures/queries/active_users.rb | 5 + spec/fixtures/queries/all_users.rb | 5 + spec/fixtures/queries/scoped_users_over.rb | 10 + spec/fixtures/queries/users_over.rb | 9 + spec/fixtures/queries/users_over_using_sql.rb | 24 ++ .../queries/users_with_name_starting.rb | 15 + spec/lib/rectify/query_spec.rb | 222 ++++++++++++ spec/spec_helper.rb | 18 +- 26 files changed, 860 insertions(+), 37 deletions(-) create mode 100644 Rakefile create mode 100644 lib/rectify/errors.rb create mode 100644 lib/rectify/query.rb create mode 100644 lib/rectify/rspec.rb create mode 100644 lib/rectify/rspec/helpers.rb create mode 100644 lib/rectify/rspec/matchers.rb create mode 100644 lib/rectify/rspec/stub_query.rb create mode 100644 spec/config/database.yml create mode 100644 spec/db/migrate/20160407175608_add_user.rb create mode 100644 spec/db/migrate/20160407192025_add_active_to_users.rb create mode 100644 spec/db/schema.rb create mode 100644 spec/fixtures/queries/active_users.rb create mode 100644 spec/fixtures/queries/all_users.rb create mode 100644 spec/fixtures/queries/scoped_users_over.rb create mode 100644 spec/fixtures/queries/users_over.rb create mode 100644 spec/fixtures/queries/users_over_using_sql.rb create mode 100644 spec/fixtures/queries/users_with_name_starting.rb create mode 100644 spec/lib/rectify/query_spec.rb diff --git a/.gitignore b/.gitignore index c111b33..644d6c9 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ *.gem +*.sqlite3 diff --git a/Gemfile.lock b/Gemfile.lock index aa7b97c..4e4046d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - rectify (0.2.0) + rectify (0.3.0) activemodel (~> 4.2, >= 4.2.0) activerecord (~> 4.2, >= 4.2.0) activesupport (~> 4.2, >= 4.2.0) @@ -77,6 +77,7 @@ GEM rails-deprecated_sanitizer (>= 1.0.1) rails-html-sanitizer (1.0.3) loofah (~> 2.0) + rake (10.4.2) rspec (3.4.0) rspec-core (~> 3.4.0) rspec-expectations (~> 3.4.0) @@ -93,6 +94,7 @@ GEM rspec-support (~> 3.4.0) rspec-support (3.4.1) slop (3.6.0) + sqlite3 (1.3.11) thread_safe (0.3.5) tzinfo (1.2.2) thread_safe (~> 0.1) @@ -111,9 +113,11 @@ DEPENDENCIES actionpack (~> 4.2, >= 4.2.0) awesome_print (~> 1.6) pry (~> 0.10.3) + rake rectify! rspec (~> 3.4) rspec-collection_matchers (~> 1.1, >= 1.1.2) + sqlite3 wisper-rspec (~> 0.0.2) BUNDLED WITH diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..8a7df9d --- /dev/null +++ b/Rakefile @@ -0,0 +1,50 @@ +# Stolen from https://gist.github.com/schickling/6762581 +# Thank you <3 +require "yaml" +require "active_record" + +namespace :db do + db_config = YAML.load(File.open("spec/config/database.yml")) + + desc "Migrate the database" + task :migrate do + ActiveRecord::Base.establish_connection(db_config) + ActiveRecord::Migrator.migrate("spec/db/migrate") + Rake::Task["db:schema"].invoke + puts "Database migrated." + end + + desc "Create a db/schema.rb file that is portable against any supported DB" + task :schema do + ActiveRecord::Base.establish_connection(db_config) + require "active_record/schema_dumper" + filename = "spec/db/schema.rb" + File.open(filename, "w:utf-8") do |file| + ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection, file) + end + end +end + +namespace :g do + desc "Generate migration" + task :migration do + name = ARGV[1] || fail("Specify name: rake g:migration your_migration") + timestamp = Time.now.strftime("%Y%m%d%H%M%S") + folder = "../spec/db/migrate" + path = File.expand_path("#{folder}/#{timestamp}_#{name}.rb", __FILE__) + + migration_class = name.split("_").map(&:capitalize).join + + File.open(path, "w") do |file| + file.write <<-EOF.strip_heredoc + class #{migration_class} < ActiveRecord::Migration + def change + end + end + EOF + end + + puts "Migration #{path} created" + abort # needed stop other tasks + end +end diff --git a/lib/rectify.rb b/lib/rectify.rb index c41ebb8..22362a7 100644 --- a/lib/rectify.rb +++ b/lib/rectify.rb @@ -10,4 +10,6 @@ require "rectify/form" require "rectify/command" require "rectify/presenter" +require "rectify/query" require "rectify/controller_helpers" +require "rectify/errors" diff --git a/lib/rectify/errors.rb b/lib/rectify/errors.rb new file mode 100644 index 0000000..b2f1dee --- /dev/null +++ b/lib/rectify/errors.rb @@ -0,0 +1,11 @@ +module Rectify + class UnableToComposeQueries < StandardError + def initialize(query, other) + super( + "Unable to composite queries #{query.class.name} and " \ + "#{other.class.name}. You cannot compose queries where #query " \ + "returns an ActiveRecord::Relation in one and an array in the other." + ) + end + end +end diff --git a/lib/rectify/query.rb b/lib/rectify/query.rb new file mode 100644 index 0000000..8e9c486 --- /dev/null +++ b/lib/rectify/query.rb @@ -0,0 +1,65 @@ +module Rectify + module SqlQuery + def query + model.find_by_sql([sql, params]) + end + end + + class Query + def initialize(scope = ActiveRecord::NullRelation) + @scope = scope + end + + def query + @scope + end + + def |(other) + if relation? && other.relation? + Rectify::Query.new(cached_query.merge(other.cached_query)) + elsif eager? && other.eager? + Rectify::Query.new(cached_query | other.cached_query) + else + fail UnableToComposeQueries.new(self, other) + end + end + + def count + cached_query.count + end + + def first + cached_query.first + end + + def each(&block) + cached_query.each(&block) + end + + def exists? + return cached_query.exists? if relation? + + cached_query.present? + end + + def none? + !exists? + end + + def to_a + cached_query.to_a + end + + def relation? + cached_query.is_a?(ActiveRecord::Relation) + end + + def eager? + cached_query.is_a?(Array) + end + + def cached_query + @cached_query ||= query + end + end +end diff --git a/lib/rectify/rspec.rb b/lib/rectify/rspec.rb new file mode 100644 index 0000000..f3d48f5 --- /dev/null +++ b/lib/rectify/rspec.rb @@ -0,0 +1,3 @@ +require "rectify/rspec/stub_query" +require "rectify/rspec/helpers" +require "rectify/rspec/matchers" diff --git a/lib/rectify/rspec/helpers.rb b/lib/rectify/rspec/helpers.rb new file mode 100644 index 0000000..2975ad0 --- /dev/null +++ b/lib/rectify/rspec/helpers.rb @@ -0,0 +1,10 @@ +module Rectify + module RSpec + module Helpers + def stub_query(query_class, options = {}) + results = options.fetch(:results, []) + allow(query_class).to receive(:new) { StubQuery.new(results) } + end + end + end +end diff --git a/lib/rectify/rspec/matchers.rb b/lib/rectify/rspec/matchers.rb new file mode 100644 index 0000000..c43e2e3 --- /dev/null +++ b/lib/rectify/rspec/matchers.rb @@ -0,0 +1,51 @@ +require "rspec/expectations" + +module Rectify + module DatabaseReporting + SQL_TO_IGNORE = / + pg_table| + pg_attribute| + pg_namespace| + current_database| + information_schema| + ^TRUNCATE TABLE| + ^ALTER TABLE| + ^BEGIN| + ^COMMIT| + ^ROLLBACK| + ^RELEASE| + ^SAVEPOINT| + ^SHOW| + ^PRAGMA + /xi + end +end + +RSpec::Matchers.define :make_database_queries_of do |expected| + supports_block_expectations + + queries = [] + + match do |proc| + ActiveSupport::Notifications + .subscribe("sql.active_record") do |_, _, _, _, query| + sql = query[:sql] + + unless Rectify::DatabaseReporting::SQL_TO_IGNORE.match(sql) + queries << sql + end + end + + proc.call + + queries.size == expected + end + + failure_message do |_| + all_queries = queries.join("\n") + + "expected the number of queries to be #{expected} " \ + "but there were #{queries.size}.\n\n" \ + "Here are the queries that were made:\n\n#{all_queries}" + end +end diff --git a/lib/rectify/rspec/stub_query.rb b/lib/rectify/rspec/stub_query.rb new file mode 100644 index 0000000..a9388a9 --- /dev/null +++ b/lib/rectify/rspec/stub_query.rb @@ -0,0 +1,13 @@ +module Rectify + module RSpec + class StubQuery < Query + def initialize(results) + @results = Array(results) + end + + def query + @results + end + end + end +end diff --git a/lib/rectify/version.rb b/lib/rectify/version.rb index 1da66dd..2ff4818 100644 --- a/lib/rectify/version.rb +++ b/lib/rectify/version.rb @@ -1,3 +1,3 @@ module Rectify - VERSION = "0.2.0" + VERSION = "0.3.0" end diff --git a/readme.md b/readme.md index aa6cf51..8c265e9 100644 --- a/readme.md +++ b/readme.md @@ -29,6 +29,7 @@ Currently, Rectify consists of the following concepts: * [Form Objects](#form-objects) * [Commands](#commands) * [Presenters](#presenters) +* [Query Objects](#query-objects) You can use these separately or together to improve the structure of your Rails applications. @@ -39,15 +40,21 @@ views are filled with too much logic. The opinion of Rectify is that these places are incorrect and that your models in particular are doing too much. Rectify's opinion is that controllers should just be concerned with HTTP related -things and models should just be concerned with data access. The problem then -becomes, how and where do you place validations and other business logic. +things and models should just be concerned with data relationships. The problem +then becomes, how and where do you place validations, queries and other business +logic? -Using Rectify, the Form Objects contain validations and represent the data input +Using Rectify, Form Objects contain validations and represent the data input of your system. Commands then take a Form Object (as well as other data) and -perform a single action which is invoked by a controller. Presenters contain the -presentation logic in a way that is easily testable and keeps your views as +perform a single action which is invoked by a controller. Query objects +encapsulate a single database query (and any logic it needs). Presenters contain +the presentation logic in a way that is easily testable and keeps your views as clean as possible. +Rectify is designed to be very lightweight and allows you to use some or all of +it's components. We also advise to use these components where they make sense +not just blindly everywhere. More on that later. + Here's an example controller that shows details about a user and also allows a user to register an account. This creates a user, sends some emails, does some special auditing and integrates with a third party system: @@ -82,11 +89,12 @@ business logic of registering a new account. The controller is clean and business logic now has a natural home: ``` -HTTP => Controller (redirecting, rendering, etc) -Data Input => Form Object (validation, acceptable input) -Business Logic => Command (logic for a specific use case) -Data Access => Model (relationships, queries) -View Logic => Presenter (formatting data) +HTTP => Controller (redirecting, rendering, etc) +Data Input => Form Object (validation, acceptable input) +Business Logic => Command (logic for a specific use case) +Data Persistence => Model (relationships between models) +Data Access => Query Object (database queries) +View Logic => Presenter (formatting data) ``` The next sections will give further details about using Form Objects, Commands @@ -670,13 +678,249 @@ user.each do |u| end ``` +## Query Objects + +The final main component to Rectify is the Query Object. It's role is to +encapsulate a single database query and any logic that it query needs to +operate. It still uses ActiveRecord but adds some very light sugar on the top to +make this style of architecture easier. This helps to keep your model classes +lean and gives a natural home to this code. + +To create a query object, you create a new class and derive off of +`Rectify::Query`. The only thing you need to do is to implement the +`#query` method and return an `ActiveRecord::Relation` object from it: + +```ruby +class ActiveUsers < Rectify::Query + def query + User.where(:active => true) + end +end +``` + +To use this object, you just instantiate it and then use one of the following +methods to make use of it: + +```ruby +ActiveUsers.new.count # => Returns the number of records +ActiveUsers.new.first # => Returns the first record +ActiveUsers.new.exists? # => Returns true if there are any records, else false +ActiveUsers.new.none? # => Returns true if there are no records, else false +ActiveUsers.new.to_a # => Execute the query and returns the resulting objects +ActiveUsers.new.each do |user| # => Iterates over each result + puts user.name +end +``` + +### Passing data to query objects + +Passing data that your queries need to operate is best done via the constructor: + +```ruby +class UsersOlderThan < Rectify::Query + def initialize(age) + @age = age + end + + def query + User.where("age > ?", @age) + end +end + +UsersOlderThan.new(25).count # => Returns the number of users over 25 years old +``` + +Sometimes your queries will need to do a little work with the provided data +before they can use it. Having your query encapsulated in an object makes this +easy and maintainable (here's a trivial example): + +```ruby +class UsersWithBlacklistedEmail < Rectify::Query + def initialize(blacklist) + @blacklist = blacklist + end + + def query + User.where(:email => blacklisted_emails) + end + + private + + def blacklisted_emails + @blacklist.map { |b| b.email.strip.downcase } + end +end +``` + +### Composition + +One of this great features of ActiveRecord is the ability to easily compose +queries together in a simple way which helps reusability. Rectify Query Objects +can also be combined to created composed queries using the `|` operator as we +use in Ruby for Set Union. Here's how it looks: + +```ruby +active_users_over_20 = ActiveUsers.new | UsersOlderThan.new(20) + +active_users_over_20.count # => Returns number of active users over 20 years old +``` + +You can union many queries in this manner which will result in another +`Rectify::Query` object that you can use just like any other. This results in a +single database query. + +If you don't like this way of composing queries then the other option would be +to pass in a query object to another in it's constructor and use it as the base +scope: + +```ruby +class UsersOlderThan < Rectify::Query + def initialize(age, scope = AllUsers.new) + @age = age + @scope = scope + end + + def query + @scope.query.where("age > ?", @age) + end +end + +UsersOlderThan.new(20, ActiveUsers.new).count +``` + +Although this method is possible and maybe useful in some cases it's cleaner to +use the provided `|` operator so we recommend that approach for the majority of +the time. + +### Leveraging your database + +Using `ActiveRecord::Relation` is a great way to construct your database queries +but sometimes you need to to use features of your database that aren't supported +by ActiveRecord directly. These are usually database specific and can greatly +improve your query efficiency. When that happens, you will need to write some +raw SQL. Rectify Query Objects allow for this. In addition to your `#query` +method returning an `ActiveRecord::Relation` you can also return an array of +objects. This means you can run raw SQL using +`ActiveRecord::Querying#find_by_sql`: + +```ruby +class UsersOverUsingSql < Rectify::Query + def initialize(age) + @age = age + end + + def query + User.find_by_sql([ + "SELECT * FROM users WHERE age > :age ORDER BY age ASC", { :age => @age } + ]) + end +end +``` + +When you do this, the normal `Rectify::Query` methods are available but they +operate on the returned array rather than on the `ActiveRecord::Relation`. This +includes composition using the `|` operator but you can't compose an +`ActiveRecord::Relation` query object with one that returns an array of objects +from its `#query` method. You can compose two queries where both return arrays +but be aware that this will query the database for each query object and then +perform a Ruby array set union on the results. This might not be the most +efficient way to get the results so only use this when you are sure it's the +right thing to do. + +The above example is fine for short SQL statements but if you are using raw SQL, +they will probably be much longer than a single line. Rectify provides a small +module that you can include to makes your query objects cleaner: + +```ruby +class UsersOverUsingSql < Rectify::Query + include Rectify::SqlQuery + + def initialize(age) + @age = age + end + + def model + User + end + + def sql + <<-SQL.strip_heredoc + SELECT * + FROM users + WHERE age > :age + ORDER BY age ASC + SQL + end + + def params + { :age => @age } + end +end +``` + +Just include `Rectify::SqlQuery` in your query object and then supply the a +`model` method that returns the model of the returned objects. A +`params` method that returns a hash containing named parameters that the SQL +statement requires. Lastly, you must supply a `sql` method that returns the raw +SQL. We recommend using a heredoc which makes the SQL much cleaner and easier +to read. Parameters use the ActiveRecord standard symbol notation as shown above +with the `:age` parameter. + +### Stubbing Query Objects in tests + +Now that you have your queries nicely encapsulated, it's now easier with a clear +division of responsibility to improve how you use the database within your +tests. You should unit test your Query Objects to ensure they return the correct +data from a know database state. + +What you can now do it stub out these database calls when you use them in other +classes. This improves your test code in a couple of ways: + +1. You need less database setup code within your tests. Normally you might use +something like factory_girl to create records in your database and then when +your tests run they query this set of data. Stubbing the queries within your +tests can reduce this complexity. +2. Fewer database queries running and less factory usage means that your tests +3. are doing less work and therefore will run a bit faster. + +In Rectify, we provide the RSpec helper method `stub_query` that will make +stubbing Query Objects easy: + +```ruby +# inside spec/rails_helper.rb + +require "rectify/rspec" + +RSpec.configure do |config| + # snip ... + + config.include Rectify::RSpec::Helpers +end + +# within a spec: + +it "returns the number of users" do + stub_query(UsersOlderThan, :results => [User.new, User.new]) + + expect(subject.awesome_method).to eq(2) +end +``` + +As a convenience `:results` accepts either an array of objects or a single +instance: + +```ruby +stub_query(UsersOlderThan, :results => [User.new, User.new]) +stub_query(UsersOlderThan, :results => User.new) +``` + ## Where do I put my files? -The next inevitable question is "Where do I put my Forms, Commands and -Presenters?". You could create `forms`, `commands` and `presenters` folders and -follow the Rails Way. Rectify suggests grouping your classes by feature rather -than by pattern. For example, create a folder called `core` (this can be -anything) and within that, create a folder for each broad feature of your +The next inevitable question is "Where do I put my Forms, Commands, Queries and +Presenters?". You could create `forms`, `commands`, `queries` and `presenters` +folders and follow the Rails Way. Rectify suggests grouping your classes by +feature rather than by pattern. For example, create a folder called `core` (this +can be anything) and within that, create a folder for each broad feature of your application. Something like the following: ``` @@ -694,7 +938,8 @@ application. Something like the following: ``` Then you would place your classes in the appropriate feature folder. If you -follow this pattern remember to namespace your classes with a matching module: +follow this pattern remember to namespace your classes with a matching module +which will allow Rails to load them: ```ruby # in app/core/billing/send_invoice.rb @@ -709,6 +954,25 @@ end You don't need to alter your load path as everything in the `app` folder is loaded automatically. +As stated above, if you prefer not to use this method of organizing your code +then that is totally fine. Just create folders under `app` for the things in +Rectify that you use: + +``` +. +└── app + ├── commands + ├── controllers + ├── forms + ├── models + ├── presenters + ├── queries + └── views +``` + +You don't need to make any configuration changes for your preferred folder +structure, just use whichever you feel most comfortable with. + ## Trade offs This style of Rails architecture is not a silver bullet for all projects. If @@ -722,12 +986,21 @@ whole system in your head. Personally I would prefer that as maintaining it will be easier as all code around a specific user task is on one place. Before you use these methods in your project, consider the trade off and use -these strategies where they make sense for you and your project. +these strategies where they make sense for you and your project. It maybe most +pragmatic to use a mixture of the classic Rails Way and the Rectify approach +depending on the complexity of different areas of your application. + +## Developing Rectify -## What's next? +Some tests (specifically for Query objects) we need access to a database that +ActiveRecord can connect to. We use SQLite for this at present. When you run the +specs with `bundle exec rspec`, the database will be created for you. -We stated above that the models should be responsible for data access. We -may introduce a nice way to keep using the power of ActiveRecord but in a way -where your models don't end up as a big ball of queries. We're thinking about -Query Objects and a nice way to do this and we're also thinking about a nicer -way to use raw SQL. +There are some Rake tasks to help with the management of this test database +using normal(ish) commands from Rails: + +``` +rake db:migrate # => Migrates the test database +rake db:schema # => Dumps database schema +rake g:migration # => Create a new migration file +``` diff --git a/rectify.gemspec b/rectify.gemspec index b65d4f0..b420a1e 100644 --- a/rectify.gemspec +++ b/rectify.gemspec @@ -24,4 +24,6 @@ Gem::Specification.new do |s| s.add_development_dependency "wisper-rspec", "~> 0.0.2" s.add_development_dependency "rspec", "~> 3.4" s.add_development_dependency "rspec-collection_matchers", "~> 1.1", ">= 1.1.2" + s.add_development_dependency "sqlite3" + s.add_development_dependency "rake" end diff --git a/spec/config/database.yml b/spec/config/database.yml new file mode 100644 index 0000000..d14ddc7 --- /dev/null +++ b/spec/config/database.yml @@ -0,0 +1,4 @@ +adapter: sqlite3 +database: spec/db/rectify_test.sqlite3 +pool: 5 +timeout: 5000 diff --git a/spec/db/migrate/20160407175608_add_user.rb b/spec/db/migrate/20160407175608_add_user.rb new file mode 100644 index 0000000..c6625c8 --- /dev/null +++ b/spec/db/migrate/20160407175608_add_user.rb @@ -0,0 +1,10 @@ +class AddUser < ActiveRecord::Migration + def change + create_table :users do |t| + t.string :first_name, :null => false, :default => "" + t.integer :age, :null => false, :default => 0 + + t.timestamps :null => false + end + end +end diff --git a/spec/db/migrate/20160407192025_add_active_to_users.rb b/spec/db/migrate/20160407192025_add_active_to_users.rb new file mode 100644 index 0000000..7a022d5 --- /dev/null +++ b/spec/db/migrate/20160407192025_add_active_to_users.rb @@ -0,0 +1,5 @@ +class AddActiveToUsers < ActiveRecord::Migration + def change + add_column :users, :active, :boolean, :null => false, :default => true + end +end diff --git a/spec/db/schema.rb b/spec/db/schema.rb new file mode 100644 index 0000000..0935a82 --- /dev/null +++ b/spec/db/schema.rb @@ -0,0 +1,24 @@ +# encoding: UTF-8 +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# Note that this schema.rb definition is the authoritative source for your +# database schema. If you need to create the application database on another +# system, you should be using db:schema:load, not running all the migrations +# from scratch. The latter is a flawed and unsustainable approach (the more migrations +# you'll amass, the slower it'll run and the greater likelihood for issues). +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema.define(version: 20160407192025) do + + create_table "users", force: :cascade do |t| + t.string "first_name", default: "", null: false + t.integer "age", default: 0, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.boolean "active", default: true, null: false + end + +end diff --git a/spec/fixtures/models/user.rb b/spec/fixtures/models/user.rb index a008a94..4a57cf0 100644 --- a/spec/fixtures/models/user.rb +++ b/spec/fixtures/models/user.rb @@ -1,11 +1,2 @@ -class User - include ActiveModel::Model - include Virtus.model - - attribute :first_name, String - attribute :age, Integer - - def save! - # For mocking an ActiveRecord class - end +class User < ActiveRecord::Base end diff --git a/spec/fixtures/queries/active_users.rb b/spec/fixtures/queries/active_users.rb new file mode 100644 index 0000000..b4f10f4 --- /dev/null +++ b/spec/fixtures/queries/active_users.rb @@ -0,0 +1,5 @@ +class ActiveUsers < Rectify::Query + def query + User.where(:active => true) + end +end diff --git a/spec/fixtures/queries/all_users.rb b/spec/fixtures/queries/all_users.rb new file mode 100644 index 0000000..54900b9 --- /dev/null +++ b/spec/fixtures/queries/all_users.rb @@ -0,0 +1,5 @@ +class AllUsers < Rectify::Query + def query + User.order(:age => :asc) + end +end diff --git a/spec/fixtures/queries/scoped_users_over.rb b/spec/fixtures/queries/scoped_users_over.rb new file mode 100644 index 0000000..fedffa2 --- /dev/null +++ b/spec/fixtures/queries/scoped_users_over.rb @@ -0,0 +1,10 @@ +class ScopedUsersOver < Rectify::Query + def initialize(age, scope = AllUsers.new) + @age = age + @scope = scope + end + + def query + @scope.query.where("age > ?", @age) + end +end diff --git a/spec/fixtures/queries/users_over.rb b/spec/fixtures/queries/users_over.rb new file mode 100644 index 0000000..3ca76dd --- /dev/null +++ b/spec/fixtures/queries/users_over.rb @@ -0,0 +1,9 @@ +class UsersOver < Rectify::Query + def initialize(age) + @age = age + end + + def query + User.where("age > ?", @age) + end +end diff --git a/spec/fixtures/queries/users_over_using_sql.rb b/spec/fixtures/queries/users_over_using_sql.rb new file mode 100644 index 0000000..39e2e55 --- /dev/null +++ b/spec/fixtures/queries/users_over_using_sql.rb @@ -0,0 +1,24 @@ +class UsersOverUsingSql < Rectify::Query + include Rectify::SqlQuery + + def initialize(age) + @age = age + end + + def model + User + end + + def sql + <<-SQL.strip_heredoc + SELECT * + FROM users + WHERE age > :age + ORDER BY age ASC + SQL + end + + def params + { :age => @age } + end +end diff --git a/spec/fixtures/queries/users_with_name_starting.rb b/spec/fixtures/queries/users_with_name_starting.rb new file mode 100644 index 0000000..d701ce7 --- /dev/null +++ b/spec/fixtures/queries/users_with_name_starting.rb @@ -0,0 +1,15 @@ +class UsersWithNameStarting < Rectify::Query + def initialize(letter) + @letter = letter + end + + def query + User.where("first_name like ?", name_prefix) + end + + private + + def name_prefix + "#{@letter}%" + end +end diff --git a/spec/lib/rectify/query_spec.rb b/spec/lib/rectify/query_spec.rb new file mode 100644 index 0000000..ddeb2f8 --- /dev/null +++ b/spec/lib/rectify/query_spec.rb @@ -0,0 +1,222 @@ +RSpec.describe Rectify::Query do + context "when #query returns an ActiveRecord::Relation" do + describe "#count" do + it "returns the count of the records matched by #query" do + User.create!(:first_name => "Andy", :age => 38) + + expect(AllUsers.new.count).to eq(1) + end + end + + describe "#first" do + it "returns the first record matched by #query" do + User.create!(:first_name => "Amber", :age => 10) + User.create!(:first_name => "Megan", :age => 9) + yongest = User.create!(:first_name => "Charlie", :age => 7) + + expect(AllUsers.new.first).to eq(yongest) + end + end + + describe "#each" do + it "yields each record matched by #query" do + a = User.create!(:first_name => "Amber", :age => 10) + m = User.create!(:first_name => "Megan", :age => 9) + + expect { |b| AllUsers.new.each(&b) }.to yield_successive_args(m, a) + end + end + + describe "#exists?" do + it "returns true if record matched by #query" do + User.create!(:first_name => "Amber", :age => 10) + + expect(AllUsers.new).to be_exists + end + + it "returns false if no records matched by #query" do + expect(AllUsers.new).not_to be_exists + end + end + + describe "#none?" do + it "returns false if record matched by #query" do + User.create!(:first_name => "Amber", :age => 10) + + expect(AllUsers.new).not_to be_none + end + + it "returns true if no records matched by #query" do + expect(AllUsers.new).to be_none + end + end + + describe "#to_a" do + it "returns the objects as an array" do + a = User.create!(:first_name => "Amber", :age => 10) + m = User.create!(:first_name => "Megan", :age => 9) + + expect(AllUsers.new.to_a).to match_array([a, m]) + end + end + + describe "composition of queries" do + it "returns the combination of two queries" do + User.create!(:first_name => "Megan", :age => 9) + User.create!(:first_name => "Fred", :age => 11, :active => false) + andy = User.create!(:first_name => "Andy", :age => 38) + + active_users_over_10 = ActiveUsers.new | UsersOver.new(10) + + expect(active_users_over_10.count).to eq(1) + expect(active_users_over_10.first).to eq(andy) + end + + it "returns the combination of three queries" do + User.create!(:first_name => "Megan", :age => 9) + User.create!(:first_name => "Fred", :age => 11, :active => false) + User.create!(:first_name => "George", :age => 40) + andy = User.create!(:first_name => "Andy", :age => 38) + + active_users_over_10_with_name_starting_with_a = ( + ActiveUsers.new | + UsersOver.new(10) | + UsersWithNameStarting.new("A") + ) + + expect(active_users_over_10_with_name_starting_with_a.count).to eq(1) + expect(active_users_over_10_with_name_starting_with_a.first).to eq(andy) + end + + it "supports composition via constructor" do + User.create!(:first_name => "Megan", :age => 21) + + expect(ScopedUsersOver.new(20, ActiveUsers.new).count).to eq(1) + end + end + end + + context "when #query returns an array" do + describe "#count" do + it "returns the count of the records matched by #query" do + User.create!(:first_name => "Amber", :age => 10) + User.create!(:first_name => "Andy", :age => 38) + + expect(UsersOverUsingSql.new(20).count).to eq(1) + end + end + + describe "#first" do + it "returns the first record matched by #query" do + User.create!(:first_name => "Amber", :age => 10) + User.create!(:first_name => "Megan", :age => 9) + yongest = User.create!(:first_name => "Charlie", :age => 7) + + expect(UsersOverUsingSql.new(0).first).to eq(yongest) + end + end + + describe "#each" do + it "yields each record matched by #query" do + a = User.create!(:first_name => "Amber", :age => 10) + m = User.create!(:first_name => "Megan", :age => 9) + + expect do |b| + UsersOverUsingSql.new(0).each(&b) + end.to yield_successive_args(m, a) + end + end + + describe "#exists?" do + it "returns true if record matched by #query" do + User.create!(:first_name => "Amber", :age => 10) + + expect(UsersOverUsingSql.new(0)).to be_exists + end + + it "returns false if no records matched by #query" do + expect(UsersOverUsingSql.new(0)).not_to be_exists + end + end + + describe "#none?" do + it "returns false if record matched by #query" do + User.create!(:first_name => "Amber", :age => 10) + + expect(UsersOverUsingSql.new(0)).not_to be_none + end + + it "returns true if no records matched by #query" do + expect(UsersOverUsingSql.new(0)).to be_none + end + end + + describe "#to_a" do + it "returns the objects as an array" do + a = User.create!(:first_name => "Amber", :age => 10) + m = User.create!(:first_name => "Megan", :age => 9) + + expect(UsersOverUsingSql.new(0).to_a).to match_array([a, m]) + end + end + + it "caches the results so subsequent calls don't hit the database" do + query = UsersOverUsingSql.new(0) + + expect do + query.count + query.first + query.each {} + query.exists? + query.none? + query.to_a + end.to make_database_queries_of(1) + end + + describe "composition of queries" do + it "raises an exeception if a sql query is merged with a relation" do + expect do + ActiveUsers.new | UsersOverUsingSql.new(0) + end.to raise_error(Rectify::UnableToComposeQueries) + end + + it "joins the result arrays of two sql queries" do + amber = User.create!(:first_name => "Amber", :age => 10) + andy = User.create!(:first_name => "Andy", :age => 38) + + users = UsersOverUsingSql.new(20) | UsersOverUsingSql.new(5) + + expect(users.count).to eq(2) + expect(users.to_a).to match_array([andy, amber]) + end + end + end + + describe "stubbing query methods" do + it "returns the provided (single) record" do + stub_query(AllUsers, :results => User.new) + + expect(AllUsers.new.count).to eq(1) + end + + it "returns the provided (multiple) records" do + stub_query(AllUsers, :results => [User.new, User.new]) + + expect(AllUsers.new.count).to eq(2) + end + + it "supports #exists?" do + stub_query(AllUsers, :results => User.new) + expect(AllUsers.new).to be_exists + + stub_query(AllUsers, :results => []) + expect(AllUsers.new).not_to be_exists + end + + it "doesn't make any database queries" do + stub_query(AllUsers, :results => [User.new, User.new]) + + expect { AllUsers.new.count }.to make_database_queries_of(0) + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index c4dfde6..b489773 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,4 +1,5 @@ require File.expand_path("../../lib/rectify", __FILE__) +require File.expand_path("../../lib/rectify/rspec", __FILE__) require "rspec/collection_matchers" require "wisper/rspec/matchers" @@ -9,18 +10,31 @@ Dir["spec/support/**/*.rb"].each { |f| require File.expand_path(f) } Dir["spec/fixtures/**/*.rb"].each { |f| require File.expand_path(f) } +system("rake db:migrate") + +db_config = YAML.load(File.open("spec/config/database.yml")) +ActiveRecord::Base.establish_connection(db_config) + RSpec.configure do |config| - config.expect_with :rspec do |expectations| + config.expect_with(:rspec) do |expectations| expectations.include_chain_clauses_in_custom_matcher_descriptions = true end - config.mock_with :rspec do |mocks| + config.mock_with(:rspec) do |mocks| mocks.verify_partial_doubles = true end + config.around(:each) do |test| + ActiveRecord::Base.transaction do + test.run + fail ActiveRecord::Rollback + end + end + config.disable_monkey_patching! config.backtrace_exclusion_patterns << /gems/ config.order = "random" config.include Wisper::RSpec::BroadcastMatcher + config.include Rectify::RSpec::Helpers end