├── .gitignore ├── .ruby-gemset ├── CONTRIBUTING.md ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── lib ├── time_for_a_boolean.rb └── time_for_a_boolean │ ├── railtie.rb │ └── version.rb ├── spec └── time_for_a_boolean_spec.rb └── time_for_a_boolean.gemspec /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | -------------------------------------------------------------------------------- /.ruby-gemset: -------------------------------------------------------------------------------- 1 | time_for_a_boolean 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | I love pull requests. Here's a quick guide: 4 | 5 | 1. Fork the repo. 6 | 2. Run the tests. I only take pull requests with passing tests, and it's great 7 | to know that you have a clean slate: `bundle && rake` 8 | 3. Add a test for your change. Only refactoring and documentation changes 9 | require no new tests. If you are adding functionality or fixing a bug, I need 10 | a test! 11 | 4. Make the test pass. 12 | 5. Make sure your changes adhere to the 13 | [thoughtbot Style Guide](https://github.com/thoughtbot/guides/tree/master/style) 14 | 6. Write an [awesome] [commit] [message]. 15 | [awesome]: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html 16 | [commit]: http://robots.thoughtbot.com/post/48933156625/5-useful-tips-for-a-better-commit-message 17 | [message]: http://rakeroutes.com/blog/deliberate-git/ 18 | 6. Push to your fork and submit a pull request. 19 | 7. At this point you're waiting on us. I like to at least comment on, if not 20 | accept, pull requests within a few days (and, typically, one business 21 | day). I may suggest some changes or improvements or alternatives. 22 | 23 | ## Increase your chances of getting merged 24 | 25 | Some things that will increase the chance that your pull request is accepted, 26 | taken straight from the Ruby on Rails guide: 27 | 28 | 1. Use Rails idioms and helpers 29 | 2. Include tests that fail without your code, and pass with it 30 | 3. Update the documentation, the surrounding one, examples elsewhere, guides, 31 | whatever is affected by your contribution 32 | 4. Syntax: 33 | * Two spaces, no tabs. 34 | * No trailing whitespace. Blank lines should not have any space. 35 | * Prefer `&&`/`||` over `and`/`or`. 36 | * `MyClass.my_method(my_arg)` not `my_method( my_arg )` or `my_method my_arg`. 37 | * `a = b` and not `a=b`. 38 | * Follow the conventions you see used in the source already. 39 | 5. And in case I didn't emphasize it enough: I love tests! 40 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in time_for_a_boolean.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Caleb Thompson 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Time for A Boolean 2 | ================== 3 | 4 | [![Build Status](https://api.travis-ci.org/calebthompson/time_for_a_boolean.svg?branch=master)](https://travis-ci.org/calebthompson/time_for_a_boolean) 5 | [![Code Climate](https://codeclimate.com/github/calebthompson/time_for_a_boolean.svg)](https://codeclimate.com/github/calebthompson/time_for_a_boolean) 6 | [![Coverage Status](https://coveralls.io/repos/calebthompson/time_for_a_boolean/badge.svg)](https://coveralls.io/r/calebthompson/time_for_a_boolean) 7 | 8 | 9 | > Sally: Hey, we need to add a flag to Post 10 | 11 | > Jean: What for? 12 | 13 | > Sally: Well, we want to let users "delete" posts, but not actually lose the 14 | data. 15 | 16 | > Jean: Sounds reasonable. But what about later, when we have to know _when_ a 17 | post was deleted? 18 | 19 | > Sally: That's a good point, but if we add a timestamp now we have to write all 20 | sorts of methods to keep a nice interface on Post... 21 | 22 | > Jean: Time for A Boolean! 23 | 24 | Wait, what? 25 | ----------- 26 | 27 | ``` 28 | rails generate migration AddDeletedAtToPosts deleted_at:timestamp 29 | ``` 30 | 31 | ```ruby 32 | class Post < ActiveRecord::Base 33 | time_for_a_boolean :deleted 34 | ... 35 | end 36 | ``` 37 | 38 | ```ruby 39 | class PostsController < ApplicationController 40 | def show 41 | @post = Post.find(params[:id]) 42 | if @post.deleted? 43 | raise ActiveRecord::RecordNotFound 44 | end 45 | end 46 | 47 | def destroy 48 | post = Post.find(params[:id]) 49 | post.deleted = true 50 | post.save 51 | redirect_to posts_url 52 | end 53 | end 54 | ``` 55 | 56 | You keep on saying things and I don't get it. 57 | --------------------------------------------- 58 | 59 | Okay, let's take a look at what happens. 60 | 61 | When we call `time_for_a_boolean :deleted` in the Post class definition, several 62 | methods are defined: 63 | 64 | | Method | Description 65 | | --------------- | ----------- 66 | | `Post#deleted` | `true` if `Post#deleted_at` is set to a time before `Time.current`, `false` otherwise 67 | | `Post#deleted?` | Alias for `Post#deleted` 68 | | `Post#deleted=` | Sets the timestamp to `Time.current` if the new value is true, and `nil` otherwise 69 | 70 | These methods allow you to use a timestamp as you would a boolean value in your 71 | application. 72 | 73 | Okay... why? 74 | ------------ 75 | 76 | * Audit for when a flag was set. Future you wants this. 77 | * `COUNT(posts.deleted_at)` gives you the count of deleted posts, which is 78 | useful when writing a report. Define and use `Post.deleted.count` when you 79 | have Ruby available. 80 | 81 | Other Options 82 | ------------- 83 | 84 | If you have a date or time column that does not follow the `attribute_at` convention, 85 | you can specify the attribute name: 86 | 87 | ``` 88 | class User < ActiveRecord::Base 89 | time_for_a_boolean :expires, :expires_on 90 | end 91 | ``` 92 | 93 | This is especially useful when using date only columns. 94 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rspec/core/rake_task' 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task default: :spec 7 | -------------------------------------------------------------------------------- /lib/time_for_a_boolean.rb: -------------------------------------------------------------------------------- 1 | require "active_support" 2 | require "active_support/core_ext/module/delegation" 3 | require "active_support/core_ext/time/calculations" 4 | require "active_model/type" 5 | require "time_for_a_boolean/version" 6 | require "time_for_a_boolean/railtie" 7 | 8 | module TimeForABoolean 9 | def time_for_a_boolean(attribute, field=:"#{attribute}_at") 10 | define_method(attribute) do 11 | !send(field).nil? && send(field) <= -> { Time.current }.() 12 | end 13 | 14 | alias_method :"#{attribute}?", attribute 15 | 16 | setter_attribute = :"#{field}=" 17 | define_method(:"#{attribute}=") do |value| 18 | if ActiveModel::Type::Boolean::FALSE_VALUES.include?(value) 19 | send(setter_attribute, nil) 20 | else 21 | send(setter_attribute, -> { Time.current }.()) 22 | end 23 | end 24 | 25 | define_method(:"#{attribute}!") do 26 | send(:"#{attribute}=", true) 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/time_for_a_boolean/railtie.rb: -------------------------------------------------------------------------------- 1 | require "rails/railtie" 2 | 3 | module TimeForABoolean 4 | class Railtie < ::Rails::Railtie 5 | initializer 'time_for_a_boolean' do 6 | ActiveSupport.on_load :active_record do 7 | extend TimeForABoolean 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/time_for_a_boolean/version.rb: -------------------------------------------------------------------------------- 1 | module TimeForABoolean 2 | VERSION = '0.2.2' 3 | end 4 | -------------------------------------------------------------------------------- /spec/time_for_a_boolean_spec.rb: -------------------------------------------------------------------------------- 1 | require 'coveralls' 2 | Coveralls.wear! 3 | 4 | require 'time_for_a_boolean' 5 | 6 | describe TimeForABoolean do 7 | it 'defines the attribute method' do 8 | klass.time_for_a_boolean :attribute 9 | 10 | expect(klass.new).to respond_to :attribute 11 | end 12 | 13 | it 'defines the query method' do 14 | klass.time_for_a_boolean :attribute 15 | 16 | expect(klass.new).to respond_to :attribute? 17 | end 18 | 19 | it 'defines the attribute writer' do 20 | klass.time_for_a_boolean :attribute 21 | 22 | expect(klass.new).to respond_to :attribute= 23 | end 24 | 25 | it 'defines the bangable method to act as an implicit writer' do 26 | klass.time_for_a_boolean :attribute 27 | 28 | expect(klass.new).to respond_to :attribute! 29 | end 30 | 31 | describe 'attribute method' do 32 | it 'calls nil? on the backing timestamp' do 33 | klass.time_for_a_boolean :attribute 34 | timestamp = double(nil?: true) 35 | allow(object).to receive(:attribute_at).and_return(timestamp) 36 | 37 | object.attribute 38 | 39 | expect(timestamp).to have_received(:nil?) 40 | end 41 | 42 | it 'is true if the attribute is not nil' do 43 | klass.time_for_a_boolean :attribute 44 | allow(object).to receive(:attribute_at).and_return(Time.now - 10) 45 | 46 | expect(object.attribute).to be_truthy 47 | end 48 | 49 | it 'is false if the attribute is nil' do 50 | klass.time_for_a_boolean :attribute 51 | allow(object).to receive(:attribute_at) 52 | 53 | expect(object.attribute).to be_falsey 54 | end 55 | 56 | it 'is false if the attribute time is in the future' do 57 | klass.time_for_a_boolean :attribute 58 | allow(object).to receive(:attribute_at).and_return(Time.now + 86400) # one day in the future 59 | 60 | expect(object.attribute).to be_falsey 61 | end 62 | 63 | context 'when the user has defined their own attribute name' do 64 | it 'calls nil? on the backing value' do 65 | klass.time_for_a_boolean :attribute, :attribute_on 66 | date = double(nil?: true) 67 | allow(object).to receive(:attribute_on).and_return(date) 68 | 69 | object.attribute 70 | 71 | expect(date).to have_received(:nil?) 72 | end 73 | 74 | it 'is true if the attribute is not nil' do 75 | klass.time_for_a_boolean :attribute, :attribute_on 76 | allow(object).to receive(:attribute_on).and_return(Date.current - 10) 77 | 78 | expect(object.attribute).to be_truthy 79 | end 80 | 81 | it 'is false if the attribute is nil' do 82 | klass.time_for_a_boolean :attribute, :attribute_on 83 | allow(object).to receive(:attribute_on) 84 | 85 | expect(object.attribute).to be_falsey 86 | end 87 | 88 | it 'is false if the attribute date is in the future' do 89 | klass.time_for_a_boolean :attribute, :attribute_on 90 | allow(object).to receive(:attribute_on).and_return(Date.current + 1) 91 | 92 | expect(object.attribute).to be_falsey 93 | end 94 | end 95 | end 96 | 97 | describe 'the query method' do 98 | it 'is an alias for the attribute method' do 99 | klass.time_for_a_boolean :attribute 100 | 101 | expect(object.method(:attribute?)).to eq object.method(:attribute) 102 | end 103 | end 104 | 105 | describe 'the writer method' do 106 | it 'sets the timestamp to now if value is true' do 107 | klass.time_for_a_boolean :attribute 108 | klass.send(:attr_accessor, :attribute_at) 109 | 110 | object.attribute = true 111 | 112 | expect(object.attribute_at).to be_kind_of(Time) 113 | end 114 | 115 | it 'sets the timestamp to nil if value is false' do 116 | klass.time_for_a_boolean :attribute 117 | klass.send(:attr_accessor, :attribute_at) 118 | 119 | object.attribute_at = Time.now 120 | object.attribute = false 121 | 122 | expect(object.attribute_at).to be_nil 123 | end 124 | 125 | it 'works with other representations of true' do 126 | klass.time_for_a_boolean :attribute 127 | klass.send(:attr_accessor, :attribute_at) 128 | 129 | object.attribute = '1' 130 | 131 | expect(object.attribute_at).to be_kind_of(Time) 132 | end 133 | 134 | it 'works with other representations of false' do 135 | klass.time_for_a_boolean :attribute 136 | klass.send(:attr_accessor, :attribute_at) 137 | 138 | object.attribute_at = Time.now 139 | object.attribute = '0' 140 | 141 | expect(object.attribute_at).to be_nil 142 | end 143 | end 144 | 145 | describe 'the bangable method' do 146 | it 'sets the timestamp to now' do 147 | klass.time_for_a_boolean :attribute 148 | klass.send(:attr_accessor, :attribute_at) 149 | 150 | object.attribute! 151 | 152 | expect(object.attribute_at).to be_kind_of(Time) 153 | end 154 | end 155 | 156 | def klass 157 | @klass ||= Class.new do 158 | extend TimeForABoolean 159 | end 160 | end 161 | 162 | def object 163 | @object ||= klass.new 164 | end 165 | end 166 | -------------------------------------------------------------------------------- /time_for_a_boolean.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path("../lib", __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require "time_for_a_boolean/version" 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "time_for_a_boolean" 8 | spec.version = TimeForABoolean::VERSION 9 | spec.authors = ["Caleb Hearth"] 10 | spec.email = ["caleb@calebhearth.com"] 11 | spec.description = <<-DESCRIPTION 12 | Use timestamp values to represent boolean data such as deleted, published, 13 | or subscribed. 14 | DESCRIPTION 15 | spec.summary = "Back boolean values with timestamps" 16 | spec.homepage = "https://github.com/calebhearth/time_for_a_boolean" 17 | spec.license = "MIT" 18 | 19 | spec.files = `git ls-files`.split($/) 20 | spec.test_files = spec.files.grep(%r{^spec/}) 21 | spec.require_paths = ["lib"] 22 | 23 | spec.metadata = { 24 | "funding-uri" => "https://github.com/sponsors/calebhearth" 25 | } 26 | 27 | spec.add_dependency "activerecord" 28 | spec.add_dependency "railties" 29 | 30 | spec.add_development_dependency "bundler" 31 | spec.add_development_dependency "coveralls" 32 | spec.add_development_dependency "rake" 33 | spec.add_development_dependency "rspec" 34 | end 35 | --------------------------------------------------------------------------------