airmodel 0.0.2 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +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
|