airmodel 0.0.2 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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