Inquisitive

Predicate methods for those curious about their datastructures.

Synopsis

Inquisitive provides String, Array, and Hash subclasses with dynamic predicate methods that allow you to interrogate the most common Ruby datastructures in a readable, friendly fashion. It's the inevitable evolution of ActiveSupport's StringInquirer.

It also allows you to elegantly interrogate your ENV hash through the Inquisitive::Environment module.

Inquisitive will try to use ActiveSupport's HashWithIndifferentAccess, but if that cannot be found it will bootstrap itself with a minimal, well-tested version extracted from ActiveSupport 4.0.

Inquisitive is tested against all maintained versions of Ruby and ActiveSupport.


Status

Version Quality Dependencies

:thumbsup: Continuous Integration Test Coverage
Master Build Status Coverage Status
Development Build Status Coverage Status

Installation

To add to your project:

$ echo "gem 'inquisitive'" >> Gemfile
$ bundle install

Otherwise:

$ gem install inquisitive

Usage

Helpers

You can coerce any object to a supported Inquisitive equivalent with the Inquisitive coercion helpers:

Inquisitive.coerce('foo').class
#=> Inquisitive::String
Inquisitive.coerce(1).class
#=> Integer

Inquisitive['foo'].class
#=> Inquisitive::String
Inquisitive[1].class
#=> Integer

Inquisitive.coerce!('foo').class
#=> Inquisitive::String
Inquisitive.coerce!(1).class
#=> NameError

You can check if any object appears to be present with the Inquisitive presence helper:

Inquisitive.present? 'foo'
#=> true
Inquisitive.present? %i[foo]
#=> true
Inquisitive.present? {foo: :bar}
#=> true
Inquisitive.present? 0
#=> true
Inquisitive.present? true
#=> true

Inquisitive.present? ''
#=> false
Inquisitive.present? Array.new
#=> false
Inquisitive.present? Hash.new
#=> false
Inquisitive.present? false
#=> false
Inquisitive.present? nil
#=> false
Inquisitive.present? Inquisitive::NilClass.new
#=> false

Finally, you can check if any object is explicitly an Inquisitive object with the Inquisitive object helper:

nil_object = nil
Inquisitive.object? nil_object
#=> false
Inquisitive.object? Inquisitive[nil_object]
#=> true
Inquisitive.object? Inquisitive::NilClass.new nil_object
#=> true

string = 'foo'
Inquisitive.object? string
#=> false
Inquisitive.object? Inquisitive[string]
#=> true
Inquisitive.object? Inquisitive::String.new string
#=> true

array = %i[foo]
Inquisitive.object? array
#=> false
Inquisitive.object? Inquisitive[array]
#=> true
Inquisitive.object? Inquisitive::Array.new array
#=> true

hash = {foo: :bar}
Inquisitive.object? hash
#=> false
Inquisitive.object? Inquisitive[hash]
#=> true
Inquisitive.object? Inquisitive::Hash.new hash
#=> true

String

Inquisitive::String tests equality:

environment = Inquisitive::String.new 'development'
#=> "development"
environment.development?
#=> true
environment.not.development?
#=> false

Array

Inquisitive::Array tests inclusion:

supported_databases = Inquisitive::Array.new %w[mysql postgres sqlite]
#=> ["mysql", "postgres", "sqlite"]
supported_databases.postgres?
#=> true
supported_databases.sql_server?
#=> false
supported_databases.exclude.sql_server?
#=> true

Hash

Inquisitive::Hash provides struct-like access to its values, wrapped in other inquisitive objects:

stubbed = Inquisitive::Hash.new(
  authentication: true,
  in: 'development',
  services: %w[database api],
  api: {protocol: 'https', subdomains: %w[app web db]},
  ignorable: { junk: [ '' ] }
)
#=> {"authentication"=>true,
#=>  "in"=>"development",
#=>  "services"=>["database", "api"],
#=>  "api"=>{"protocol"=>"https", "subdomains"=>["app", "web", "db"]},
#=>  "ignorable"=>{"junk"=>[""]}}

stubbed.authentication?
#=> true
stubbed.registration?
#=> false
stubbed.services?
#=> true
stubbed.api?
#=> true
stubbed.ignorable?
#=> false

stubbed.in.development?
#=> true
stubbed.in.production?
#=> false

stubbed.services.database?
#=> true
stubbed.services.sidekiq?
#=> false

stubbed.api.protocol?
#=> true
stubbed.api.protocol.http?
#=> false
stubbed.api.domains.web?
#=> true

Inquisitive::Hash also allows negation with the no method:

config = Inquisitive::Hash.new(database: 'postgres')
#=> {"database"=>"postgres"}

config.database?
#=> true
config.no.database?
#=> false
config.api?
#=> false
config.no.api?
#=> true

Empty keys and nil values become instances of Inquisitive::NilClass, which is a black-hole null object that respects the Inquisitive interface, allowing you to inquire on non-existant nested datastructures as if there was one there, negated methods included:

stubbed = Inquisitive::Hash.new
#=> {}

# We can query it as if we assumed we had:
#=> {"authentication"=>true,
#=>  "in"=>"development",
#=>  "services"=>["database", "api"],
#=>  "api"=>{"protocol"=>"https", "subdomains"=>["app", "web", "db"]}}

stubbed.authentication?
#=> false
stubbed.registration?
#=> false
stubbed.services?
#=> false
stubbed.api?
#=> false
stubbed.ignorable?
#=> false
stubbed.no.ignorable?
#=> true

stubbed.in.development?
#=> false
stubbed.in.production?
#=> false
stubbed.in.not.production?
#=> true

stubbed.services.database?
#=> false
stubbed.services.sidekiq?
#=> false
stubbed.services.exclude.sidekiq?
#=> true

stubbed.api.protocol?
#=> false
stubbed.api.no.protocol?
#=> true
stubbed.api.protocol.http?
#=> false
stubbed.api.domains.web?
#=> false

This custom Inquisitive::NilClass comes with a few caveats, read the section below to understand them.

NilClass

Inquisitive::NilClass is a black-hole null object that respects the Inquisitive interface, allowing you to inquire on non-existant nested datastructures as if there was one there, negated methods included:

nillish = Inquisitive::NilClass.new
#=> nil

nillish.nil?
#=> true
nillish.present?
#=> false


nillish.predicate?
#=> false
nillish.access
#=> nil
nillish.deep.access
#=> nil
nillish.not.access
#=> true
nillish.exclude.access
#=> true
nillish.no.access
#=> true

Be warned: since Ruby doesn't allow subclassing NilClass and provides no boolean-coercion interface, Inquisitive::NilClass will appear truthy. I recommend using built-in predicates (stubbed.authentication? && ...), presence predicates with ActiveSupport (stubbed.authentication.present? && ...), Inquisitive's presence utility (Inquisitive.present?(stubbed.authentication) && ...) or nil predicates (stubbed.authentication.nil? || ...) in boolean chains. Also note that for Inquisitive::Hash access, stubbed.fetch(:authentication, ...) behaves as expected.

Inquisitive Environment

Inquisitive::Environment can be used in your modules and classes to more easily interrogate ENV variables with inquisitive objects:

Strings

ENV['ENVIRONMENT'] = 'development'
class MyApp
  extend Inquisitive::Environment
  inquires_about 'ENVIRONMENT'
end

MyApp.environment
#=> "development"
MyApp.environment.development?
#=> true
MyApp.environment.production?
#=> false

Arrays

Arrays are recognized when environment variables contain commas:

ENV['SUPPORTED_DATABASES'] = 'mysql,postgres,sqlite'
class MyApp
  extend Inquisitive::Environment
  inquires_about 'SUPPORTED_DATABASES'
end

MyApp.supported_databases
#=> ["mysql", "postgres", "sqlite"]
MyApp.supported_databases.sqlite?
#=> true
MyApp.supported_databases.sql_server?
#=> false

Hashes

Hashes are recognized when environment variables names contain double underscores:

ENV['STUB__AUTHENTICATION']  = 'true'
ENV['STUB__IN']              = 'development'
ENV['STUB__SERVICES']        = 'database,api'
ENV['STUB__API__PROTOCOL']   = 'https'
ENV['STUB__API__SUBDOMAINS'] = 'app,web,db'
class MyApp
  extend Inquisitive::Environment
  inquires_about 'STUB'
end

MyApp.stub.authentication?
#=> true
MyApp.stub.registration?
#=> false
MyApp.stub.in.development?
#=> true
MyApp.stub.in.production?
#=> false
MyApp.stub.services.exclude.sidekiq?
#=> true
MyApp.stub.services.sidekiq?
#=> false
MyApp.stub.api.protocol.http?
#=> false
MyApp.stub.api.subdomains.web?
#=> true

Defaults

You can set default values for your environment inquirers.

class MyApp
  extend Inquisitive::Environment
  inquires_about 'ENV', default: 'production'
end

MyApp.env?
#=> true
MyApp.env.production?
#=> true

Naming

You can also give your environment inquirers custom names:

ENV['ENVIRONMENT'] = 'development'
class MyApp
  extend Inquisitive::Environment
  inquires_about 'ENVIRONMENT', with: :env
end

MyApp.env
#=> "development"
MyApp.env.development?
#=> true

MyApp.env.production?
#=> false

Presence

Environment inquirers can have explicit presence checks, circumventing a common pitfall when reasoning about environment variables. Borrowing from the example above:

ENV['STUB__AUTHENTICATION'] = 'false'
class MyApp
  extend Inquisitive::Environment
  inquires_about 'STUB'
end

MyApp.stub.authentication
#=> "false"
MyApp.stub.authentication?
#=> true
MyApp.stub.authentication.true?
#=> false

It's common to use the presence of environment variables as runtime booleans. This is frequently done by setting the environment variable to the string "true" when you want it to be true, and not at all otherwise. As demonstrated, this pattern can lead to ambiguity when the string is other values.

By default such variables will be parsed as an Inquisitive::String, so predicate methods will return true whatever their contents, as long as they exist. You can bind the predicate method tighter to an explicit value if you prefer:

ENV['STUB_AUTHENTICATION'] = 'false'
ENV['STUB_REGISTRATION']   = 'true'
class MyApp
  extend Inquisitive::Environment
  inquires_about 'STUB_AUTHENTICATION', present_if: 'true'
  inquires_about 'STUB_REGISTRATION',   present_if: 'true'
end

MyApp.stub_authentication
#=> "false"
MyApp.stub_authentication?
#=> false

MyApp.stub_registration
#=> "true"
MyApp.stub_registration?
#=> true

This only works on top-level inquirers, so there's no way to get our nested MyApp.stubbed.authentication? to behave as expected. Prefer MyApp.stubbed.authentication.true? instead.

The present_if check uses === under the covers for maximum expressiveness, so you can also use it to match against regexs, classes, and other constructs.

Truthy Strings

Inquisitive::Environment.truthy contains a regex useful for trying to read truthy values from string environment variables.

ENV['NO']        = 'no'
ENV['YES']       = 'yes'
ENV['TRUTHY']    = 'TrUe'
ENV['FALSEY']    = 'FaLsE'
ENV['BOOLEAN']   = '1'
ENV['BOOLENOPE'] = '0'
class MyApp
  extend Inquisitive::Environment
  inquires_about 'NO',        present_if: truthy
  inquires_about 'YES',       present_if: truthy
  inquires_about 'TRUTHY',    present_if: truthy
  inquires_about 'FALSEY',    present_if: truthy
  inquires_about 'BOOLEAN',   present_if: truthy
  inquires_about 'BOOLENOPE', present_if: truthy
end

MyApp.no?
#=> false
MyApp.yes?
#=> true

MyApp.truthy?
#=> true
MyApp.falsey?
#=> false

MyApp.boolean?
#=> true
MyApp.boolenope?
#=> false

Origins

The idea for Inquisitive originated with this pull request, as I was going through a complicated Rails application and making it more 12-factor friendly by extracting much of the configuration into environment variables.

By the end of my effort, my configuration was substantially more centralized and standardized, but far more complicated. These complications arose in two places: adding, managing, permuting, and documenting my .env file consumed by dotenv; and organizing, parsing, and switching on those values injected into the ENV.

My pull request, and later Inquisitive, was extracted from my treatment of the latter complication. My solution to the former, starenv, was a generally more complicated beast.


Contributing

  1. Fork it
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create new Pull Request