Skip to content

Commit

Permalink
Merge pull request #34 from chrisfrank/master
Browse files Browse the repository at this point in the history
Implement associations as instance methods
  • Loading branch information
chrisfrank committed Nov 5, 2018
2 parents 6d557e3 + 08d501a commit 42e8eda
Show file tree
Hide file tree
Showing 6 changed files with 158 additions and 78 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# 1.0.0 (unreleased)

* 1.0.0 will introduce breaking changes, including removing support for symbols. To update, change snake-case symbols to their correct column names (for example, `record["First Name"]` instead of `record[:first_name]`)
* Implement associations as instance methods, e.g.
```ruby
tea.brews #=> [<Brew>, <Brew>] returns associated models
tea["Brews"] #=> ["rec456", "rec789"] returns a raw Airtable field
```

# 0.2.5

Expand Down
66 changes: 53 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class Tea < Airrecord::Table
self.base_key = "app1"
self.table_name = "Teas"

has_many "Brews", class: 'Brew', column: "Brews"
has_many :brews, class: "Brew", column: "Brews"

def self.chinese
all(filter: '{Country} = "China"')
Expand All @@ -43,7 +43,7 @@ class Brew < Airrecord::Table
self.base_key = "app1"
self.table_name = "Brews"

belongs_to "Tea", class: 'Tea', column: 'Tea'
belongs_to :tea, class: "Tea", column: "Tea"

def self.hot
all(filter: "{Temperature} > 90")
Expand All @@ -58,7 +58,7 @@ teas = Tea.all
tea = teas.first
tea["Country"] # access atribute
tea.location # instance methods
tea["Brews"] # associated brews
tea.brews # associated brews
```

A short-hand API for definitions and more ad-hoc querying is also available:
Expand Down Expand Up @@ -115,10 +115,17 @@ end
This gives us a class that maps to records in a table. Class methods are
available to fetch records on the table.

### Reading a Single Record

Retrieve a single record via `#find`:
```ruby
tea = Tea.find("someid")
```

### Listing Records

Retrieval of multiple records is done through `#all`. To get all records in a
table:
Retrieval of multiple records is usually done through `#all`. To get all records
in a table:

```ruby
Tea.all # array of Tea instances
Expand Down Expand Up @@ -183,6 +190,14 @@ Tea.all(paginate: false)
Tea.all(sort: { "Created At" => "desc" }, paginate: false)
```

When you know the IDs of the records you want, and you want them in an ad-hoc
order, use `#find_many` instead of `#all`:

```ruby
teas = Tea.find_many(["someid", "anotherid", "yetanotherid"])
#=> [<Tea @id="someid">,<Tea @id="anotherid">, <Tea @id="yetanotherid">]
```

### Creating

Creating a new record is done through `#create`.
Expand Down Expand Up @@ -268,14 +283,22 @@ class Tea < Airrecord::Table
self.base_key = "app1"
self.table_name = "Teas"

has_many "Brews", class: 'Brew', column: "Brews"
has_many :brews, class: "Brew", column: "Brews"
has_one :teapot, class: "Teapot", column: "Teapot"
end

class Brew < Airrecord::Table
self.base_key = "app1"
self.table_name = "Brews"

belongs_to "Tea", class: 'Tea', column: 'Tea'
belongs_to :tea, class: "Tea", column: "Tea"
end

class Teapot < Airrecord::Table
self.base_key = "app1"
self.table_name = "Teapot"

belongs_to :tea, class: "Tea", column: "Tea"
end
```

Expand All @@ -289,25 +312,42 @@ _not_ support associations across Bases.
To retrieve records from associations to a record:

```ruby
tea = Tea.find('rec84')
tea["Brews"] # brews associated with tea
tea = Tea.find("rec123")

# record.association returns Airrecord instances
tea.brews #=> [<Brew @id="rec456">, <Brew @id="rec789">]
tea.teapot #=> <Teapot @id="rec012">

# record["Associated Column"] returns the raw Airtable field, an array of IDs
tea["Brews"] #=> ["rec789", "rec456"]
tea["Teapot"] #=> ["rec012"]
```

This in turn works the other way too:

```ruby
brew = Brew.find('rec849')
brew["Tea"] # the associated tea instance
brew = Brew.find("rec456")
brew.tea #=> <Tea @id="rec123"> the associated tea instance
brew["Tea"] #=> the raw Airtable field, a single-item array ["rec123"]
```

### Creating associated records

You can easily associate records with each other:

```ruby
tea = Tea.find('rec849829')
tea = Tea.find("rec123")
# This will create a brew associated with the specific tea
brew = Brew.new("Tea" => tea, "Temperature" => "80", "Time" => "4m", "Rating" => "5")
brew = Brew.new("Temperature" => "80", "Time" => "4m", "Rating" => "5")
brew.tea = tea
brew.create
```

Alternatively, you can specify association ids directly:

```ruby
tea = Tea.find("rec123")
brew = Brew.new("Tea" => [tea.id], "Temperature" => "80", "Time" => "4m", "Rating" => "5")
brew.create
```

Expand Down
73 changes: 28 additions & 45 deletions lib/airrecord/table.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,23 +25,32 @@ class << self
def deprecate_symbols
warn Kernel.caller.first + ": warning: Using symbols with airrecord is deprecated."
end

def client
@@clients ||= {}
@@clients[api_key] ||= Client.new(api_key)
end

def has_many(name, options)
@associations ||= []
@associations << {
field: name.to_sym, # todo: deprecate_symbols
}.merge(options)
def has_many(method_name, options)
define_method(method_name.to_sym) do
# Get association ids in reverse order, because Airtable’s UI and API
# sort associations in opposite directions. We want to match the UI.
ids = (self[options.fetch(:column)] || []).reverse
table = Kernel.const_get(options.fetch(:class))
options[:single] ? table.find(ids.first) : table.find_many(ids)
end

define_method("#{method_name}=".to_sym) do |value|
self[options.fetch(:column)] = Array(value).map(&:id).reverse
end
end

def belongs_to(name, options)
has_many(name, options.merge(single: true))
def belongs_to(method_name, options)
has_many(method_name, options.merge(single: true))
end

alias has_one belongs_to

def api_key
@api_key || Airrecord.api_key
end
Expand All @@ -57,6 +66,12 @@ def find(id)
end
end

def find_many(ids)
or_args = ids.map { |id| "RECORD_ID() = '#{id}'"}.join(',')
formula = "OR(#{or_args})"
records(filter: formula).sort_by { |record| or_args.index(record.id) }
end

def records(filter: nil, sort: nil, view: nil, offset: nil, paginate: true, fields: nil, max_records: nil, page_size: nil)
options = {}
options[:filterByFormula] = filter if filter
Expand Down Expand Up @@ -128,17 +143,7 @@ def [](key)
value = fields[column_mappings[key]]
end

if association = self.association(key)
klass = Kernel.const_get(association[:class])
associations = value.map { |id_or_obj|
id_or_obj = id_or_obj.respond_to?(:id) ? id_or_obj.id : id_or_obj
klass.find(id_or_obj)
}
return associations.first if association[:single]
associations
else
type_cast(value)
end
type_cast(value)
end

def []=(key, value)
Expand Down Expand Up @@ -209,18 +214,8 @@ def destroy
end
end

def serializable_fields(fields = self.fields)
Hash[fields.map { |(key, value)|
if association(key)
value = [ value ] unless value.is_a?(Enumerable)
assocs = value.map { |assoc|
assoc.respond_to?(:id) ? assoc.id : assoc
}
[key, assocs]
else
[key, value]
end
}]
def serializable_fields
fields
end

def ==(other)
Expand All @@ -236,14 +231,6 @@ def hash

protected

def association(key)
if self.class.associations
self.class.associations.find { |association|
association[:column].to_s == column_mappings[key].to_s || association[:column].to_s == key.to_s
}
end
end

def fields=(fields)
@updated_keys = []
@column_mappings = Hash[fields.keys.map { |key| [underscore(key), key] }] # TODO remove (deprecate_symbols)
Expand All @@ -268,13 +255,9 @@ def client
end

def type_cast(value)
if value =~ /\d{4}-\d{2}-\d{2}/
Time.parse(value + " UTC")
else
value
end
return Time.parse(value + " UTC") if value =~ /\d{4}-\d{2}-\d{2}/
value
end

end

def self.table(api_key, base_key, table_name)
Expand Down
83 changes: 65 additions & 18 deletions test/associations_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,27 @@ class Tea < Airrecord::Table
self.base_key = "app1"
self.table_name = "Teas"

has_many "Brews", class: "Brew", column: "Brews"
has_many :brews, class: "Brew", column: "Brews"
has_one :pot, class: "Teapot", column: "Teapot"
end

class Brew < Airrecord::Table
self.api_key = "key1"
self.base_key = "app1"
self.table_name = "Brews"

belongs_to "Tea", class: "Tea", column: "Tea"
belongs_to :tea, class: "Tea", column: "Tea"
end

class Teapot < Airrecord::Table
self.api_key = "key1"
self.base_key = "app1"
self.table_name = "Teapots"

belongs_to :tea, class: "Tea", column: "Tea"
end


class AssociationsTest < MiniTest::Test
def setup
@stubs = Faraday::Adapter::Test::Stubs.new
Expand All @@ -25,42 +35,79 @@ def setup
end

def test_has_many_associations
tea = Tea.new("Name" => "Dong Ding", "Brews" => ["rec2"])
tea = Tea.new("Name" => "Dong Ding", "Brews" => ["rec2", "rec1"])

brews = [
{ "id" => "rec2", "Name" => "Good brew" },
{ "id" => "rec1", "Name" => "Decent brew" }
]
stub_request(brews, table: Brew)

record = Brew.new("Name" => "Good brew")
stub_find_request(record, id: "rec2", table: Brew)
assert_equal 2, tea.brews.size
assert_kind_of Airrecord::Table, tea.brews.first
assert_equal "rec1", tea.brews.first.id
end

assert_equal 1, tea["Brews"].size
assert_kind_of Airrecord::Table, tea["Brews"].first
assert_equal "rec2", tea["Brews"].first.id
def test_has_many_handles_empty_associations
tea = Tea.new("Name" => "Gunpowder")
stub_request([], table: Brew)
assert_equal 0, tea.brews.size
end

def test_belongs_to
brew = Brew.new("Name" => "Good Brew", "Tea" => ["rec1"])
tea = Tea.new("Name" => "Dong Ding", "Brews" => ["rec2"])
stub_find_request(tea, table: Tea, id: "rec1")

assert_equal "rec1", brew["Tea"].id
assert_equal "rec1", brew.tea.id
end

def test_has_one
tea = Tea.new("id" => "rec1", "Name" => "Sencha", "Teapot" => ["rec3"])
pot = Teapot.new("Name" => "Cast Iron", "Tea" => ["rec1"])
stub_find_request(pot, table: Teapot, id: "rec3")

assert_equal "rec3", tea.pot.id
end

def test_build_association_and_post_id
def test_build_association_from_strings
tea = Tea.new({"Name" => "Jingning", "Brews" => ["rec2", "rec1"]})
stub_post_request(tea, table: Tea)

tea.create

stub_request([{ id: "rec2" }, { id: "rec1" }], table: Brew)
assert_equal 2, tea.brews.count
end

def test_build_belongs_to_association_from_setter
tea = Tea.new({"Name" => "Jingning", "Brews" => []}, id: "rec1")
brew = Brew.new("Name" => "greeaat", "Tea" => [tea])
brew = Brew.new("Name" => "greeaat")
brew.tea = tea
stub_post_request(brew, table: Brew)

brew.create

stub_find_request(tea, table: Tea, id: "rec1")
assert_equal tea.id, brew["Tea"].id
assert_equal tea.id, brew.tea.id
end

def test_build_association_from_strings
tea = Tea.new({"Name" => "Jingning", "Brews" => ["rec2"]})
stub_post_request(tea, table: Tea)
def test_build_has_many_association_from_setter
tea = Tea.new("Name" => "Earl Grey")
brews = %w[Perfect Meh].each_with_object([]) do |name, memo|
brew = Brew.new("Name" => name)
stub_post_request(brew, table: Brew)
brew.create
memo << brew
end

tea.create
tea.brews = brews

brew_fields = brews.map { |brew| brew.fields.merge("id" => brew.id) }
stub_request(brew_fields, table: Brew)

stub_find_request(Brew.new({}), table: Brew, id: "rec2")
assert_equal 1, tea["Brews"].count
assert_equal 2, tea.brews.size
assert_kind_of Airrecord::Table, tea.brews.first
assert_equal tea.brews.first.id, brews.first.id
end
end

0 comments on commit 42e8eda

Please sign in to comment.