airmodel 0.0.1pre
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 +7 -0
- data/.gitignore +22 -0
- data/Gemfile +2 -0
- data/README.md +81 -0
- data/Rakefile +6 -0
- data/airmodel.gemspec +27 -0
- data/config/bases.yml +8 -0
- data/lib/airmodel.rb +44 -0
- data/lib/airmodel/model.rb +153 -0
- data/lib/airmodel/utils.rb +100 -0
- data/lib/airmodel/version.rb +3 -0
- data/spec/airmodel_spec.rb +18 -0
- data/spec/models_spec.rb +277 -0
- data/spec/spec_helper.rb +37 -0
- metadata +155 -0
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
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
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
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,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
|
data/spec/models_spec.rb
ADDED
@@ -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
|
data/spec/spec_helper.rb
ADDED
@@ -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: []
|