mirror of
https://github.com/inspec/inspec
synced 2024-12-03 09:59:26 +00:00
FilterTable Developer Documentation (#3048)
Signed-off-by: Clinton Wolfe <clintoncwolfe@gmail.com>
This commit is contained in:
parent
9f5614e041
commit
b334eb65d9
2 changed files with 764 additions and 0 deletions
316
docs/dev/filtertable-internals.md
Normal file
316
docs/dev/filtertable-internals.md
Normal file
|
@ -0,0 +1,316 @@
|
||||||
|
# Internals of FilterTable
|
||||||
|
|
||||||
|
If you just want to _use_ FilterTable, see filtertable-usage.md . Reading this may make you more confused, not less.
|
||||||
|
|
||||||
|
## What makes this hard?
|
||||||
|
|
||||||
|
The terminology used for many concepts does not help the reader understand what is going on. Additionaly, the ways in which the classes relate is not straightforward. Finally, variable names within the classes are often re-used ('filter' is a favorite) or are too short to be meaningful (x and c are both used as variable names, in long blocks).
|
||||||
|
|
||||||
|
FilterTable was created in 2016 in an attempt to consolidate the pluralization features of several resources. They each had slightly different featuresets, and were all in the wild, so FilterTable exposes some extensive side-effects to provide those features.
|
||||||
|
|
||||||
|
## Where is the code?
|
||||||
|
|
||||||
|
The main FilterTable code is in [utils/filter.rb](https://github.com/chef/inspec/blob/master/lib/utils/filter.rb).
|
||||||
|
|
||||||
|
Also educational is the unit test for Filtertable, at test/unit/utils/filter_table_test.rb
|
||||||
|
|
||||||
|
The file utils/filter_array.rb appears to be unrelated.
|
||||||
|
|
||||||
|
## What are the classes involved?
|
||||||
|
|
||||||
|
### FilterTable::Factory
|
||||||
|
|
||||||
|
This class is responsible for the definition of the filtertable. It provides the methods that are used by the resource author to configure the filtertable.
|
||||||
|
|
||||||
|
FilterTable::Factory initializes three instance variables:
|
||||||
|
```
|
||||||
|
@accessors = []
|
||||||
|
@connectors = {}
|
||||||
|
@resource = nil
|
||||||
|
```
|
||||||
|
|
||||||
|
### FilterTable::Table
|
||||||
|
|
||||||
|
This is the actual innards of the implementation. The Factory's goal is to configure a Table sublcass and attach it to the resource you are authoring. The table is a container for the raw data your resource provides, and performs filtration services.
|
||||||
|
|
||||||
|
### FilterTable::ExceptionCatcher
|
||||||
|
|
||||||
|
TODO
|
||||||
|
|
||||||
|
## What are the major entry points? (FilterTable::Factory)
|
||||||
|
|
||||||
|
A resource class using FilterTable typically will call a sequence similar to this, in the class body:
|
||||||
|
|
||||||
|
```
|
||||||
|
filter = FilterTable.create
|
||||||
|
filter.add_accessor(:entries)
|
||||||
|
.add(:exists?) { |x| !x.entries.empty? }
|
||||||
|
.add(:thing_ids, field: :thing_id)
|
||||||
|
filter.connect(self, :table)
|
||||||
|
```
|
||||||
|
|
||||||
|
Each of those calls supports method chaining.
|
||||||
|
|
||||||
|
### create
|
||||||
|
|
||||||
|
Returns a blank instance of a FilterTable::Factory.
|
||||||
|
|
||||||
|
### add\_accessor
|
||||||
|
|
||||||
|
Suggested alternate name: register_chainable_filter_method(:new_method_name)
|
||||||
|
|
||||||
|
This simply pushes the provided method name onto the `@accessors` instance variable array. See "accessor" behavior section below for what this does.
|
||||||
|
|
||||||
|
After adding the method name to the array, it returns `self` - the FilterTable::Factory instance - so that method chaining will work.
|
||||||
|
|
||||||
|
### add
|
||||||
|
|
||||||
|
Suggested alternate name 1: register_property_or_matcher_and_filter_criterion
|
||||||
|
Suggested alternate name 2: register_connector
|
||||||
|
|
||||||
|
This is one of the most confusingly named methods. `add` requires a symbol (which will be used as a method name _to be added to the resource class_), then also accepts a block and/or additional args. These things - name, block, and opts - are packed into a simple struct called a Connector. The name stored in the struct will be `opts[:field]` if provided, and the method name if not.
|
||||||
|
|
||||||
|
The Connector struct is then appended to the Hash `@connectors`, keyed on the method name provided. `self` is then returned for method chaining.
|
||||||
|
|
||||||
|
#### Behavior when a block is provided
|
||||||
|
|
||||||
|
This behavior is implemented by line 256.
|
||||||
|
|
||||||
|
If a block is provided, it is turned into a Lambda and used as the method body.
|
||||||
|
|
||||||
|
The block will be provided two arguments (though most users only use the first):
|
||||||
|
1. The FilterTable::Table instance that wraps the raw data.
|
||||||
|
2. An optional value used as an additional opportunity to filter.
|
||||||
|
|
||||||
|
For example, this is common:
|
||||||
|
```
|
||||||
|
filter.add(:exists?) { |x| !x.entries.empty? }
|
||||||
|
```
|
||||||
|
|
||||||
|
Here, `x` is the Table instance, which exposes the `entries` method (which returns an array, one entry for each raw data row).
|
||||||
|
|
||||||
|
You could also implement a more sophisticated property, which semantically should re-filter the table based on the candidate value, and return the new table.
|
||||||
|
|
||||||
|
```
|
||||||
|
filter.add(:smaller_than) { |table, threshold| table.where { some_field <= threshold } }
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
things.smaller_than(12)
|
||||||
|
```
|
||||||
|
|
||||||
|
If you provide _both_ a block and opts, only the block is used, and the options are ignored.
|
||||||
|
|
||||||
|
#### Behavior when no block is provided
|
||||||
|
|
||||||
|
If you do not provide a block, you _must_ provide a `:field` option (though that does no appear to be enforced). The behavior is to define a method with the name provided, that has a conditional return type. The method body is defined in lines 258-266.
|
||||||
|
|
||||||
|
If called without arguments, it returns an array of the values in the raw data for that column.
|
||||||
|
```
|
||||||
|
things.thing_ids => [1,2,3,4]
|
||||||
|
```
|
||||||
|
|
||||||
|
If called with an argument, it instead calls `where` passing the name of the field and the argument, effectively filtering.
|
||||||
|
```
|
||||||
|
things.thing_ids(2) => FilterTable::Table that only contains a row where thing_id = 2
|
||||||
|
```
|
||||||
|
|
||||||
|
If called with a block, it passes the block to where.
|
||||||
|
```
|
||||||
|
things.thing_ids { some_code } => Same as things.where { some_code }
|
||||||
|
```
|
||||||
|
|
||||||
|
POSSIBLE BUG: I think this case is broken; it certainly seems ill-advised.
|
||||||
|
|
||||||
|
#### Known Options
|
||||||
|
|
||||||
|
You can provide options to `add`, after the desired method name.
|
||||||
|
|
||||||
|
##### field
|
||||||
|
|
||||||
|
This is the most common option. It selects an implementation in which the desired method will be defined such that it returns an array of the row values using the specified key. In other words, this acts as a "column fetcher", like in SQL: "SELECT some_column FROM some_table"
|
||||||
|
|
||||||
|
Internally, (line 195-200), a Struct type is created to repressent a row of raw data. The struct's attribute list is taken from the `field` options passed to `add`.
|
||||||
|
|
||||||
|
* No checking is performed to see if the field name is actually a column in the raw data (the raw data hasn't been fetched yet, so we can't check).
|
||||||
|
* You can't have two `add` calls that reference the same field, because the Struct would see that as a duplicate attribute.
|
||||||
|
|
||||||
|
POSSIBLE BUG: We could deduplicate the field names when defining the Struct, thus allowing multiple properties to use the same field.
|
||||||
|
|
||||||
|
##### type
|
||||||
|
|
||||||
|
`type: :simple` has been seen in the wild. When you call the method like `things.thing_ids => Array`, this has the very useful effect of flattening and uniq'ing the returned array.
|
||||||
|
|
||||||
|
No other values for `:type` have been seen.
|
||||||
|
|
||||||
|
### connect
|
||||||
|
|
||||||
|
Suggested alternate name: install_filtertable
|
||||||
|
|
||||||
|
This method is called like this:
|
||||||
|
|
||||||
|
```
|
||||||
|
filter.connect(self, :data_fetching_method_name)
|
||||||
|
```
|
||||||
|
|
||||||
|
`filter` is an instance of FilterTable::Factory. `self` is a reference to the resource class you are authoring. `data_fetching_method_name` is a symbol, the name of a methdo that will return the actual data to be processed by the FilterTable - as an array of hashes.
|
||||||
|
|
||||||
|
Note that 'connect' does not refer to Connectors.
|
||||||
|
|
||||||
|
`add` and `add_accessor` did nothing other than add register names for methods that we'd like to have added to the resource class. No filtering ability is present, nor are the methods defined, at this point.
|
||||||
|
|
||||||
|
So, `connect`'s job is to actually install everything.
|
||||||
|
|
||||||
|
#### Re-pack the "connectors"
|
||||||
|
|
||||||
|
First, on lines 188-192, the list of custom methods ("connectors", registered using the `add` method) are repacked into an array of arrays of two elements - the desired method name and the lambda that will be used as the method body. The lambda is created by the private method `create_connector`.
|
||||||
|
|
||||||
|
TBD: what exactly create_connector does
|
||||||
|
|
||||||
|
#### Defines a special Struct type to represent rows in the table
|
||||||
|
|
||||||
|
At lines 195-200, a new Struct type is defined, with attributes for each of the known table fields. The motivation for this struct type is to implement the block-mode behavior of `where`. Because each struct represents a row, and it has the attributes (accessors) for the fields, block-mode `where` is implemented by instance-evaling against each row as a struct.
|
||||||
|
|
||||||
|
Additionally, an instanace variable, `@__filter` is defined, with an accessor(!). (That's really wierd - double-underscore usually means "intended to be private"). `to_s` is implemented, using `@__filter`, or `super` if not defined. I guess we then rely on the `Struct` class to stringify?
|
||||||
|
|
||||||
|
I think `@__filter` is a trace - a string indicating the filter criteria used to create the table. I found no location where this per-row trace data was used.
|
||||||
|
|
||||||
|
CONFUSING NAME: `@__filter` meaning a trace of criteria operations is very confusing - the word "filter" is very overloaded.
|
||||||
|
|
||||||
|
Table fields are determined by listing the `field_name`s of the Connectors.
|
||||||
|
|
||||||
|
BUG: this means that any `add` call that uses a block but not options will end up with an attribute in the row Struct. Thus, `filter.add(:exists?) { ... }` results in a row Struct that includes an attribute named `exists?` which may be undesired.
|
||||||
|
|
||||||
|
POSSIBLE MISFEATURE: Defining a Struct for rows means that people who use `entries` (or other data accessors) interact with something unusual. The simplest possible thing would be an Array of Hashes. There is likely something relying on this...
|
||||||
|
|
||||||
|
#### Subclass FilterTable::Table into an anonymous class
|
||||||
|
|
||||||
|
At line 203, create the local var `table`, which refers to an anonymous class that subclasses FilterTable::Table. The class is opened and two groups of methods are defined.
|
||||||
|
|
||||||
|
Lines 204-206 install the "connector" methods, using the names and lambdas determined on line 188.
|
||||||
|
|
||||||
|
Line 208-213 define a method, `new_entry`. Its job is to append a row to the FilterTable::Table as a row Struct, given a plain Hash (presumably as provided by the data fetching method) and a String piece of tracking information (again, confusingly referred to as "filter").
|
||||||
|
|
||||||
|
#### Install methods on the resource
|
||||||
|
|
||||||
|
Lines 216-232 install the data table accessors and "connector" methods onto the resource that you are authoring.
|
||||||
|
|
||||||
|
Line 222-223 collects the names of the methods to define - by agglomerating the names of the data table accessors and "connector" methods. They are treated the same.
|
||||||
|
|
||||||
|
Line 224 uses `send` with a block to call `define_method` on the resource class that you're authoring. Using a block with `send` is undocumented, but is treated as an implicit argument (per stackoverflow) , so the end result is that the block is used as the body for the new method being defined.
|
||||||
|
|
||||||
|
The method body is wrapped in an exception-catching facility that catches skipped or failed resource excepytions and wraps them in a specialized excepytion catcher class. TBD: understand this better.
|
||||||
|
|
||||||
|
Line 226 constructs an instance of the anonymous FilterTable::Table subclass defined at 203. It passes three args:
|
||||||
|
|
||||||
|
1. `self`. TBD: which class is this referring to at this point?
|
||||||
|
2. The return value of calling the data fetcher method.
|
||||||
|
3. The string ' with', which is probably informing the criteria stringification. The extra space is intentional, as it follows the resource name: 'my_things with color == :red' might be a result.
|
||||||
|
|
||||||
|
And that new FilterTable::Table subclass instance is stored in a local variable, named, confusingly, "filter".
|
||||||
|
|
||||||
|
On line 227, we then immediately call a method on that "FilterTable::Table subclass instance". The method name is the same as the one we're defining on the resource - but we're calling it on the Table. Recall we defined all the "connector" methods on the Table subclass at line 204-206. The method gets called with any args or block passed, and since it's the last thing, it provides the return value.
|
||||||
|
|
||||||
|
VERY WORRISOME THING: So, the Table subclass has methods for the "connectors" (for example, `thing_ids` or `exist?`. What about the "accessors" - `where` and `entries`? Are those in the FilterTable::Table class, or method_missing'd?
|
||||||
|
|
||||||
|
## What is its behavior? (FilterTable::Table)
|
||||||
|
|
||||||
|
Assume that your resource has a method, `fetch_data`, which returns a fixed array:
|
||||||
|
|
||||||
|
```
|
||||||
|
[
|
||||||
|
{ id: 1, name: 'Dani', color: 'blue' },
|
||||||
|
{ id: 2, name: 'Mike', color: 'red' },
|
||||||
|
{ id: 3, name: 'Erika', color: 'green' },
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Assume that you then perform this sequence in your resource class body:
|
||||||
|
```
|
||||||
|
filter = FilterTable.create
|
||||||
|
filter.add_accessor(:entries)
|
||||||
|
filter.add_accessor(:where)
|
||||||
|
filter.add(:exists?) { |x| !x.exists.empty? }
|
||||||
|
filter.add(:names, field: :name)
|
||||||
|
filter.connect(self, :fetch_data)
|
||||||
|
```
|
||||||
|
|
||||||
|
We know from the above exploration of `connect` that we now have several new methods on the resource class, all of which delegate to the FilterTable::Table implementation.
|
||||||
|
|
||||||
|
### FilterTable::Table constructor and internals
|
||||||
|
|
||||||
|
Factory calls the FilterTable::Table constructor at 142-144 with three args. Table stores them into instance vars:
|
||||||
|
* @resource - this was passed in as `self`; I think this would be the resource class
|
||||||
|
* @params - the raw table data. (confusing name)
|
||||||
|
* @filters - This looks to be stringification trace data; the string ' with' was passed in by Factory.
|
||||||
|
|
||||||
|
params and filters get `attr_reader`s.
|
||||||
|
|
||||||
|
### `entries` behavior
|
||||||
|
|
||||||
|
From usage, I expect entries to return a structure that resembles an array of hashes representing the (filtered) data.
|
||||||
|
|
||||||
|
#### A new method `entries` is defined on the resource class
|
||||||
|
|
||||||
|
That is performed by Factory#connect line 224.
|
||||||
|
|
||||||
|
#### It delegates to FilterTable::Table#entries
|
||||||
|
|
||||||
|
This is a real method defined in filter.rb line 120.
|
||||||
|
|
||||||
|
It loops over the provided raw data (@params) and builds an array, calling `new_entry` (see Factory 208-213) on each row; also appending a stringification trace to each entry. The array is returned.
|
||||||
|
|
||||||
|
#### `entries` conclusion
|
||||||
|
|
||||||
|
Not Surprising: It does behave as expected - an array of hashlike structs representing the table. I don't know why it adds in the per-row strigification data - I've never seen that used.
|
||||||
|
|
||||||
|
Surprising: this is a real method with a concrete implementation. That means that you can't call `filter.add_accessor` with arbitrary method names - `:entries` means something very specific.
|
||||||
|
|
||||||
|
### `where` behavior
|
||||||
|
|
||||||
|
From usage, I expect this to take either method params or a block (both of which are magical), perform filtering, and return some object that contains only the filtered rows.
|
||||||
|
|
||||||
|
So, what happens when you call `add_accessor(:where)` and then call `resource.where`?
|
||||||
|
|
||||||
|
#### A new method `where` is defined on the resource class
|
||||||
|
|
||||||
|
That is performed by Factory#connect line 224.
|
||||||
|
|
||||||
|
#### It delegates to FilterTable::Table#where
|
||||||
|
|
||||||
|
Like `entries`, this is a real implemented method on FilterTable::Table, at line 93.
|
||||||
|
|
||||||
|
The method accepts all params as the local var `conditions` which defaults to an empty Hash. A block, if any, is also explicitly assigned the name `block`.
|
||||||
|
|
||||||
|
The implementation opens with two guard clauses, both of which will return `self` (which is the FilterTable::Table subclass instance).
|
||||||
|
|
||||||
|
MISFEATURE: The first guard clause simply returns the Table if `conditions` is not a Hash. That would mean that someone called it like: `thing.where(:apples, :bananas, :canteloupes)`. That misuse is silently ignored; I think we should probably throw a ResourceFailed or something.
|
||||||
|
|
||||||
|
The second guard clause is a sensible degenerate case - return the existing Table if there are no conditions and no block. So `thing.where` is OK.
|
||||||
|
|
||||||
|
Line 97 initializes a local var, `filters`, which again is a stringification tracker.
|
||||||
|
|
||||||
|
Line 98 confuses things further. A local var `table` is initialized to the value of instance var `@params`. The naming here is poorly chosen - @params is the initial raw data (and it has an attr_reader, no need for the @); and `table` is the new _raw data_ - not a new FilterTable class or instance.
|
||||||
|
|
||||||
|
Lines 99-102 loop over the provided Hash `conditions`. It repeatedly downfilters `table` by calling the private method `filter_lines` on it. `filter_lines` does some syntactic sugaring for common types, Ints and Floats and Regexp matching. Additionally, the 99-102 loop builds up the stringification tracker, `filter`, by stringifying the field name and target value.
|
||||||
|
|
||||||
|
BUG: (Issue 2943) - Lines 99-102 do not validate the names of the provided fields.
|
||||||
|
|
||||||
|
Line 104-109 begins work if a filtration block has been provided. At this point, `table` has been initialized with the raw data, and (if method params were provided) has also been filtered down.
|
||||||
|
|
||||||
|
Line 105 filters the rows of the raw data using an odd approach: each row is evaluated using `Array#find_all`, with the block `{ |e| new_entry(e, '').instance_eval(&block) }` `new_entry` wraps each raw row in a Struct (the `''` indicates we're not trying to save stringification data here). (Recall that new_entry was defined by `FilterTable::Factory#connect`, line 195). Then the provided block is `instance_eval`'d against the Struct. Because the Struct was defined with attributes (that is, accessor methods) for each declared field name (from FilterTable::Factory#add), you can use field names in the block, and each row-as-struct will be able to respond.
|
||||||
|
|
||||||
|
_That just explained a major spooky side-effect for me._
|
||||||
|
|
||||||
|
Lines 106-108 do something with stringification tracing. TODO.
|
||||||
|
|
||||||
|
Finally, at line 111, the FilterTable::Table anonymous subclass is again used to construct a new instance, passing on the resource reference, the newly filtered raw data table, and the newly adjusted stringificatioon tracer.
|
||||||
|
|
||||||
|
That new Table instance is returned, and thus `where` allows you to chain.
|
||||||
|
|
||||||
|
#### `where` conclusion
|
||||||
|
|
||||||
|
Unsurprising: How where works with method params.
|
||||||
|
Surprising: How where works in block mode, instance_eval'ing against each row-as-Struct.
|
||||||
|
Surprising: You can use method-mode and block-mode together if you want.
|
||||||
|
Problematic: Many confusing variable names here. A lot of clarity could be gained by simple search and replace.
|
448
docs/dev/filtertable-usage.md
Normal file
448
docs/dev/filtertable-usage.md
Normal file
|
@ -0,0 +1,448 @@
|
||||||
|
# Using FilterTable to write a Resource
|
||||||
|
|
||||||
|
## When do I use FilterTable?
|
||||||
|
|
||||||
|
FilterTable is intended to help you author "plural" resources.
|
||||||
|
|
||||||
|
Plural resources examine platform objects in bulk. For example, sorting through which packages are installed on a system, or which virtual machines are on a cloud provider. You don't know the identifiers of the objects, but you may know some of their properties, and you may want to filter the objects based on those - for example, all processes running more than an hour, or all VMs on a particular subnet.
|
||||||
|
|
||||||
|
Singular resources, in contrast, are designed to examine a particular platform object in detail, _when you know an identifier_. For example, you would use a singular resource to fetch a VM by its ID, then interrogate its networking configuration. Singular resources are able to provide richer properties and matchers than plural resources, because the semantics are clearer.
|
||||||
|
|
||||||
|
If you can't tell if the resource you are authoring is singular or plural, STOP and consult with a team member. This is a fundamental design question, and while we have had some resources that "straddle the fence" in the past, they are very difficult to use and maintain.
|
||||||
|
|
||||||
|
### Should I use FilterTable to represent list properties?
|
||||||
|
|
||||||
|
Suppose you have a person, and you want to represent that person's shoes. Should you use FilterTable for that?
|
||||||
|
|
||||||
|
NO. FilterTable is intended to represent pluralities inherent to the resource itself, not a property of the resource. So, you would use FilterTable to represent _people_. To represent shoes, you could have a simple, dumb array-of-strings property on Person. Or, you could create a new resource, Shoe, or Shoes, which has a person_name or person_id property. Or expose a complex structure as a low-level property, and create mid-level properties/matchers that compute on the values internally (`shoe_fit?`, `has_shoes_for_occasion?('red_carpet')`)
|
||||||
|
|
||||||
|
### May I have multiple FilterTable installations on a class?
|
||||||
|
|
||||||
|
In theory, yes - that would be used to implement different data fetching / caching strategies. It is a very advanced usage, and no core resources currently do this, as far as I know.
|
||||||
|
|
||||||
|
## Example Usage
|
||||||
|
|
||||||
|
Suppose you are writing a resource, `things`. You want it to behave like any plural resource (we'll explore what that means in a moment). That is the basic expected behavior of any plural resource.
|
||||||
|
|
||||||
|
### How do I declare my interaction with FilterTable?
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
|
||||||
|
require 'utils/filter'
|
||||||
|
|
||||||
|
class Thing < Inspec.resource(1)
|
||||||
|
#... other Resource DSL work goes here ...
|
||||||
|
|
||||||
|
# FilterTable setup
|
||||||
|
filter_table_config = FilterTable.create
|
||||||
|
filter_table_config.add_accessor(:where)
|
||||||
|
filter_table_config.add_accessor(:entries)
|
||||||
|
filter_table_config.add(:exist?) { |filter_table| !filter_table.entries.empty? }
|
||||||
|
filter_table_config.add(:count) { |filter_table| filter_table.entries.count }
|
||||||
|
filter_table_config.add(:thing_ids, field: :thing_id)
|
||||||
|
filter_table_config.add(:colors, field: :color, type: :simple)
|
||||||
|
filter_table_config.connect(self, :fetch_data)
|
||||||
|
|
||||||
|
def fetch_data
|
||||||
|
# This method should return an array of hashes - the raw data. We'll hardcode it here.
|
||||||
|
[
|
||||||
|
{ thing_id: 1, color: :red },
|
||||||
|
{ thing_id: 2, color: :blue, tackiness: 'very' },
|
||||||
|
{ thing_id: 3, color: :red },
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
def some_other_property
|
||||||
|
# We'll examine this later
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that all of the methods on `filter_table_config` support chaining, so you will sometimes see it as:
|
||||||
|
```ruby
|
||||||
|
filter_table_config.add_accessor(:where)
|
||||||
|
.add_accessor(:entries)
|
||||||
|
.add(:exist?) { |filter_table| !filter_table.entries.empty? }
|
||||||
|
```
|
||||||
|
etc.
|
||||||
|
|
||||||
|
## Standard behavior
|
||||||
|
|
||||||
|
With a (fairly standard) implementation like that above, what behavior do you get out of the box?
|
||||||
|
|
||||||
|
### Your class is still just a Resource class
|
||||||
|
|
||||||
|
Nothing special immediately happens to your class or instances of it. The data fetcher is not called yet.
|
||||||
|
|
||||||
|
### Instances of your class can create a specialized FilterTable::Table instance
|
||||||
|
|
||||||
|
When most of the following methods are called, it may trigger the instantiation of a FilterTable::Table anonymous subclass. That instance will have called the raw data fetcher, and will wrap the raw data inside it. Many of the following methods return the Table instance.
|
||||||
|
|
||||||
|
### A `where` method you can call with nil to get the Table
|
||||||
|
|
||||||
|
The resource class gains a method, `where`. If called with a single `nil` param or no params, it will call the data fetcher method, wrap it up, and return the Table instance. Calling `where` in other modes will do the same thing, but will filter the data.
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
describe things.where(nil)
|
||||||
|
it { should exist }
|
||||||
|
its('count') { should cmp 3 }
|
||||||
|
end
|
||||||
|
|
||||||
|
# This works, too, but for different internal reasons
|
||||||
|
describe things.where
|
||||||
|
it { should exist }
|
||||||
|
its('count') { should cmp 3 }
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### A `where` method you can call with hash params, with loose matching
|
||||||
|
|
||||||
|
If you call `where` as a method with no block and passing hash params, with keys you know are in the raw data, it will fetch the raw data, then filter row-wise and return the resulting Table.
|
||||||
|
|
||||||
|
Multiple criteria are joined with a logical AND.
|
||||||
|
|
||||||
|
The filtering is fancy, not just straight equality.
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
describe things.where(color: :red) do
|
||||||
|
its('count') { should cmp 2 }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Regexes
|
||||||
|
describe things.where(color: /^re/) do
|
||||||
|
its('count') { should cmp 2 }
|
||||||
|
end
|
||||||
|
|
||||||
|
# It eventually falls out to === comparison
|
||||||
|
# Here, range membership 1..2
|
||||||
|
describe things.where(thing_id: (1..2)) do
|
||||||
|
its('count') { should cmp 2 }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Things that don't exist are silently ignored, but do not match
|
||||||
|
# See https://github.com/chef/inspec/issues/2943
|
||||||
|
describe things.where(none_such: :nope) do
|
||||||
|
its('count') { should cmp 0 }
|
||||||
|
end
|
||||||
|
|
||||||
|
# irregular rows are supported
|
||||||
|
# Only one row has the :tackiness key, with value 'very'.
|
||||||
|
describe things.where(tackiness: 'very') do
|
||||||
|
its('count') { should cmp 1 }
|
||||||
|
end
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### A `where` method you can call with a block, referencing some fields
|
||||||
|
|
||||||
|
You can also call the `where` method with a block. The block is executed row-wise. If it returns truthy, the row is included in the results. Additionally, within the block each field declared with the `add` configuration method is available as a data accessor.
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
|
||||||
|
# You can have any logic you want in the block
|
||||||
|
describe things.where { true } do
|
||||||
|
its('count') { should cmp 3 }
|
||||||
|
end
|
||||||
|
|
||||||
|
# You can access any field you declared using `add`
|
||||||
|
describe things.where { thing_id > 2 } do
|
||||||
|
its('count') { should cmp 1 }
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### You can chain off of `where` or any other Table without re-fetching raw data
|
||||||
|
|
||||||
|
The first time `where` is called, the data fetcher method is called. `where` performs filtration on the raw data table. It then constructs a new FilterTable::Table, directly passing in the filtered raw data; this is then the return value from `where`.
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# This only calls fetch_data once
|
||||||
|
describe things.where(color: :red).where { thing_id > 2 } do
|
||||||
|
its('count') { should cmp 1 }
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
Some other methods return a Table object, and they may be chained without a re-fetch as well.
|
||||||
|
|
||||||
|
### An `entries` method that will return an array of Structs
|
||||||
|
|
||||||
|
The other `add_accessor` call enables a pre-defined method, `entries`. `entries` is much simpler than `where` - in fact, its behavior is unrelated. It returns an encapsulated version of the raw data - a plain array, containing Structs as row-entries. Each struct has an attribute for each time you called `add`.
|
||||||
|
|
||||||
|
Overall, in my opinion, `entries` is less useful than `params` (which returns the raw data). Wrapping in Structs does not seem to add much benefit.
|
||||||
|
|
||||||
|
Importantly, note that the return value of `entries` is not the resource, nor the Table - in other words, you cannot chain it. However, you can call `entries` on any Table.
|
||||||
|
|
||||||
|
If you call `entries` without chaining it after `where`, calling entries will trigger the call to the data fetching method.
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
|
||||||
|
# Access the entries array
|
||||||
|
describe things.entries do
|
||||||
|
# This is Array#count, not the resource's `count` method
|
||||||
|
its('count') { should cmp 3}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Access the entries array after chaining off of where
|
||||||
|
describe things.where(color: :red).entries do
|
||||||
|
# This is Array#count, not the resource's or table's `count` method
|
||||||
|
its('count') { should cmp 2}
|
||||||
|
end
|
||||||
|
|
||||||
|
# You can access the struct elements as a method, as a hash keyed on symbol, or as a hash keyed on string
|
||||||
|
describe things.entries.first.color do
|
||||||
|
it { should cmp :red }
|
||||||
|
end
|
||||||
|
describe things.entries.first[:color] do
|
||||||
|
it { should cmp :red }
|
||||||
|
end
|
||||||
|
describe things.entries.first['color'] do
|
||||||
|
it { should cmp :red }
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### You get an `exist?` matcher defined on the resource and the table
|
||||||
|
|
||||||
|
This `add` call:
|
||||||
|
```ruby
|
||||||
|
filter_table_config.add(:exist?) { |filter_table| !filter_table.entries.empty? }
|
||||||
|
```
|
||||||
|
|
||||||
|
causes a new method to be defined on both the resource class and the Table class. The body of the method is taken from the block that is provided. When the method it called, it will recieve the FilterTable::Table instance as its first parameter. (It may also accept a second param, but that doesn't make sense for this method - see thing_ids).
|
||||||
|
|
||||||
|
As when you are implementing matchers on a singular resource, the only thing that distinuishes this as a matcher is the fact that it ends in `?`.
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# Bare call on the matcher (called as a method on the resource)
|
||||||
|
describe things do
|
||||||
|
it { should exist }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Chained on where (called as a method on the Table)
|
||||||
|
describe things.where(color: :red) do
|
||||||
|
it { should exist }
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### You get an `count` property defined on the resource and the table
|
||||||
|
|
||||||
|
This `add` call:
|
||||||
|
```ruby
|
||||||
|
filter_table_config.add(:count) { |filter_table| filter_table.entries.count }
|
||||||
|
```
|
||||||
|
|
||||||
|
causes a new method to be defined on both the resource class and the Table class. As with `exists?`, the body is taken from the block.
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# Bare call on the property (called as a method on the resource)
|
||||||
|
describe things do
|
||||||
|
its('count') { should cmp 3 }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Chained on where (called as a method on the Table)
|
||||||
|
describe things.where(color: :red) do
|
||||||
|
its('count') { should cmp 2 }
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### A `thing_ids` method that will return an array of plain values when called without params
|
||||||
|
|
||||||
|
This `add` call:
|
||||||
|
```ruby
|
||||||
|
filter_table_config.add(:thing_ids, field: :thing_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
will cause a method to be defined on both the resource and the Table. Note that this `add` call does not provide a block; so FilterTable::Factory generates a method body. The `:field` option specifies which column to access in the raw data (that is, which hash key in the array-of-hashes).
|
||||||
|
|
||||||
|
The implementation provided by Factory changes behavior based on calling pattern. If no params or block is provided, a simple array is returned, containing the column-wise values in the raw data.
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
|
||||||
|
# Use it to check for presence / absence of a member
|
||||||
|
# This retains nice output formatting - we're testing on a Table associated with a Things resource
|
||||||
|
describe things.where(color: :red) do
|
||||||
|
its('thing_ids') { should include 3 }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Equivalent but with poor formatting - we're testing an anonymous array
|
||||||
|
describe things.where(color: :red).thing_ids do
|
||||||
|
it { should include 3 }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Use as a test-less enumerator
|
||||||
|
things.where(color: :red).thing_ids.each do |thing_id|
|
||||||
|
# Do something with thing_id, maybe
|
||||||
|
# describe thing(thing_id) do ...
|
||||||
|
end
|
||||||
|
|
||||||
|
# Can be used without where - enumerates all Thing IDs with no filter
|
||||||
|
things.thing_ids.each do |thing_id|
|
||||||
|
# Do something with thing_id, maybe
|
||||||
|
# describe thing(thing_id) do ...
|
||||||
|
end
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### A `colors` method that will return a flattened and uniq'd array of values
|
||||||
|
|
||||||
|
This method behaves just like `thing_ids`, except that it returns the values of the `color` column. In addition, the `type: :simple` option causes it to flatten and uniq the array of values when called without args or a block.
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# Three rows in the data: red, blue, red
|
||||||
|
describe things.colors do
|
||||||
|
its('count') { should cmp 2 }
|
||||||
|
it { should include :red }
|
||||||
|
it { should include :blue }
|
||||||
|
end
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### A `colors` method that can filter on a value and return a Table
|
||||||
|
|
||||||
|
You also get this for `thing_ids`. This is unrelated to `type: :simple` for `colors`.
|
||||||
|
|
||||||
|
People definitely use this in the wild. It reads badly to me; I think this is a legacy usage that we should consider deprecating. To me, this seems to imply that there is a sub-resource (here, colors) we are auditing.
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# Filter on colors
|
||||||
|
describe things.colors(:red) do
|
||||||
|
its('count') { should cmp 2 }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Same, but doesn't imply we're now operating on some 'color' resource
|
||||||
|
describe things.where(color: :red) do
|
||||||
|
its('count') { should cmp 2 }
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### A `colors` method that can filter on a block and return a Table
|
||||||
|
|
||||||
|
You also get this for `thing_ids`. This is unrelated to `type: :simple` for `colors`.
|
||||||
|
|
||||||
|
I haven't seen this used in the wild, but its existence gives me a headache.
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# Example A, B, C, and D are semantically the same
|
||||||
|
|
||||||
|
# A: Filter both on colors and the block
|
||||||
|
describe things.colors(:red) { thing_id < 2 } do
|
||||||
|
its('count') { should cmp 1 }
|
||||||
|
its('thing_ids') { should include 1 }
|
||||||
|
end
|
||||||
|
|
||||||
|
# B use one where block
|
||||||
|
describe things.where { color == :red && thing_id < 2 } do
|
||||||
|
its('count') { should cmp 1 }
|
||||||
|
its('thing_ids') { should include 1 }
|
||||||
|
end
|
||||||
|
|
||||||
|
# C use two where blocks
|
||||||
|
describe things.where { color == :red }.where { thing_id < 2 } do
|
||||||
|
its('count') { should cmp 1 }
|
||||||
|
its('thing_ids') { should include 1 }
|
||||||
|
end
|
||||||
|
|
||||||
|
# D use a where param and a where block
|
||||||
|
describe things.where(color: :red) { thing_id < 2 } do
|
||||||
|
its('count') { should cmp 1 }
|
||||||
|
its('thing_ids') { should include 1 }
|
||||||
|
end
|
||||||
|
|
||||||
|
# This has nothing to do with colors at all, and may be broken - the lack of an arg to `colors` may make it never match
|
||||||
|
describe things.colors { thing_id < 2 } do
|
||||||
|
its('count') { should cmp 1 }
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### You can call `params` on any Table to get the raw data
|
||||||
|
|
||||||
|
People _definitely_ use this out in the wild. Unlike `entries`, which wraps each row in a Struct and omits undeclared fields, `params` simply returns the actual raw data array-of-hashes. It is not `dup`'d.
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
tacky_things = things.where(color: :blue).params.select { |row| row[:tackiness] }
|
||||||
|
tacky_things.map { |row| row[:thing_id] }.each do |thing_id|
|
||||||
|
# Use to audit a singular Thing
|
||||||
|
describe thing(thing_id) do
|
||||||
|
it { should_not be_paisley }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### You can call `resource` on any Table to get the resource instance
|
||||||
|
|
||||||
|
You could use this to do something fairly complicated.
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
describe things.where do # Just getting a Table object
|
||||||
|
its('resource.some_method') { should cmp 'some_value' }
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
However, the resource instance won't know about the filtration, so I'm not sure what good this does. Chances are, someone is doing something horrid using this feature in the wild.
|
||||||
|
|
||||||
|
## Gotchas and Surprises
|
||||||
|
|
||||||
|
### Methods defined with `add` will change their return type based on their call pattern
|
||||||
|
|
||||||
|
To me, calling things.thing_ids should always return the same type of value. But if you call it with args or a block, it not only runs a filter, but also changes its return type to Table.
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
|
||||||
|
# This is an Array of color values (symbols, here)
|
||||||
|
things.colors
|
||||||
|
|
||||||
|
# This is a FilterTable::Table and these are equivalent
|
||||||
|
things.colors(:red)
|
||||||
|
things.where(color: :red)
|
||||||
|
|
||||||
|
# This is a FilterTable::Table and these are equivalent
|
||||||
|
things.colors { color == :red } # I think there is a bug here which makes this never match
|
||||||
|
things.where(color: :red)
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### `entries` will not have fields present in the raw data
|
||||||
|
|
||||||
|
`entries` will only know about the fields declared by `add` with `field:`. And...
|
||||||
|
|
||||||
|
### `entries` will have things that are not fields
|
||||||
|
|
||||||
|
Each time you call `add` - even for things like `count` and `exists?` - that will add an attribute to the Struct that is used to represent a row. Those attributes will always be nil.
|
||||||
|
|
||||||
|
### `add` does not know about what fields are in the raw data
|
||||||
|
|
||||||
|
This is because the raw data fetcher is not called until as late as possible. That's good - it might be expensive - but it also means we can't scan it for columns. There are ways around that.
|
||||||
|
|
||||||
|
### `where` param-mode and raw data access is sensitive to symbols vs strings
|
||||||
|
|
||||||
|
### You can't call resource methods on a Table directly
|
||||||
|
|
||||||
|
### You can't use a column name in a `where` block unless it was declared as a field using `add`
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# This will give a NameError - :tackiness is in the raw
|
||||||
|
# data hash but not declared using `add`.
|
||||||
|
describe things.where { tackiness == 'very' } do
|
||||||
|
its('count') { should cmp 1 }
|
||||||
|
end
|
||||||
|
# NameError: undefined local variable or method `tackiness' for #<struct :exists?=nil, count=nil, id=nil>
|
||||||
|
|
||||||
|
# But this works:
|
||||||
|
describe things.where(tackiness: 'very') do
|
||||||
|
its('count') { should cmp 1 }
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### The eval context of a where block is an anonymous Struct class
|
||||||
|
|
||||||
|
You can't get to the resource or the table from there. (It's the Entry Struct type).
|
||||||
|
|
||||||
|
### You can in fact filter for `nil` values
|
||||||
|
|
||||||
|
### There is no obvious accessor for the Table instance
|
||||||
|
|
||||||
|
You can in fact get the FilterTable::Table instance by calling `where` with no args. But that is not obvious.
|
||||||
|
|
||||||
|
### There is no way to get the FilterTable::Factory object used to configure the resource
|
||||||
|
|
||||||
|
Especially while developing in inspec shell, it would be nice to be able to get at the FilterTable::Factory object, perhaps to add more accessors.
|
||||||
|
|
Loading…
Reference in a new issue