airmodel 0.0.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 0993017b73e85cdf5feea7202fe15bc08b7337db
4
- data.tar.gz: 96efc042d20d434f44e31485f2f585a18aa26522
3
+ metadata.gz: 13bc3270f4a9eee17e8559404cdb9f0e9917f899
4
+ data.tar.gz: 8ddb417cbac7e452de3f7ca469007370c2a7f6e6
5
5
  SHA512:
6
- metadata.gz: b6198d0499f0b32b0d7d6df2dd16ea5405cdf8fd9125ac87118416082587080e1c775319aef2359825f8c458455483fc88883dc5b5c4c2fc6b84866c15cb078e
7
- data.tar.gz: 574e06e6344caee7da1ed5e904bbe6fabaf58f313e8714fe5305de108a1c7dfb109c6b6cfd33b411f2ad1200a3ef86ac6ebae91b3e13a112a1a10741181cb454
6
+ metadata.gz: 38013c380e9a5b226c1ab4fe3c80ce10f5901052e9962db2f480fe37c555e29d956accd00b832e83e5aa7fcc5545b2995833ee3a61c0dfa5e0d37775f9cf76ce
7
+ data.tar.gz: 3686e3416162fd2744cceb2f81ea0c0272ab4fa6d16009205e1f9d74e34308ef6011a7c6b72dbd558b1e4cd10a67a719b530665c20a1b18fee0765e377ac2a6a
data/README.md CHANGED
@@ -30,20 +30,11 @@ before your app starts...
30
30
 
31
31
  :songs:
32
32
  :table_name: Songs
33
- :bases: appXYZ123ABC
33
+ :base_id: appXYZ123ABC
34
34
  :albums:
35
35
  :table_name: Albums
36
- :bases: appZZTOPETC
36
+ :base_id: appZZTOPETC
37
37
 
38
- Airmodel supports sharding your data across multiple Airtable bases, as long as
39
- they all have the same structure. If you've split your customers into east- and
40
- west-coast bases, for example, your YAML file should look like this:
41
-
42
- :songs:
43
- :table_name: Customers
44
- :bases:
45
- "east_coast": appXYZ123ABC
46
- "west_coast": appWXYOMGWTF
47
38
 
48
39
  Usage
49
40
  ----------------
@@ -61,27 +52,37 @@ Now you can write code like
61
52
 
62
53
  Song.all
63
54
 
64
- Song.some
55
+ Song.where("Artist Name": "The Beatles", "Composer": "Harrison")
65
56
 
66
57
  Song.first
67
58
 
68
59
  Song.new("Name": "Best Song Ever").save
69
60
 
70
- Song.where("Artist Name": "The Beatles", "Composer": "Harrison")
71
-
72
61
  Song.find("recXYZ")
73
62
 
74
63
  Song.find(["recXYZ", "recABC", "recJKL"])
75
64
 
65
+
66
+ Most queries are chainable, e.g.
67
+
68
+ Song.where("rating" => 5).where('artist' => "Fiona Apple").order("rating", "DESC").limit(5)
69
+
70
+ There's also a special `Model.by_formula` query, which overrides any filters
71
+ supplied in your `Model.where()` statements, and replaces them with a raw
72
+ [Airtable
73
+ Formula](https://support.airtable.com/hc/en-us/articles/203255215-Formula-field-reference)
74
+
75
+ You can still chain `.limit` and `.order` with a `.by_forumla` query.
76
+
77
+ Song.by_formula("NOT({Rating} < 3)").order("rating", "DESC").limit(5)
78
+
76
79
  See lib/airmodel/model.rb for all available methods.
77
80
 
78
81
 
79
82
  Contributions
80
83
  ----------------
81
84
 
82
- Add a test to spec/models_spec.rb, make sure it passes, then send a pull
85
+ Add a passing test to spec/model_spec.rb, then send a pull
83
86
  request. Thanks!
84
87
 
85
88
 
86
-
87
-
@@ -1,8 +1,6 @@
1
- :test_models:
2
- :table_name: "example_table"
3
- :bases: appXYZ
4
- :sharded_test_models:
5
- :table_name: "example_table"
6
- :bases:
7
- "nyc": appLGA
8
- "sf": appSFO
1
+ :albums:
2
+ :table_name: "albums"
3
+ :base_id: appXYZ
4
+ :songs:
5
+ :table_name: "songs"
6
+ :base_id: appXYZ
@@ -2,7 +2,9 @@ require 'active_support/all'
2
2
  require 'airtable'
3
3
 
4
4
  require "airmodel/version"
5
+ require "airmodel/query"
5
6
  require "airmodel/utils"
7
+ require "airmodel/associable"
6
8
  require "airmodel/model"
7
9
 
8
10
  # builds ActiveRecord-style models on top of Airtable
@@ -0,0 +1,37 @@
1
+ module Airmodel
2
+ module Associable
3
+
4
+ # defines a clone of the child class on this model
5
+ # required args: association_name
6
+ def has_many(association_name, args={})
7
+ args[:class_name] ||= association_name.to_s.singularize.capitalize
8
+ define_method association_name do
9
+ config = if args[:base_key]
10
+ # the airtable base_id is dynamically configured
11
+ # as a column on the parent model,
12
+ # and the table_name is either passed as
13
+ # an argument or inferrred from the child model name
14
+ {
15
+ base_id: self.send(args[:base_key]),
16
+ table_name: args[:table_name] || association_name.to_s.tableize
17
+ }
18
+ else
19
+ # the airtable base info is defined in the
20
+ # YML config file, with the rest of the data
21
+ Airmodel.bases[args[:class_name].tableize.to_sym] || raise(NoSuchBase.new("Couldn't find base '#{association_name}' in config file.\nPlease pass :base_key => foo with your has_many call,\nor add '#{association_name}' to your config file."))
22
+ end
23
+ finder_name = "@#{association_name}_finder"
24
+ if f = instance_variable_get(finder_name)
25
+ f
26
+ else
27
+ finder = Class.new(Object.const_get args[:class_name]) do
28
+ @base_id = config[:base_id]
29
+ @table_name = config[:table_name]
30
+ end
31
+ instance_variable_set(finder_name, finder)
32
+ end
33
+ end
34
+ end
35
+
36
+ end
37
+ end
@@ -1,32 +1,26 @@
1
1
  module Airmodel
2
2
  class Model < Airtable::Record
3
3
  extend Utils
4
+ extend Associable
4
5
 
5
- # returns all records in a table, making as many calls as necessary
6
- # to work around Airtable's 100-record per page design. This can be VERY
7
- # slow, and should not be used in production unless you cache it agressively.
8
- # Where possible, use Model.some instead.
9
- def self.all(args={sort: default_sort})
10
- puts "RUNNING EXPENSIVE API QUERY TO AIRTABLE (#{self.name})"
11
- self.classify tables(args).map{|tbl| tbl.all(args)}.flatten
6
+ def self.all
7
+ Query.new(self).all
12
8
  end
13
9
 
14
- # returns up to 100 records from Airtable
15
- def self.some(args={sort: default_sort})
16
- puts "RUNNING EXPENSIVE API QUERY TO AIRTABLE (#{self.name})"
17
- self.classify tables(args).map{|tbl| tbl.records(args) }.flatten
10
+ def self.where(args)
11
+ Query.new(self).where(args)
18
12
  end
19
13
 
20
- # find up to 100 records that match the filters
21
- def self.where(filters)
22
- shard = filters.delete(:shard)
23
- order = filters.delete(:sort)
24
- formula = "AND(" + filters.map{|k,v| "{#{k}}='#{v}'" }.join(',') + ")"
25
- some(
26
- shard: shard,
27
- sort: order,
28
- filterByFormula: formula,
29
- )
14
+ def self.by_formula(args)
15
+ Query.new(self).by_formula(args)
16
+ end
17
+
18
+ def self.order(args)
19
+ Query.new(self).order(args)
20
+ end
21
+
22
+ def self.limit(args)
23
+ Query.new(self).limit(args)
30
24
  end
31
25
 
32
26
  # find a record by ID.
@@ -35,29 +29,31 @@ module Airmodel
35
29
  # THEN you can pass self.find([an,array,of,ids]) and it will return
36
30
  # each record in that order. This is mostly only useful for looking up
37
31
  # records linked to a particular record.
38
- def self.find(id, shard=nil)
32
+ def self.find(id)
39
33
  if id.is_a? String
40
- results = self.classify tables(shard: shard).map{|tbl| tbl.find(id) }
34
+ results = self.classify table.find(id)
41
35
  results.count == 0 ? nil : results.first
42
36
  else
43
37
  formula = "OR(" + id.map{|x| "id='#{x}'" }.join(',') + ")"
44
- some(shard: shard, filterByFormula: formula).sort_by do |x|
45
- id.index(x.id)
46
- end
38
+ self.classify(
39
+ table.records(filterByFormula: formula).sort_by do |x|
40
+ id.index(x.id)
41
+ end
42
+ )
47
43
  end
48
44
  end
49
45
 
50
46
  # find a record by specified attributes, return it
51
47
  def self.find_by(filters)
52
- shard = filters.delete(:shard)
53
48
  if filters[:id]
54
- results = self.classify tables(shard: shard).map{|tbl| tbl.find(filters[:id]) }
49
+ results = self.classify table.find(filters[:id])
55
50
  else
56
51
  formula = "AND(" + filters.map{|k,v| "{#{k}}='#{v}'" }.join(',') + ")"
57
- results = some(
58
- shard: shard,
59
- filterByFormula: formula,
60
- limit: 1
52
+ results = self.classify(
53
+ table.records(
54
+ filterByFormula: formula,
55
+ limit: 1
56
+ )
61
57
  )
62
58
  end
63
59
  results.count == 0 ? nil : results.first
@@ -71,48 +67,44 @@ module Airmodel
71
67
 
72
68
  # return the first record
73
69
  def self.first
74
- results = some(
75
- limit: 1
76
- )
77
- results.count == 0 ? nil : results.first
70
+ Query.new(self).first
78
71
  end
79
72
 
80
73
  # create a new record and save it to Airtable
81
74
  def self.create(*models)
82
75
  results = models.map{|r|
83
76
  record = self.new(r)
84
- tables.map{|tbl| tbl.create(record) }.first
77
+ table.create(record)
85
78
  }
86
79
  results.length == 1 ? results.first : results
87
80
  end
88
81
 
89
82
  # send a PATCH request to update a few fields on a record in one API call
90
- def self.patch(id, fields, shard=nil)
91
- r = tables(shard: shard).map{|tbl|
92
- tbl.update_record_fields(id, airtable_formatted(fields))
93
- }.first
83
+ def self.patch(id, fields)
84
+ r = table.update_record_fields(id, airtable_formatted(fields))
94
85
  self.new(r.fields)
95
86
  end
96
87
 
97
88
  # INSTANCE METHODS
98
89
 
90
+ # return self.class.table. defined as an instance
91
+ # method to allow individual models to override it and
92
+ # connect to a different base in strange circumstances.
93
+ def table
94
+ self.class.table
95
+ end
96
+
99
97
  def formatted_fields
100
98
  self.class.airtable_formatted(self.fields)
101
99
  end
102
100
 
103
- def save(shard=self.shard_identifier)
101
+ def save
104
102
  if self.valid?
105
103
  if new_record?
106
- results = self.class.tables(shard: shard).map{|tbl|
107
- tbl.create self
108
- }
109
- # return the first version of this record that saved successfully
110
- results.find{|x| !!x }
104
+ self.table.create(self)
111
105
  else
112
- results = self.class.tables(shard: shard).map{|tbl|
113
- tbl.update_record_fields(id, self.changed_fields)
114
- }
115
- results.find{|x| !!x }
106
+ result = self.table.update_record_fields(id, self.changed_fields)
107
+ result
116
108
  end
117
109
  else
118
110
  false
@@ -121,18 +113,18 @@ module Airmodel
121
113
 
122
114
  def changed_fields
123
115
  current = fields
124
- old = self.class.find_by(id: id, shard: shard_identifier).fields
116
+ old = self.class.find_by(id: id).fields
125
117
  self.class.hash_diff(current, old)
126
118
  end
127
119
 
128
120
  def destroy
129
- self.class.tables(shard: shard_identifier).map{|tbl| tbl.destroy(id) }.first
121
+ self.table.destroy(id)
130
122
  end
131
123
 
132
124
  def update(fields)
133
- res = self.class.tables(shard: shard_identifier).map{|tbl| tbl.update_record_fields(id, fields) }.first
125
+ res = self.table.update_record_fields(id, fields)
134
126
  res.fields.each{|field, value| self[field] = value }
135
- true
127
+ self
136
128
  end
137
129
 
138
130
  def new_record?
@@ -153,13 +145,6 @@ module Airmodel
153
145
  {}
154
146
  end
155
147
 
156
- # getter method that should return the YAML key that defines
157
- # which shard the record lives in. Override if you're sharding
158
- # your data, otherwise just let it return nil
159
- def shard_identifier
160
- nil
161
- end
162
-
163
148
  end
164
149
 
165
150
  # raise this error when a table
@@ -0,0 +1,74 @@
1
+ module Airmodel
2
+ class Query
3
+
4
+ def initialize(querying_class)
5
+ @querying_class = querying_class
6
+ end
7
+
8
+ def params
9
+ @params ||= {
10
+ where_clauses: {},
11
+ order: @querying_class.default_sort
12
+ }
13
+ end
14
+
15
+ def where(args)
16
+ params[:where_clauses].merge!(args)
17
+ self
18
+ end
19
+
20
+ def by_formula(formula)
21
+ params[:where_clauses] = {}
22
+ params[:formula] = formula
23
+ self
24
+ end
25
+
26
+ def limit(lim)
27
+ params[:limit] = lim
28
+ self
29
+ end
30
+
31
+ def order(column, direction)
32
+ params[:order] = [column, direction.downcase.to_sym]
33
+ self
34
+ end
35
+
36
+ # add kicker methods
37
+ def to_a
38
+ puts "RUNNING EXPENSIVE API QUERY TO AIRTABLE (#{@querying_class.name})"
39
+ # filter by explicit formula, or by joining all where_clasues together
40
+ formula = params[:formula] || "AND(" + params[:where_clauses].map{|k,v| "{#{k}}='#{v}'" }.join(',') + ")"
41
+ @querying_class.classify @querying_class.table.all(
42
+ sort: params[:order],
43
+ filterByFormula: formula,
44
+ limit: params[:limit]
45
+ )
46
+ end
47
+
48
+ def all
49
+ to_a
50
+ end
51
+
52
+ def first
53
+ to_a.first
54
+ end
55
+
56
+ def last
57
+ to_a.last
58
+ end
59
+
60
+ def each(&block)
61
+ to_a.each(&block)
62
+ end
63
+
64
+ def map(&block)
65
+ to_a.map(&block)
66
+ end
67
+
68
+ def inspect
69
+ to_a.inspect
70
+ end
71
+
72
+ end
73
+ end
74
+
@@ -2,22 +2,22 @@ module Airmodel
2
2
  module Utils
3
3
 
4
4
  def table_name
5
- self.name.tableize.to_sym
5
+ @table_name || self.name.tableize.to_sym
6
6
  end
7
- #
8
- # return an array of Airtable::Table objects,
9
- # each backed by a base defined in DB YAML file
10
- def tables(args={})
11
- db = Airmodel.bases[table_name] || raise(NoSuchBase.new("Could not find base '#{table_name}' in config file"))
12
- bases_list = normalized_base_config(db[:bases])
13
- # return just one Airtable::Table if a particular shard was requested
14
- if args[:shard]
15
- [Airmodel.client.table(bases_list[args.delete(:shard)], db[:table_name])]
16
- # otherwise return each one
7
+
8
+ def base_config
9
+ if @base_id
10
+ { :base_id => @base_id, :table_name => table_name }
17
11
  else
18
- bases_list.map{|key, val| Airmodel.client.table val, db[:table_name] }
12
+ Airmodel.bases[table_name] || raise(NoSuchBase.new("Could not find base '#{table_name}' in config file"))
19
13
  end
20
14
  end
15
+ #
16
+ # return an Airtable::Table object,
17
+ # backed by a base defined in DB YAML file
18
+ def table
19
+ Airmodel.client.table base_config[:base_id], base_config[:table_name]
20
+ end
21
21
 
22
22
  def at(base_id, table_name)
23
23
  Airmodel.client.table(base_id, table_name)
@@ -25,8 +25,14 @@ module Airmodel
25
25
 
26
26
  ## converts array of generic airtable records to the instances
27
27
  # of the appropriate class
28
- def classify(list=[])
29
- list.map{|r| self.new(r.fields) }
28
+ def classify(obj=[])
29
+ if obj.is_a? Airtable::Record
30
+ [self.new(obj.fields)]
31
+ elsif obj.respond_to? :map
32
+ obj.map{|r| self.new(r.fields) }
33
+ else
34
+ raise AlienObject.new("Object is neither an array nor an Airtable::Model")
35
+ end
30
36
  end
31
37
 
32
38
  # convert blank strings to nil, [""] to [], and "true" to a boolean
@@ -45,27 +51,6 @@ module Airmodel
45
51
  }
46
52
  end
47
53
 
48
- # standardizes bases from config file, whether you've defined
49
- # your bases as a single string, a hash, an array,
50
- # or an array of hashes, returns hash of form { "base_label" => "base_id" }
51
- def normalized_base_config(config)
52
- if config.is_a? String
53
- { "#{config}" => config }
54
- elsif config.is_a? Array
55
- parsed = config.map{|x|
56
- if x.respond_to? :keys
57
- [x.keys.first, x.values.first]
58
- else
59
- [x,x]
60
- end
61
- }
62
- Hash[parsed]
63
- else
64
- config
65
- end
66
- end
67
-
68
-
69
54
  # Returns a hash that removes any matches with the other hash
70
55
  #
71
56
  # {a: {b:"c"}} - {:a=>{:b=>"c"}} # => {}
@@ -96,5 +81,9 @@ module Airmodel
96
81
  def -(first_hash, second_hash)
97
82
  hash_diff(first_hash, second_hash)
98
83
  end
84
+
85
+ class AlienObject < StandardError
86
+ end
87
+
99
88
  end
100
89
  end
@@ -1,3 +1,3 @@
1
1
  module Airmodel
2
- VERSION = '0.0.2'
2
+ VERSION = '1.0.0'
3
3
  end
@@ -0,0 +1,70 @@
1
+ require 'spec_helper'
2
+
3
+ class Song < Airmodel::Model
4
+ end
5
+
6
+ class ParentModel < Airmodel::Model
7
+ has_many :songs
8
+
9
+ def dynamically_assigned_child_base_id
10
+ 'appABCDEF'
11
+ end
12
+ end
13
+
14
+ describe ParentModel do
15
+
16
+ before(:each) do
17
+ config = Airmodel.bases[:albums]
18
+ #stub INDEX requests
19
+ stub_airtable_response!(
20
+ Regexp.new("https://api.airtable.com/v0/#{config[:base_id]}/#{config[:table_name]}"),
21
+ { "records" => [{"id": "recXYZ", fields: {"color":"red"} }, {"id":"recABC", fields: {"color": "blue"} }] }
22
+ )
23
+ #stub CREATE requests
24
+ stub_airtable_response!("https://api.airtable.com/v0/appXYZ/albums",
25
+ { "fields" => { "color" => "red", "foo" => "bar" }, "id" => "12345" },
26
+ :post
27
+ )
28
+ end
29
+
30
+ after(:each) do
31
+ FakeWeb.clean_registry
32
+ end
33
+
34
+
35
+ describe 'has_many' do
36
+ it 'should return a list of songs' do
37
+ songs = ParentModel.new.songs
38
+ expect(songs.table_name).to eq 'songs'
39
+ end
40
+
41
+ it 'should raise NoSuchBase when passed a weird association not backed by bases.yml' do
42
+ begin
43
+ ParentModel.has_many :unusual_children
44
+ false
45
+ rescue Airmodel::NoSuchBase
46
+ true
47
+ end
48
+ end
49
+
50
+ it 'should work when passed a weird association that *is* backed by bases.yml' do
51
+ ParentModel.has_many :tracks, class_name: 'Song'
52
+ end
53
+
54
+ it 'should work with a base_key instead of a yml file' do
55
+ stub_airtable_response!(
56
+ Regexp.new("https://api.airtable.com/v0/appABCDEF/tunes"),
57
+ { "records" => [{"id": "recXYZ", fields: {"color":"red"} }, {"id":"recABC", fields: {"color": "blue"} }] }
58
+ )
59
+ ParentModel.has_many :tunes, base_key: 'dynamically_assigned_child_base_id', class_name: 'Song'
60
+ tunes = ParentModel.new.tunes
61
+ expect(tunes.first.table.worksheet_name).to eq 'tunes'
62
+ end
63
+
64
+ it 'should let me define the important args however I like'
65
+
66
+ end
67
+
68
+ end
69
+
70
+
@@ -1,19 +1,19 @@
1
1
  require 'spec_helper'
2
2
 
3
- class TestModel < Airmodel::Model
3
+ class Album < Airmodel::Model
4
4
  end
5
5
 
6
- describe TestModel do
6
+ describe Album do
7
7
 
8
8
  before(:each) do
9
- config = Airmodel.bases[:test_models]
9
+ config = Airmodel.bases[:albums]
10
10
  #stub INDEX requests
11
11
  stub_airtable_response!(
12
- Regexp.new("https://api.airtable.com/v0/#{config[:bases]}/#{config[:table_name]}"),
12
+ Regexp.new("https://api.airtable.com/v0/#{config[:base_id]}/#{config[:table_name]}"),
13
13
  { "records" => [{"id": "recXYZ", fields: {"color":"red"} }, {"id":"recABC", fields: {"color": "blue"} }] }
14
14
  )
15
15
  #stub CREATE requests
16
- stub_airtable_response!("https://api.airtable.com/v0/appXYZ/example_table",
16
+ stub_airtable_response!("https://api.airtable.com/v0/appXYZ/albums",
17
17
  { "fields" => { "color" => "red", "foo" => "bar" }, "id" => "12345" },
18
18
  :post
19
19
  )
@@ -24,104 +24,98 @@ describe TestModel do
24
24
  end
25
25
 
26
26
  describe "Class Methods" do
27
- describe "tables" do
28
- it "should return Airtable::Table objects for each base in the config file" do
29
- tables = TestModel.tables
30
- tables.each do |t|
31
- expect(t.class).to eq Airtable::Table
32
- expect(t.app_token).not_to eq nil
33
- end
27
+ describe "table" do
28
+ it "should return an Airtable::Table object" do
29
+ table = Album.table
30
+ expect(table.class).to eq Airtable::Table
31
+ expect(table.app_token).not_to eq nil
34
32
  end
35
33
  end
36
34
 
37
35
  describe "at" do
38
36
  it "should connect to an arbitrary base and table" do
39
- table = TestModel.at("hello", "world")
37
+ table = Album.at("hello", "world")
40
38
  expect(table.class).to eq Airtable::Table
41
39
  end
42
40
  end
43
41
 
44
- describe "some" do
45
- it "should return a list of airtable records" do
46
- records = TestModel.some
47
- expect(records.first.id).to eq "recXYZ"
48
- end
49
- end
50
-
51
42
  describe "classify" do
52
- it "should return TestModels from Airtable::Records" do
43
+ it "should return albums from Airtable::Records" do
53
44
  array = [Airtable::Record.new]
54
- results = TestModel.classify(array)
55
- expect(results.first.class).to eq TestModel
45
+ results = Album.classify(array)
46
+ expect(results.first.class).to eq Album
56
47
  end
57
48
  end
58
49
 
59
50
  describe "all" do
60
51
  it "should return a list of airtable records" do
61
- records = TestModel.all
52
+ records = Album.all
62
53
  expect(records.first.id).to eq "recXYZ"
63
54
  end
64
55
  end
65
56
 
66
57
  describe "where" do
67
58
  it "should return a list of airtable records that match filters" do
68
- records = TestModel.where(color: "red")
69
- expect(records.first.class).to eq TestModel
59
+ records = Album.where(color: "red")
60
+ expect(records.first.class).to eq Album
70
61
  expect(records.first.color).to eq "red"
71
62
  end
72
63
  end
73
64
 
74
65
  describe "find" do
75
66
  it "should call airtable-ruby's 'find' method when passed just one record ID" do
76
- stub_airtable_response! "https://api.airtable.com/v0/appXYZ/example_table/recABC", { "id":"recABC", fields: {"name": "example record"} }
77
- record = TestModel.find("recABC")
67
+ stub_airtable_response! "https://api.airtable.com/v0/appXYZ/albums/recABC", { "id":"recABC", fields: {"name": "example record"} }
68
+ record = Album.find("recABC")
78
69
  expect(record.name).to eq "example record"
79
70
  end
80
71
  it "should return an ordered list of records when passed an array of record IDs" do
81
- records = TestModel.find(["recABC", "recXYZ"])
72
+ records = Album.find(["recABC", "recXYZ"])
82
73
  expect(records.class).to eq Array
83
74
  expect(records.first.id).to eq "recABC"
75
+ expect(records.first.class).to eq Album
84
76
  end
85
77
  end
86
78
 
87
79
  describe "find_by" do
88
80
  it "should return one record that matches the supplied filters", skip_before: true do
89
81
  stub_airtable_response!(
90
- Regexp.new("https://api.airtable.com/v0/appXYZ/example_table"),
82
+ Regexp.new("https://api.airtable.com/v0/appXYZ/albums"),
91
83
  { "records" => [{"id":"recABC", fields: {"color": "blue"} }] }
92
84
  )
93
- record = TestModel.find_by(color: "blue")
85
+ record = Album.find_by(color: "blue")
94
86
  expect(record.color).to eq "blue"
87
+ expect(record.class).to eq Album
95
88
  end
96
89
  it "should call airtable-ruby's 'find' method when the filter is an id" do
97
- stub_airtable_response! "https://api.airtable.com/v0/appXYZ/example_table/recABC", { "id":"recABC", fields: {"name": "example record"} }
98
- record = TestModel.find_by(id: "recABC")
90
+ stub_airtable_response! "https://api.airtable.com/v0/appXYZ/albums/recABC", { "id":"recABC", fields: {"name": "example record"} }
91
+ record = Album.find_by(id: "recABC")
99
92
  expect(record.name).to eq "example record"
93
+ expect(record.class).to eq Album
100
94
  end
101
95
  end
102
96
 
103
97
  describe "first" do
104
98
  it "should return the first record" do
105
- record = TestModel.first
106
- expect(record.class).to eq TestModel
99
+ record = Album.first
100
+ expect(record.class).to eq Album
107
101
  end
108
102
  end
109
103
 
110
104
  describe "create" do
111
105
  it "should create a new record" do
112
- record = TestModel.create(color: "red")
106
+ record = Album.create(color: "red")
113
107
  expect(record.id).to eq "12345"
114
108
  end
115
109
  end
116
110
 
117
111
  describe "patch" do
118
112
  it "should update a record" do
119
- stub_airtable_response!(Regexp.new("/v0/appXYZ/example_table/12345"),
113
+ stub_airtable_response!(Regexp.new("/v0/appXYZ/albums/12345"),
120
114
  { "fields" => { "color" => "blue", "foo" => "bar" }, "id" => "12345" },
121
115
  :patch
122
116
  )
123
- record = TestModel.create(color: "red")
124
- record = TestModel.patch("12345", { color: "blue" })
117
+ record = Album.create(color: "red")
118
+ record = Album.patch("12345", { color: "blue" })
125
119
  expect(record.color).to eq "blue"
126
120
  end
127
121
  end
@@ -131,17 +125,17 @@ describe TestModel do
131
125
  describe "Instance Methods" do
132
126
 
133
127
  describe "save" do
134
- record = TestModel.new(color: "red")
128
+ record = Album.new(color: "red")
135
129
  it "should create a new record" do
136
130
  record.save
137
131
  expect(record.id).to eq "12345"
138
132
  end
139
133
  it "should update an existing record" do
140
- stub_airtable_response!("https://api.airtable.com/v0/appXYZ/example_table/12345",
134
+ stub_airtable_response!("https://api.airtable.com/v0/appXYZ/albums/12345",
141
135
  { "fields" => { "color" => "red", "foo" => "bar" }, "id" => "12345" },
142
136
  :get
143
137
  )
144
- stub_airtable_response!("https://api.airtable.com/v0/appXYZ/example_table/12345",
138
+ stub_airtable_response!("https://api.airtable.com/v0/appXYZ/albums/12345",
145
139
  { "fields" => { "color" => "blue" }, "id" => "12345" },
146
140
  :patch
147
141
  )
@@ -153,22 +147,22 @@ describe TestModel do
153
147
 
154
148
  describe "destroy" do
155
149
  it "should delete a record" do
156
- stub_airtable_response!("https://api.airtable.com/v0/appXYZ/example_table/12345",
150
+ stub_airtable_response!("https://api.airtable.com/v0/appXYZ/albums/12345",
157
151
  { "deleted": true, "id" => "12345" },
158
152
  :delete
159
153
  )
160
- response = TestModel.new(id: "12345").destroy
154
+ response = Album.new(id: "12345").destroy
161
155
  expect(response["deleted"]).to eq true
162
156
  end
163
157
  end
164
158
 
165
159
  describe "update" do
166
160
  it "should update the supplied attrs on an existing record" do
167
- stub_airtable_response!("https://api.airtable.com/v0/appXYZ/example_table/12345",
161
+ stub_airtable_response!("https://api.airtable.com/v0/appXYZ/albums/12345",
168
162
  { "fields" => { "color" => "green"}, "id" => "12345" },
169
163
  :patch
170
164
  )
171
- record = TestModel.create(color: "red", id:"12345")
165
+ record = Album.create(color: "red", id:"12345")
172
166
  record.update(color: "green")
173
167
  expect(record.color).to eq "green"
174
168
  end
@@ -176,18 +170,18 @@ describe TestModel do
176
170
 
177
171
  describe "cache_key" do
178
172
  it "should return a unique key that can be used to id this record in memcached" do
179
- record = TestModel.new(id: "recZXY")
180
- expect(record.cache_key).to eq "test_models_recZXY"
173
+ record = Album.new(id: "recZXY")
174
+ expect(record.cache_key).to eq "albums_recZXY"
181
175
  end
182
176
  end
183
177
 
184
178
  describe "changed_fields" do
185
179
  it "should return a hash of attrs changed since last save" do
186
- stub_airtable_response!("https://api.airtable.com/v0/appXYZ/example_table/12345",
180
+ stub_airtable_response!("https://api.airtable.com/v0/appXYZ/albums/12345",
187
181
  { fields: { 'color': 'red' }, "id" => "12345" },
188
182
  :get
189
183
  )
190
- record = TestModel.create(color: 'red')
184
+ record = Album.create(color: 'red')
191
185
  record[:color] = 'green'
192
186
  expect(record.changed_fields).to have_key 'color'
193
187
  end
@@ -195,7 +189,7 @@ describe TestModel do
195
189
 
196
190
  describe "new_record?" do
197
191
  it "should return true if the record hasn't been saved to airtable yet" do
198
- record = TestModel.new(color: 'red')
192
+ record = Album.new(color: 'red')
199
193
  expect(record.new_record?).to eq true
200
194
  end
201
195
  end
@@ -208,7 +202,7 @@ describe TestModel do
208
202
  "truthy_string": "true",
209
203
  "falsy_string": "false"
210
204
  }
211
- record = TestModel.new(attrs)
205
+ record = Album.new(attrs)
212
206
  formatted_attrs = record.formatted_fields
213
207
  it "should convert empty arrays [''] to []" do
214
208
  expect(formatted_attrs["empty_array"]).to eq []
@@ -228,60 +222,13 @@ describe TestModel do
228
222
  end
229
223
  end
230
224
 
231
- class ShardedTestModel < Airmodel::Model
232
- end
233
-
234
- describe ShardedTestModel do
235
- let(:config) { Airmodel.bases[:test_models_sharded] }
236
-
237
- describe "class_methods" do
238
-
239
- describe "tables" do
240
- it "should return Airtable::Table objects for each base in the config file" do
241
- tables = ShardedTestModel.tables
242
- tables.each do |t|
243
- expect(t.class).to eq Airtable::Table
244
- expect(t.app_token.class).to eq String
245
- end
246
- end
247
- it "should return just the one table matching args[:shard]" do
248
- tables = ShardedTestModel.tables(shard: "east_coast")
249
- end
250
- end
251
-
252
- describe "normalized_base_config" do
253
- it "should return a hash from a string" do
254
- sample_config = "appXYZ"
255
- target = { "appXYZ" => "appXYZ" }
256
- expect(TestModel.normalized_base_config(sample_config)).to eq target
257
- end
258
- it "should return a hash from an array of strings" do
259
- sample_config = %w(appXYZ appABC)
260
- target = { "appXYZ" => "appXYZ", "appABC" => "appABC" }
261
- expect(TestModel.normalized_base_config(sample_config)).to eq target
262
- end
263
- it "should return a hash from an array of hashes" do
264
- sample_config = [{"nyc": "appXYZ"}, {"sf": "appABC"}]
265
- target = { "nyc": "appXYZ", "sf": "appABC" }
266
- expect(TestModel.normalized_base_config(sample_config)).to eq target
267
- end
268
- it "should return itself from a hash" do
269
- sample_config = {"nyc": "appXYZ", "sf": "appABC" }
270
- target = { "nyc": "appXYZ", "sf": "appABC" }
271
- expect(TestModel.normalized_base_config(sample_config)).to eq target
272
- end
273
- end
274
-
275
- end
276
- end
277
-
278
- class BaselessTestModel < Airmodel::Model
225
+ class BaselessModel < Airmodel::Model
279
226
  end
280
227
 
281
- describe BaselessTestModel do
228
+ describe BaselessModel do
282
229
  describe "it should raise a NoSuchBaseError when no base is defined" do
283
230
  begin
284
- records = BaselessTestModel.some
231
+ records = BaselessModel.all
285
232
  false
286
233
  rescue Airmodel::NoSuchBase
287
234
  true
@@ -0,0 +1,45 @@
1
+ require "spec_helper"
2
+
3
+ class Album < Airmodel::Model
4
+ end
5
+
6
+ describe Album do
7
+
8
+ before(:each) do
9
+ config = Airmodel.bases[:albums]
10
+ #stub INDEX requests
11
+ stub_airtable_response!(
12
+ Regexp.new("https://api.airtable.com/v0/#{config[:base_id]}/#{config[:table_name]}"),
13
+ { "records" => [{"id": "recXYZ", fields: {"color":"red"} }, {"id":"recABC", fields: {"color": "blue"} }] }
14
+ )
15
+ end
16
+
17
+ after(:each) do
18
+ FakeWeb.clean_registry
19
+ end
20
+
21
+
22
+ describe "where" do
23
+ it "allows chaining" do
24
+ q = Album.where(
25
+ "name" => "Tidal",
26
+ "artist" => "Fiona Apple",
27
+ "great" => true
28
+ ).limit(10)
29
+ expect(q.class).to eq Airmodel::Query
30
+ # it should execute the query on
31
+ # query.all, and return an array
32
+ expect(q.all.class).to be Array
33
+ end
34
+
35
+ it "can replace where_clauses with a raw airtable formula" do
36
+ formula = "NOT({Rating} < 4"
37
+ q = Album.where("great" => true).by_formula(formula)
38
+ expect(q.params[:where_clauses]).to be {}
39
+ expect(q.params[:formula]).to be formula
40
+ expect(q.all.class).to be Array
41
+ end
42
+ end
43
+
44
+ end
45
+
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: airmodel
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.2
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - chrisfrankdotfm
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2016-10-21 00:00:00.000000000 Z
11
+ date: 2016-12-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -122,11 +122,15 @@ files:
122
122
  - airmodel.gemspec
123
123
  - config/bases.yml
124
124
  - lib/airmodel.rb
125
+ - lib/airmodel/associable.rb
125
126
  - lib/airmodel/model.rb
127
+ - lib/airmodel/query.rb
126
128
  - lib/airmodel/utils.rb
127
129
  - lib/airmodel/version.rb
128
130
  - spec/airmodel_spec.rb
129
- - spec/models_spec.rb
131
+ - spec/associable_spec.rb
132
+ - spec/model_spec.rb
133
+ - spec/query_spec.rb
130
134
  - spec/spec_helper.rb
131
135
  homepage: https://github.com/chrisfrank/airmodel
132
136
  licenses:
@@ -148,7 +152,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
148
152
  version: '0'
149
153
  requirements: []
150
154
  rubyforge_project:
151
- rubygems_version: 2.6.4
155
+ rubygems_version: 2.5.1
152
156
  signing_key:
153
157
  specification_version: 4
154
158
  summary: Interact with your Airtable data using ActiveRecord-style models