airmodel 0.0.1pre

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 52609d17520965802a5ce6ad3bfbd4ed9e16d1bc
4
+ data.tar.gz: a2aa97650c28a577de2a568827ef8e52ab502004
5
+ SHA512:
6
+ metadata.gz: 1cb44df1db02298370724c4245548cb0dc52f49700efa0a8946c255b3aeaf9603912a1f4b7d451a8273a03927b31382714453e54b7a41981f5d60f447b005ef1
7
+ data.tar.gz: 7b8a4704f3679579fc8f0324f6a6f66a4764b47770843ae8a20d56898699eb535b8d608146ed0834fc48893d00ce1612619af13bf7102349b9d3ffc2324ebba0
data/.gitignore ADDED
@@ -0,0 +1,22 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
18
+ *.bundle
19
+ *.so
20
+ *.o
21
+ *.a
22
+ mkmf.log
data/Gemfile ADDED
@@ -0,0 +1,2 @@
1
+ source 'https://rubygems.org'
2
+ gemspec
data/README.md ADDED
@@ -0,0 +1,81 @@
1
+ Airmodel
2
+ ===========
3
+
4
+ Interact with your Airtable data using ActiveRecord-style models.
5
+
6
+ Installation
7
+ ----------------
8
+
9
+ Add this line to your Gemfile:
10
+
11
+ gem install 'airmodel', git: 'https://github.com/chrisfrank/airmodel.git'
12
+
13
+ Configuration
14
+ ----------------
15
+ 1. Supply your Airtable API key, either by setting ENV['AIRTABLE_API_KEY']
16
+ before your app starts...
17
+
18
+ ENV['AIRTABLE_API_KEY'] = YOUR_API_KEY
19
+
20
+ ... or by putting this line somewhere in your app's init block:
21
+
22
+ Airmodel.client(YOUR_API_KEY_HERE)
23
+
24
+ 2. Tell Airmodel where your bases are, either by creating a YAML file at
25
+ *config/bases.yml*, or with this line somewhere in your init block:
26
+
27
+ Airmodel.bases(path_to_your_yaml_file)
28
+
29
+ Your YAML file should look something like this:
30
+
31
+ :songs:
32
+ :table_name: Songs
33
+ :bases: appXYZ123ABC
34
+ :albums:
35
+ :table_name: Albums
36
+ :bases: appZZTOPETC
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
+
48
+ Usage
49
+ ----------------
50
+
51
+ Create a class for each key in your YAML file. You should name it after the
52
+ singularized version of your YAML key:
53
+
54
+ class Song < Airmodel::Model
55
+ end
56
+
57
+ class Album < Airmodel::Model
58
+ end
59
+
60
+ Now you can write code like
61
+
62
+ Song.all
63
+
64
+ Song.first
65
+
66
+ Song.new("Name": "Best Song Ever").save
67
+
68
+ Song.where("Artist Name": "The Beatles", "Composer": "Harrison")
69
+
70
+ See lib/airmodel/model.rb for all available methods.
71
+
72
+
73
+ Contributions
74
+ ----------------
75
+
76
+ Add a test to spec/models_spec.rb, make sure it passes, then send a pull
77
+ request. Thanks!
78
+
79
+
80
+
81
+
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require 'rspec/core/rake_task'
2
+ RSpec::Core::RakeTask.new(:spec) do |t|
3
+ t.pattern = Dir.glob('spec/*_spec.rb')
4
+ t.rspec_opts = '--format documentation'
5
+ end
6
+ task :default => :spec
data/airmodel.gemspec ADDED
@@ -0,0 +1,27 @@
1
+ lib = File.expand_path('../lib', __FILE__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require 'airmodel/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'airmodel'
7
+ spec.version = Airmodel::VERSION
8
+ spec.authors = ['chrisfrankdotfm']
9
+ spec.email = ['chris.frank@thefutureproject.org']
10
+ spec.description = 'Airtable data in ActiveRecord-style syntax'
11
+ spec.summary = 'Interact with your Airtable data using ActiveRecord-style models'
12
+ spec.homepage = 'https://github.com/chrisfrank/airmodel'
13
+ spec.license = 'MIT'
14
+
15
+ spec.files = `git ls-files`.split($/)
16
+ spec.test_files = spec.files.grep('^(test|spec|features)/')
17
+ spec.require_paths = ['lib']
18
+
19
+ spec.add_development_dependency 'bundler', '~> 1'
20
+ spec.add_development_dependency 'rake', '~> 10'
21
+ spec.add_development_dependency 'rspec', '~> 3'
22
+ spec.add_development_dependency 'pry', '~> 0.10'
23
+ spec.add_development_dependency 'fakeweb', '~> 1.3'
24
+
25
+ spec.add_dependency 'airtable', '~> 0.0.8'
26
+ spec.add_dependency 'activesupport', '~> 5.0'
27
+ end
data/config/bases.yml ADDED
@@ -0,0 +1,8 @@
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
data/lib/airmodel.rb ADDED
@@ -0,0 +1,44 @@
1
+ require 'active_support/all'
2
+ require 'airtable'
3
+
4
+ require "airmodel/version"
5
+ require "airmodel/utils"
6
+ require "airmodel/model"
7
+
8
+ # builds ActiveRecord-style models on top of Airtable
9
+ module Airmodel
10
+
11
+ def self.root
12
+ File.expand_path '../..', __FILE__
13
+ end
14
+
15
+ def self.client(api_key=ENV["AIRTABLE_API_KEY"])
16
+ @@api_client ||= Airtable::Client.new(api_key)
17
+ @@api_client
18
+ end
19
+
20
+ def self.bases(path_to_config_file="#{Airmodel.root}/config/bases.yml")
21
+ @@bases ||= YAML.load_file(path_to_config_file)
22
+ @@bases
23
+ end
24
+
25
+ end
26
+
27
+ # monkeypatch airtable-ruby to add v 0.0.9's PATCH method,
28
+ # at least until Airtable adds 0.0.9 to rubygems
29
+ module Airtable
30
+ class Table
31
+
32
+ def update_record_fields(record_id, fields_for_update)
33
+ result = self.class.patch(worksheet_url + "/" + record_id,
34
+ :body => { "fields" => fields_for_update }.to_json,
35
+ :headers => { "Content-type" => "application/json" }).parsed_response
36
+ if result.present? && result["id"].present?
37
+ Record.new(result_attributes(result))
38
+ else # failed
39
+ false
40
+ end
41
+ end
42
+
43
+ end
44
+ end
@@ -0,0 +1,153 @@
1
+ module Airmodel
2
+ class Model < Airtable::Record
3
+ extend Utils
4
+
5
+ # returns all records in the database, making as many calls as necessary
6
+ # to work around Airtable's 100-record per page design
7
+ def self.all(args={sort: default_sort})
8
+ puts "RUNNING EXPENSIVE API QUERY TO AIRTABLE (#{self.name})"
9
+ self.classify tables(args).map{|tbl| tbl.all(args)}.flatten
10
+ end
11
+
12
+ # returns up to 100 records from Airtable
13
+ def self.records(args={sort: default_sort})
14
+ puts "RUNNING EXPENSIVE API QUERY TO AIRTABLE (#{self.name})"
15
+ self.classify tables(args).map{|tbl| tbl.records(args) }.flatten
16
+ end
17
+
18
+ # default to whatever order airtable returns
19
+ # this method gets overridden on Airtabled classes
20
+ def self.default_sort
21
+ nil
22
+ end
23
+
24
+ # find records that match the filters
25
+ def self.where(filters)
26
+ shard = filters.delete(:shard)
27
+ order = filters.delete(:sort)
28
+ formula = "AND(" + filters.map{|k,v| "{#{k}}='#{v}'" }.join(',') + ")"
29
+ records(
30
+ shard: shard,
31
+ sort: order,
32
+ filterByFormula: formula,
33
+ )
34
+ end
35
+
36
+ # find a record by specified attributes, return it
37
+ def self.find_by(filters)
38
+ shard = filters.delete(:shard)
39
+ if filters[:id]
40
+ results = self.classify tables(shard: shard).map{|tbl| tbl.find(filters[:id]) }
41
+ else
42
+ formula = "AND(" + filters.map{|k,v| "{#{k}}='#{v}'" }.join(',') + ")"
43
+ results = records(
44
+ shard: shard,
45
+ filterByFormula: formula,
46
+ limit: 1
47
+ )
48
+ end
49
+ results.count == 0 ? nil : results.first
50
+ end
51
+
52
+ # return the first record
53
+ def self.first
54
+ results = records(
55
+ limit: 1
56
+ )
57
+ results.count == 0 ? nil : results.first
58
+ end
59
+
60
+ # create a new record and save it to Airtable
61
+ def self.create(*records)
62
+ results = records.map{|r|
63
+ record = self.new(r)
64
+ tables.map{|tbl| tbl.create(record) }.first
65
+ }
66
+ results.length == 1 ? results.first : results
67
+ end
68
+
69
+ # send a PATCH request to update a few fields on a record in one API call
70
+ def self.patch(id, fields, shard=nil)
71
+ r = tables(shard: shard).map{|tbl|
72
+ tbl.update_record_fields(id, airtable_formatted(fields))
73
+ }.first
74
+ self.new(r.fields)
75
+ end
76
+
77
+ # INSTANCE METHODS
78
+
79
+ def formatted_fields
80
+ self.class.airtable_formatted(self.fields)
81
+ end
82
+
83
+ def save(shard=self.shard_identifier)
84
+ if self.valid?
85
+ if new_record?
86
+ results = self.class.tables(shard: shard).map{|tbl|
87
+ tbl.create self
88
+ }
89
+ # return the first version of this record that saved successfully
90
+ results.find{|x| !!x }
91
+ else
92
+ results = self.class.tables(shard: shard).map{|tbl|
93
+ tbl.update_record_fields(id, self.changed_fields)
94
+ }
95
+ results.find{|x| !!x }
96
+ end
97
+ else
98
+ false
99
+ end
100
+ end
101
+
102
+ def changed_fields
103
+ current = fields
104
+ old = self.class.find_by(id: id, shard: shard_identifier).fields
105
+ self.class.hash_diff(current, old)
106
+ end
107
+
108
+ def destroy
109
+ self.class.tables(shard: shard_identifier).map{|tbl| tbl.destroy(id) }.first
110
+ end
111
+
112
+ def update(fields)
113
+ res = self.class.tables(shard: shard_identifier).map{|tbl| tbl.update_record_fields(id, fields) }.first
114
+ res.fields.each{|field, value| self[field] = value }
115
+ true
116
+ end
117
+
118
+ def new_record?
119
+ id.nil?
120
+ end
121
+
122
+ def cache_key
123
+ "#{self.class.table_name}_#{self.id}"
124
+ end
125
+
126
+ # override if you want to write server-side model validations
127
+ def valid?
128
+ true
129
+ end
130
+
131
+ # override if you want to return validation errors
132
+ def errors
133
+ {}
134
+ end
135
+
136
+ # getter method that should return the YAML key that defines
137
+ # which shard the record lives in. Override if you're sharding
138
+ # your data, otherwise just let it return nil
139
+ def shard_identifier
140
+ nil
141
+ end
142
+
143
+ end
144
+
145
+ # raise this error when a table
146
+ # is not defined in config/airtable_data.yml
147
+ class NoSuchBase < StandardError
148
+ end
149
+
150
+ class NoConnection < StandardError
151
+ end
152
+
153
+ end
@@ -0,0 +1,100 @@
1
+ module Airmodel
2
+ module Utils
3
+
4
+ def table_name
5
+ self.name.tableize.to_sym
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 = 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[args.delete(:shard)], db[:table_name])]
16
+ # otherwise return each one
17
+ else
18
+ bases.map{|key, val| Airmodel.client.table val, db[:table_name] }
19
+ end
20
+ end
21
+
22
+ def at(base_id, table_name)
23
+ Airmodel.client.table(base_id, table_name)
24
+ end
25
+
26
+ ## converts array of generic airtable records to the instances
27
+ # of the appropriate class
28
+ def classify(list=[])
29
+ list.map{|r| self.new(r.fields) }
30
+ end
31
+
32
+ # convert blank strings to nil, [""] to [], and "true" to a boolean
33
+ def airtable_formatted(hash)
34
+ h = hash.dup
35
+ h.each{|k,v|
36
+ if v == [""]
37
+ h[k] = []
38
+ elsif v == ""
39
+ h[k] = nil
40
+ elsif v == "true"
41
+ h[k] = true
42
+ elsif v == "false"
43
+ h[k] = false
44
+ end
45
+ }
46
+ end
47
+
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
+ # Returns a hash that removes any matches with the other hash
70
+ #
71
+ # {a: {b:"c"}} - {:a=>{:b=>"c"}} # => {}
72
+ # {a: [{c:"d"},{b:"c"}]} - {:a => [{c:"d"}, {b:"d"}]} # => {:a=>[{:b=>"c"}]}
73
+ #
74
+ def hash_diff!(first_hash, second_hash)
75
+ second_hash.each_pair do |k,v|
76
+ tv = first_hash[k]
77
+ if tv.is_a?(Hash) && v.is_a?(Hash) && v.present? && tv.present?
78
+ tv.diff!(v)
79
+ elsif v.is_a?(Array) && tv.is_a?(Array) && v.present? && tv.present?
80
+ v.each_with_index do |x, i|
81
+ tv[i].diff!(x)
82
+ end
83
+ first_hash[k] = tv - [{}]
84
+ else
85
+ first_hash.delete(k) if first_hash.has_key?(k) && tv == v
86
+ end
87
+ first_hash.delete(k) if first_hash.has_key?(k) && first_hash[k].blank?
88
+ end
89
+ first_hash
90
+ end
91
+
92
+ def hash_diff(first_hash, second_hash)
93
+ hash_diff!(first_hash.dup, second_hash)
94
+ end
95
+
96
+ def -(first_hash, second_hash)
97
+ hash_diff(first_hash, second_hash)
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,3 @@
1
+ module Airmodel
2
+ VERSION = '0.0.1pre'
3
+ end
@@ -0,0 +1,18 @@
1
+ require 'spec_helper'
2
+ describe Airmodel do
3
+
4
+ describe "bases" do
5
+ it 'should load Airtable base configuration from a YAML file' do
6
+ db = Airmodel.bases
7
+ expect(db).to eq YAML.load_file("#{Airmodel.root}/config/bases.yml")
8
+ end
9
+ end
10
+
11
+ describe "client" do
12
+ it 'should return a connected Airtable API client' do
13
+ client = Airmodel.client
14
+ expect(client.class).to eq Airtable::Client
15
+ end
16
+ end
17
+
18
+ end
@@ -0,0 +1,277 @@
1
+ require 'spec_helper'
2
+
3
+ class TestModel < Airmodel::Model
4
+ end
5
+
6
+ describe TestModel do
7
+
8
+ before(:each) do
9
+ config = Airmodel.bases[:test_models]
10
+ #stub INDEX requests
11
+ stub_airtable_response!(
12
+ Regexp.new("https://api.airtable.com/v0/#{config[:bases]}/#{config[:table_name]}"),
13
+ { "records" => [{"id": "recXYZ", fields: {"color":"red"} }, {"id":"recABC", fields: {"color": "blue"} }] }
14
+ )
15
+ #stub CREATE requests
16
+ stub_airtable_response!("https://api.airtable.com/v0/appXYZ/example_table",
17
+ { "fields" => { "color" => "red", "foo" => "bar" }, "id" => "12345" },
18
+ :post
19
+ )
20
+ end
21
+
22
+ after(:each) do
23
+ FakeWeb.clean_registry
24
+ end
25
+
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
34
+ end
35
+ end
36
+
37
+ describe "at" do
38
+ it "should connect to an arbitrary base and table" do
39
+ table = TestModel.at("hello", "world")
40
+ expect(table.class).to eq Airtable::Table
41
+ end
42
+ end
43
+
44
+ describe "records" do
45
+ it "should return a list of airtable records" do
46
+ records = TestModel.records
47
+ expect(records.first.id).to eq "recXYZ"
48
+ end
49
+ end
50
+
51
+ describe "classify" do
52
+ it "should return TestModels from Airtable::Records" do
53
+ array = [Airtable::Record.new]
54
+ results = TestModel.classify(array)
55
+ expect(results.first.class).to eq TestModel
56
+ end
57
+ end
58
+
59
+ describe "all" do
60
+ it "should return a list of airtable records" do
61
+ records = TestModel.all
62
+ expect(records.first.id).to eq "recXYZ"
63
+ end
64
+ end
65
+
66
+ describe "where" do
67
+ 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
70
+ expect(records.first.color).to eq "red"
71
+ end
72
+ end
73
+
74
+ describe "find_by" do
75
+ it "should return one record that matches the supplied filters", skip_before: true do
76
+ stub_airtable_response!(
77
+ Regexp.new("https://api.airtable.com/v0/appXYZ/example_table"),
78
+ { "records" => [{"id":"recABC", fields: {"color": "blue"} }] }
79
+ )
80
+ record = TestModel.find_by(color: "blue")
81
+ expect(record.color).to eq "blue"
82
+ end
83
+ it "should call airtable-ruby's 'find' method when the filter is an id" do
84
+ stub_airtable_response! "https://api.airtable.com/v0/appXYZ/example_table/recABC", { "id":"recABC", fields: {"name": "example record"} }
85
+ record = TestModel.find_by(id: "recABC")
86
+ expect(record.name).to eq "example record"
87
+ end
88
+ end
89
+
90
+ describe "first" do
91
+ it "should return the first record" do
92
+ record = TestModel.first
93
+ expect(record.class).to eq TestModel
94
+ end
95
+ end
96
+
97
+ describe "create" do
98
+ it "should create a new record" do
99
+ record = TestModel.create(color: "red")
100
+ expect(record.id).to eq "12345"
101
+ end
102
+ end
103
+
104
+ describe "patch" do
105
+ it "should update a record" do
106
+ stub_airtable_response!(Regexp.new("/v0/appXYZ/example_table/12345"),
107
+ { "fields" => { "color" => "blue", "foo" => "bar" }, "id" => "12345" },
108
+ :patch
109
+ )
110
+ record = TestModel.create(color: "red")
111
+ record = TestModel.patch("12345", { color: "blue" })
112
+ expect(record.color).to eq "blue"
113
+ end
114
+ end
115
+
116
+ end
117
+
118
+ describe "Instance Methods" do
119
+
120
+ describe "save" do
121
+ record = TestModel.new(color: "red")
122
+ it "should create a new record" do
123
+ record.save
124
+ expect(record.id).to eq "12345"
125
+ end
126
+ it "should update an existing record" do
127
+ stub_airtable_response!("https://api.airtable.com/v0/appXYZ/example_table/12345",
128
+ { "fields" => { "color" => "red", "foo" => "bar" }, "id" => "12345" },
129
+ :get
130
+ )
131
+ stub_airtable_response!("https://api.airtable.com/v0/appXYZ/example_table/12345",
132
+ { "fields" => { "color" => "blue" }, "id" => "12345" },
133
+ :patch
134
+ )
135
+ record[:color] = "blue"
136
+ record.save
137
+ expect(record.color).to eq "blue"
138
+ end
139
+ end
140
+
141
+ describe "destroy" do
142
+ it "should delete a record" do
143
+ stub_airtable_response!("https://api.airtable.com/v0/appXYZ/example_table/12345",
144
+ { "deleted": true, "id" => "12345" },
145
+ :delete
146
+ )
147
+ response = TestModel.new(id: "12345").destroy
148
+ expect(response["deleted"]).to eq true
149
+ end
150
+ end
151
+
152
+ describe "update" do
153
+ it "should update the supplied attrs on an existing record" do
154
+ stub_airtable_response!("https://api.airtable.com/v0/appXYZ/example_table/12345",
155
+ { "fields" => { "color" => "green"}, "id" => "12345" },
156
+ :patch
157
+ )
158
+ record = TestModel.create(color: "red", id:"12345")
159
+ record.update(color: "green")
160
+ expect(record.color).to eq "green"
161
+ end
162
+ end
163
+
164
+ describe "cache_key" do
165
+ it "should return a unique key that can be used to id this record in memcached" do
166
+ record = TestModel.new(id: "recZXY")
167
+ expect(record.cache_key).to eq "test_models_recZXY"
168
+ end
169
+ end
170
+
171
+ describe "changed_fields" do
172
+ it "should return a hash of attrs changed since last save" do
173
+ stub_airtable_response!("https://api.airtable.com/v0/appXYZ/example_table/12345",
174
+ { fields: { 'color': 'red' }, "id" => "12345" },
175
+ :get
176
+ )
177
+ record = TestModel.create(color: 'red')
178
+ record[:color] = 'green'
179
+ expect(record.changed_fields).to have_key 'color'
180
+ end
181
+ end
182
+
183
+ describe "new_record?" do
184
+ it "should return true if the record hasn't been saved to airtable yet" do
185
+ record = TestModel.new(color: 'red')
186
+ expect(record.new_record?).to eq true
187
+ end
188
+ end
189
+
190
+ describe "formatted_fields" do
191
+ attrs = {
192
+ "empty_array": [''],
193
+ "empty_array_bis": [""],
194
+ "blank_string": "",
195
+ "truthy_string": "true",
196
+ "falsy_string": "false"
197
+ }
198
+ record = TestModel.new(attrs)
199
+ formatted_attrs = record.formatted_fields
200
+ it "should convert empty arrays [''] to []" do
201
+ expect(formatted_attrs["empty_array"]).to eq []
202
+ expect(formatted_attrs["empty_array_bis"]).to eq []
203
+ end
204
+ it "should convert blank strings to nil" do
205
+ expect(formatted_attrs["blank_string"]).to eq nil
206
+ end
207
+ it "should convert 'true' to a boolean" do
208
+ expect(formatted_attrs["truthy_string"]).to eq true
209
+ end
210
+ it "should convert 'false' to a boolean" do
211
+ expect(formatted_attrs["falsy_string"]).to eq false
212
+ end
213
+ end
214
+
215
+ end
216
+ end
217
+
218
+ class ShardedTestModel < Airmodel::Model
219
+ end
220
+
221
+ describe ShardedTestModel do
222
+ let(:config) { Airmodel.bases[:test_models_sharded] }
223
+
224
+ describe "class_methods" do
225
+
226
+ describe "tables" do
227
+ it "should return Airtable::Table objects for each base in the config file" do
228
+ tables = ShardedTestModel.tables
229
+ tables.each do |t|
230
+ expect(t.class).to eq Airtable::Table
231
+ expect(t.app_token.class).to eq String
232
+ end
233
+ end
234
+ it "should return just the one table matching args[:shard]" do
235
+ tables = ShardedTestModel.tables(shard: "east_coast")
236
+ end
237
+ end
238
+
239
+ describe "normalized_base_config" do
240
+ it "should return a hash from a string" do
241
+ sample_config = "appXYZ"
242
+ target = { "appXYZ" => "appXYZ" }
243
+ expect(TestModel.normalized_base_config(sample_config)).to eq target
244
+ end
245
+ it "should return a hash from an array of strings" do
246
+ sample_config = %w(appXYZ appABC)
247
+ target = { "appXYZ" => "appXYZ", "appABC" => "appABC" }
248
+ expect(TestModel.normalized_base_config(sample_config)).to eq target
249
+ end
250
+ it "should return a hash from an array of hashes" do
251
+ sample_config = [{"nyc": "appXYZ"}, {"sf": "appABC"}]
252
+ target = { "nyc": "appXYZ", "sf": "appABC" }
253
+ expect(TestModel.normalized_base_config(sample_config)).to eq target
254
+ end
255
+ it "should return itself from a hash" do
256
+ sample_config = {"nyc": "appXYZ", "sf": "appABC" }
257
+ target = { "nyc": "appXYZ", "sf": "appABC" }
258
+ expect(TestModel.normalized_base_config(sample_config)).to eq target
259
+ end
260
+ end
261
+
262
+ end
263
+ end
264
+
265
+ class BaselessTestModel < Airmodel::Model
266
+ end
267
+
268
+ describe BaselessTestModel do
269
+ describe "it should raise a NoSuchBaseError when no base is defined" do
270
+ begin
271
+ records = BaselessTestModel.records
272
+ false
273
+ rescue Airmodel::NoSuchBase
274
+ true
275
+ end
276
+ end
277
+ end
@@ -0,0 +1,37 @@
1
+ require 'airmodel'
2
+ require 'fakeweb'
3
+ require 'pry'
4
+
5
+ def stub_airtable_response!(url, response, method=:get)
6
+ FakeWeb.register_uri(
7
+ method,
8
+ url,
9
+ body: response.to_json,
10
+ content_type: "application/json"
11
+ )
12
+ end
13
+
14
+ RSpec.configure do |config|
15
+ config.color = true
16
+ end
17
+
18
+ FakeWeb.allow_net_connect = false
19
+
20
+ # enable Debug mode in Airtable
21
+ module Airtable
22
+ # Base class for authorized resources sending network requests
23
+ class Resource
24
+ include HTTParty
25
+ base_uri 'https://api.airtable.com/v0/'
26
+ debug_output $stdout
27
+
28
+ attr_reader :api_key, :app_token, :worksheet_name
29
+
30
+ def initialize(api_key, app_token, worksheet_name)
31
+ @api_key = api_key
32
+ @app_token = app_token
33
+ @worksheet_name = worksheet_name
34
+ self.class.headers({'Authorization' => "Bearer #{@api_key}"})
35
+ end
36
+ end # AuthorizedResource
37
+ end # Airtable
metadata ADDED
@@ -0,0 +1,155 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: airmodel
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1pre
5
+ platform: ruby
6
+ authors:
7
+ - chrisfrankdotfm
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2016-10-17 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '10'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '10'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3'
55
+ - !ruby/object:Gem::Dependency
56
+ name: pry
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.10'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.10'
69
+ - !ruby/object:Gem::Dependency
70
+ name: fakeweb
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.3'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.3'
83
+ - !ruby/object:Gem::Dependency
84
+ name: airtable
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: 0.0.8
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: 0.0.8
97
+ - !ruby/object:Gem::Dependency
98
+ name: activesupport
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '5.0'
104
+ type: :runtime
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '5.0'
111
+ description: Airtable data in ActiveRecord-style syntax
112
+ email:
113
+ - chris.frank@thefutureproject.org
114
+ executables: []
115
+ extensions: []
116
+ extra_rdoc_files: []
117
+ files:
118
+ - ".gitignore"
119
+ - Gemfile
120
+ - README.md
121
+ - Rakefile
122
+ - airmodel.gemspec
123
+ - config/bases.yml
124
+ - lib/airmodel.rb
125
+ - lib/airmodel/model.rb
126
+ - lib/airmodel/utils.rb
127
+ - lib/airmodel/version.rb
128
+ - spec/airmodel_spec.rb
129
+ - spec/models_spec.rb
130
+ - spec/spec_helper.rb
131
+ homepage: https://github.com/chrisfrank/airmodel
132
+ licenses:
133
+ - MIT
134
+ metadata: {}
135
+ post_install_message:
136
+ rdoc_options: []
137
+ require_paths:
138
+ - lib
139
+ required_ruby_version: !ruby/object:Gem::Requirement
140
+ requirements:
141
+ - - ">="
142
+ - !ruby/object:Gem::Version
143
+ version: '0'
144
+ required_rubygems_version: !ruby/object:Gem::Requirement
145
+ requirements:
146
+ - - ">"
147
+ - !ruby/object:Gem::Version
148
+ version: 1.3.1
149
+ requirements: []
150
+ rubyforge_project:
151
+ rubygems_version: 2.6.4
152
+ signing_key:
153
+ specification_version: 4
154
+ summary: Interact with your Airtable data using ActiveRecord-style models
155
+ test_files: []