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 +4 -4
- data/README.md +18 -17
- data/config/bases.yml +6 -8
- data/lib/airmodel.rb +2 -0
- data/lib/airmodel/associable.rb +37 -0
- data/lib/airmodel/model.rb +47 -62
- data/lib/airmodel/query.rb +74 -0
- data/lib/airmodel/utils.rb +24 -35
- data/lib/airmodel/version.rb +1 -1
- data/spec/associable_spec.rb +70 -0
- data/spec/{models_spec.rb → model_spec.rb} +49 -102
- data/spec/query_spec.rb +45 -0
- metadata +8 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 13bc3270f4a9eee17e8559404cdb9f0e9917f899
|
4
|
+
data.tar.gz: 8ddb417cbac7e452de3f7ca469007370c2a7f6e6
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
:
|
33
|
+
:base_id: appXYZ123ABC
|
34
34
|
:albums:
|
35
35
|
:table_name: Albums
|
36
|
-
:
|
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.
|
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/
|
85
|
+
Add a passing test to spec/model_spec.rb, then send a pull
|
83
86
|
request. Thanks!
|
84
87
|
|
85
88
|
|
86
|
-
|
87
|
-
|
data/config/bases.yml
CHANGED
@@ -1,8 +1,6 @@
|
|
1
|
-
:
|
2
|
-
:table_name: "
|
3
|
-
:
|
4
|
-
:
|
5
|
-
:table_name: "
|
6
|
-
:
|
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
|
data/lib/airmodel.rb
CHANGED
@@ -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
|
data/lib/airmodel/model.rb
CHANGED
@@ -1,32 +1,26 @@
|
|
1
1
|
module Airmodel
|
2
2
|
class Model < Airtable::Record
|
3
3
|
extend Utils
|
4
|
+
extend Associable
|
4
5
|
|
5
|
-
|
6
|
-
|
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
|
-
|
15
|
-
|
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
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
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
|
32
|
+
def self.find(id)
|
39
33
|
if id.is_a? String
|
40
|
-
results = self.classify
|
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
|
-
|
45
|
-
|
46
|
-
|
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
|
49
|
+
results = self.classify table.find(filters[:id])
|
55
50
|
else
|
56
51
|
formula = "AND(" + filters.map{|k,v| "{#{k}}='#{v}'" }.join(',') + ")"
|
57
|
-
results =
|
58
|
-
|
59
|
-
|
60
|
-
|
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
|
-
|
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
|
-
|
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
|
91
|
-
r =
|
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
|
101
|
+
def save
|
104
102
|
if self.valid?
|
105
103
|
if new_record?
|
106
|
-
|
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
|
-
|
113
|
-
|
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
|
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.
|
121
|
+
self.table.destroy(id)
|
130
122
|
end
|
131
123
|
|
132
124
|
def update(fields)
|
133
|
-
res = self.
|
125
|
+
res = self.table.update_record_fields(id, fields)
|
134
126
|
res.fields.each{|field, value| self[field] = value }
|
135
|
-
|
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
|
+
|
data/lib/airmodel/utils.rb
CHANGED
@@ -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
|
-
|
9
|
-
|
10
|
-
|
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
|
-
|
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(
|
29
|
-
|
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
|
data/lib/airmodel/version.rb
CHANGED
@@ -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
|
3
|
+
class Album < Airmodel::Model
|
4
4
|
end
|
5
5
|
|
6
|
-
describe
|
6
|
+
describe Album do
|
7
7
|
|
8
8
|
before(:each) do
|
9
|
-
config = Airmodel.bases[:
|
9
|
+
config = Airmodel.bases[:albums]
|
10
10
|
#stub INDEX requests
|
11
11
|
stub_airtable_response!(
|
12
|
-
Regexp.new("https://api.airtable.com/v0/#{config[:
|
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/
|
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 "
|
28
|
-
it "should return Airtable::Table
|
29
|
-
|
30
|
-
|
31
|
-
|
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 =
|
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
|
43
|
+
it "should return albums from Airtable::Records" do
|
53
44
|
array = [Airtable::Record.new]
|
54
|
-
results =
|
55
|
-
expect(results.first.class).to eq
|
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 =
|
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 =
|
69
|
-
expect(records.first.class).to eq
|
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/
|
77
|
-
record =
|
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 =
|
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/
|
82
|
+
Regexp.new("https://api.airtable.com/v0/appXYZ/albums"),
|
91
83
|
{ "records" => [{"id":"recABC", fields: {"color": "blue"} }] }
|
92
84
|
)
|
93
|
-
record =
|
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/
|
98
|
-
record =
|
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 =
|
106
|
-
expect(record.class).to eq
|
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 =
|
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/
|
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 =
|
124
|
-
record =
|
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 =
|
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/
|
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/
|
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/
|
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 =
|
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/
|
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 =
|
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 =
|
180
|
-
expect(record.cache_key).to eq "
|
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/
|
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 =
|
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 =
|
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 =
|
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
|
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
|
228
|
+
describe BaselessModel do
|
282
229
|
describe "it should raise a NoSuchBaseError when no base is defined" do
|
283
230
|
begin
|
284
|
-
records =
|
231
|
+
records = BaselessModel.all
|
285
232
|
false
|
286
233
|
rescue Airmodel::NoSuchBase
|
287
234
|
true
|
data/spec/query_spec.rb
ADDED
@@ -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
|
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-
|
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/
|
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.
|
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
|