airtable-orm 0.1.0 → 0.2.1
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/CHANGELOG.md +55 -0
- data/lib/airtable/orm/associations.rb +26 -3
- data/lib/airtable/orm/attributes.rb +5 -1
- data/lib/airtable/orm/errors.rb +4 -0
- data/lib/airtable/orm/http/rate_limiter.rb +23 -14
- data/lib/airtable/orm/persistence.rb +31 -18
- data/lib/airtable/orm/querying.rb +30 -9
- data/lib/airtable/orm/schema.rb +4 -7
- data/lib/airtable/orm/version.rb +1 -1
- data/lib/airtable/orm.rb +4 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dc3056cbf98026ea8a89b01d99c632ad01da304d85841544552b44565f897793
|
|
4
|
+
data.tar.gz: cf839f12c5812c4834604dc46c716fdb671c47110d94829fa863d82f5d56e489
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dfcd829b2a0df28d151770f30c0f51e98680bc6ab0c6f8fd5f0ff9fd2fd6ffefdef4f3c30fbe7dbcb3f3afa08689d6dffb6fc234d28a4403c3ec56f004f0abfa
|
|
7
|
+
data.tar.gz: 6c31d239c24e6c86e6303cc5eb7fdd34a5916c914c27999625c6b0c945178f9d7def6386855ba77cc13493054d14dc8abf1f087ca27474b8ff6a21f1e34f2e57
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,60 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.2.1] - 2026-07-15
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- The schema cache stores the full base schema instead of only the tables configured at fetch
|
|
8
|
+
time — a table added to `config.tables` after the cache warmed up (24 h expiry, shared store
|
|
9
|
+
in Rails hosts) now resolves immediately instead of raising `ConfigurationError` until expiry.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Record payloads are parsed by string keys directly instead of deep-converting every record
|
|
14
|
+
with `with_indifferent_access` — one avoided deep copy per record on `where`/`all`/`find`,
|
|
15
|
+
two per record on batch updates. The (undocumented) `instantiate_from_api_response` and
|
|
16
|
+
`apply_response_fields` now expect string-keyed parsed payloads; `createdTime` parsing lives
|
|
17
|
+
in the single `Persistence.parse_created_time` helper.
|
|
18
|
+
|
|
19
|
+
## [0.2.0] - 2026-07-14
|
|
20
|
+
|
|
21
|
+
Hardening release from a deep code review of the whole gem. Minor (not patch) because a few
|
|
22
|
+
observable contracts changed — see Changed below.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- `find_by` validates field references before formula interpolation — a string key containing
|
|
27
|
+
braces raises `ArgumentError` instead of injecting formula clauses.
|
|
28
|
+
- `find` rejects IDs that don't match Airtable's record-ID format with `RecordNotFound` —
|
|
29
|
+
`find(nil)` no longer falls through to the list endpoint and returns a phantom record, and
|
|
30
|
+
IDs are no longer interpolated unvalidated into the URL path.
|
|
31
|
+
- `format_formula_value` normalizes `DateTime` values to UTC (they previously matched the
|
|
32
|
+
`Date` branch and kept their local offset) and no longer mutates the caller's `Time` object
|
|
33
|
+
(`getutc` instead of `utc`).
|
|
34
|
+
- Formula escaping keeps control characters intact — Airtable formula string literals have no
|
|
35
|
+
escape sequences, so values containing newlines or tabs now actually match.
|
|
36
|
+
- The rate limiter prunes its sliding window against the current time (no more spurious ~1 s
|
|
37
|
+
pause on the first request after an idle period) and sleeps outside its mutex, so a throttled
|
|
38
|
+
thread no longer serializes every other thread's request.
|
|
39
|
+
- A 2xx response without a records array (e.g. an HTML body from a proxy) raises `ApiError`
|
|
40
|
+
instead of `NoMethodError` deep inside `where`/`count`.
|
|
41
|
+
- `preload` and `has_many` readers slice linked-ID lists to the `find_many` per-request cap
|
|
42
|
+
(500), so associations with more links load instead of raising `ArgumentError`.
|
|
43
|
+
- `Airtable::ORM.configure` invalidates the memoized HTTP client, so reconfiguring after the
|
|
44
|
+
first request (e.g. rotating the API key or changing timeouts) takes effect instead of being
|
|
45
|
+
silently ignored.
|
|
46
|
+
|
|
47
|
+
### Changed
|
|
48
|
+
|
|
49
|
+
- Invalid `sort:` arguments (anything but a Hash or Array) raise `ArgumentError` instead of
|
|
50
|
+
being silently discarded — `last(sort: :field)` previously ran unsorted and returned an
|
|
51
|
+
arbitrary record.
|
|
52
|
+
- A configured table missing from the fetched base schema raises the new
|
|
53
|
+
`Airtable::ORM::ConfigurationError` with a diagnostic message instead of `NoMethodError`
|
|
54
|
+
on `nil`.
|
|
55
|
+
- A stale `belongs_to` link to a deleted record reads as `nil` (matching the preloaded path)
|
|
56
|
+
instead of raising `RecordNotFound`.
|
|
57
|
+
|
|
3
58
|
## [0.1.0] - 2026-07-10
|
|
4
59
|
|
|
5
60
|
Initial release — the generic Airtable client/ORM extracted from the EUCS Mono application:
|
|
@@ -5,6 +5,14 @@ module Airtable
|
|
|
5
5
|
module Associations
|
|
6
6
|
extend ActiveSupport::Concern
|
|
7
7
|
|
|
8
|
+
# Fetch linked records via find_many, slicing to its per-request ID cap so an
|
|
9
|
+
# association (or preload union) with more links than the cap still loads.
|
|
10
|
+
def self.fetch_linked_records(klass, ids)
|
|
11
|
+
records = ids.each_slice(Airtable::ORM::Persistence::MAX_FIND_MANY_IDS)
|
|
12
|
+
.flat_map { |slice| klass.find_many(slice).to_a }
|
|
13
|
+
Airtable::ORM::Collection.new(records, model_class: klass)
|
|
14
|
+
end
|
|
15
|
+
|
|
8
16
|
# Requires Attributes concern. Uses read_raw_attribute/write_raw_attribute to
|
|
9
17
|
# bypass accessors and avoid infinite recursion when association names match attributes.
|
|
10
18
|
|
|
@@ -57,7 +65,11 @@ module Airtable
|
|
|
57
65
|
memoize_association(association_name) do
|
|
58
66
|
ids = read_linked_ids(foreign_key)
|
|
59
67
|
klass = class_name.constantize
|
|
60
|
-
ids.empty?
|
|
68
|
+
if ids.empty?
|
|
69
|
+
Airtable::ORM::Collection.new([], model_class: klass)
|
|
70
|
+
else
|
|
71
|
+
Airtable::ORM::Associations.fetch_linked_records(klass, ids)
|
|
72
|
+
end
|
|
61
73
|
end
|
|
62
74
|
end
|
|
63
75
|
|
|
@@ -91,7 +103,13 @@ module Airtable
|
|
|
91
103
|
define_method(association_name) do
|
|
92
104
|
memoize_association(association_name) do
|
|
93
105
|
id = read_linked_ids(foreign_key).first
|
|
94
|
-
|
|
106
|
+
begin
|
|
107
|
+
id ? class_name.constantize.find(id) : nil
|
|
108
|
+
rescue Airtable::ORM::RecordNotFound
|
|
109
|
+
# A stale link to a deleted record reads as nil, matching the preloaded
|
|
110
|
+
# path (find_many simply omits missing records).
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
95
113
|
end
|
|
96
114
|
end
|
|
97
115
|
|
|
@@ -135,7 +153,12 @@ module Airtable
|
|
|
135
153
|
records.filter_map { |r| r.send(:read_linked_ids, foreign_key).first }.uniq
|
|
136
154
|
end
|
|
137
155
|
|
|
138
|
-
fetched_by_id =
|
|
156
|
+
fetched_by_id =
|
|
157
|
+
if all_ids.empty?
|
|
158
|
+
{}
|
|
159
|
+
else
|
|
160
|
+
Airtable::ORM::Associations.fetch_linked_records(klass, all_ids).index_by(&:id)
|
|
161
|
+
end
|
|
139
162
|
|
|
140
163
|
records.each do |record|
|
|
141
164
|
ids = record.send(:read_linked_ids, foreign_key)
|
|
@@ -47,7 +47,11 @@ module Airtable
|
|
|
47
47
|
def schema
|
|
48
48
|
return {} unless table_name
|
|
49
49
|
|
|
50
|
-
@schema ||= Airtable::ORM::Schema.fetch(base_id)[table_id]
|
|
50
|
+
@schema ||= Airtable::ORM::Schema.fetch(base_id)[table_id] || raise(
|
|
51
|
+
Airtable::ORM::ConfigurationError,
|
|
52
|
+
"No table #{table_id.inspect} (#{table_name.inspect}) in the fetched schema for base " \
|
|
53
|
+
"#{base_id.inspect} — check config.tables against the Airtable base"
|
|
54
|
+
)
|
|
51
55
|
end
|
|
52
56
|
|
|
53
57
|
# Clear the memoized schema cache
|
data/lib/airtable/orm/errors.rb
CHANGED
|
@@ -14,6 +14,10 @@ module Airtable
|
|
|
14
14
|
# Raised when an invalid attribute is provided
|
|
15
15
|
class InvalidAttributeError < Error; end
|
|
16
16
|
|
|
17
|
+
# Raised when the host configuration doesn't match the Airtable base
|
|
18
|
+
# (e.g. a configured table ID absent from the fetched schema)
|
|
19
|
+
class ConfigurationError < Error; end
|
|
20
|
+
|
|
17
21
|
# Raised when a record fails validation.
|
|
18
22
|
# Use the #record method to retrieve the record which did not validate.
|
|
19
23
|
class RecordInvalid < Error
|
|
@@ -13,11 +13,7 @@ module Airtable
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def call(env)
|
|
16
|
-
@
|
|
17
|
-
wait if too_many_requests_in_last_second?
|
|
18
|
-
@requests << Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
19
|
-
@requests.shift if @rps && @requests.size > @rps
|
|
20
|
-
end
|
|
16
|
+
throttle if @rps
|
|
21
17
|
@app.call(env)
|
|
22
18
|
end
|
|
23
19
|
|
|
@@ -27,20 +23,33 @@ module Airtable
|
|
|
27
23
|
|
|
28
24
|
private
|
|
29
25
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
26
|
+
# Compute the wait under the lock but sleep outside it, so a throttled thread
|
|
27
|
+
# doesn't serialize every other thread's request behind its sleep.
|
|
28
|
+
def throttle
|
|
29
|
+
wait_time = @mutex.synchronize do
|
|
30
|
+
now = monotonic_now
|
|
31
|
+
prune(now)
|
|
32
|
+
1.0 - (now - @requests.first) if @requests.size >= @rps
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
@sleeper.call(wait_time) if wait_time&.positive?
|
|
33
36
|
|
|
34
|
-
|
|
37
|
+
@mutex.synchronize do
|
|
38
|
+
now = monotonic_now
|
|
39
|
+
prune(now)
|
|
40
|
+
@requests << now
|
|
41
|
+
@requests.shift while @requests.size > @rps
|
|
42
|
+
end
|
|
35
43
|
end
|
|
36
44
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
45
|
+
# Drop timestamps that fell out of the 1-second sliding window — without this,
|
|
46
|
+
# a full window recorded before an idle period would throttle the next request.
|
|
47
|
+
def prune(now)
|
|
48
|
+
@requests.shift while @requests.any? && now - @requests.first >= 1.0
|
|
40
49
|
end
|
|
41
50
|
|
|
42
|
-
def
|
|
43
|
-
|
|
51
|
+
def monotonic_now
|
|
52
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
44
53
|
end
|
|
45
54
|
end
|
|
46
55
|
end
|
|
@@ -17,6 +17,10 @@ module Airtable
|
|
|
17
17
|
BATCH_SIZE = 10
|
|
18
18
|
MAX_FIND_MANY_IDS = 500
|
|
19
19
|
|
|
20
|
+
# Airtable record IDs: rec + alphanumeric characters. Validated before any ID reaches
|
|
21
|
+
# a URL path or formula interpolation (injection guard).
|
|
22
|
+
RECORD_ID_FORMAT = /\Arec[a-zA-Z0-9_-]+\z/
|
|
23
|
+
|
|
20
24
|
class_methods do
|
|
21
25
|
# Batch update records (up to 10 per API request).
|
|
22
26
|
# Triages locally: new/invalid → failed, unchanged → skipped, changed → sent.
|
|
@@ -54,8 +58,13 @@ module Airtable
|
|
|
54
58
|
"#{full_path}?returnFieldsByFieldId=true"
|
|
55
59
|
end
|
|
56
60
|
|
|
57
|
-
# Find a record by ID
|
|
61
|
+
# Find a record by ID. Rejects malformed IDs up front — an ID that can never exist
|
|
62
|
+
# must not reach the URL path (nil would hit the LIST endpoint, "recX/.." another one).
|
|
58
63
|
def find(id)
|
|
64
|
+
unless id.to_s.match?(RECORD_ID_FORMAT)
|
|
65
|
+
raise Airtable::ORM::RecordNotFound, "Couldn't find record with id=#{id.inspect}"
|
|
66
|
+
end
|
|
67
|
+
|
|
59
68
|
response = client.connection.get(api_path(id))
|
|
60
69
|
parsed_response = response.body
|
|
61
70
|
|
|
@@ -102,21 +111,28 @@ module Airtable
|
|
|
102
111
|
record
|
|
103
112
|
end
|
|
104
113
|
|
|
105
|
-
# Instantiate a record from API
|
|
114
|
+
# Instantiate a record from a parsed API payload. Expects string keys (the shape the
|
|
115
|
+
# JSON middleware produces) — this runs once per record on every where/all/find, so no
|
|
116
|
+
# per-record deep conversion here.
|
|
106
117
|
def instantiate_from_api_response(response)
|
|
107
|
-
|
|
108
|
-
symbol_attrs = fields_to_symbol_attributes(data[:fields])
|
|
109
|
-
|
|
110
|
-
record = new(**symbol_attrs)
|
|
118
|
+
record = new(**fields_to_symbol_attributes(response["fields"]))
|
|
111
119
|
record.send(:assign_persistence_state,
|
|
112
|
-
id:
|
|
113
|
-
created_at:
|
|
120
|
+
id: response["id"],
|
|
121
|
+
created_at: parse_created_time(response),
|
|
114
122
|
persisted: true)
|
|
115
123
|
record.clear_changes_information
|
|
116
124
|
|
|
117
125
|
record
|
|
118
126
|
end
|
|
119
127
|
|
|
128
|
+
# @api private — createdTime is ISO-8601 UTC, parsed with Time.iso8601 (Time.zone
|
|
129
|
+
# doesn't exist outside Rails); nil-guarded. The single parse site for both
|
|
130
|
+
# instantiate_from_api_response and #apply_response_fields.
|
|
131
|
+
def parse_created_time(data)
|
|
132
|
+
created_time = data["createdTime"]
|
|
133
|
+
created_time ? Time.iso8601(created_time.to_s) : nil
|
|
134
|
+
end
|
|
135
|
+
|
|
120
136
|
# Convert API field IDs to symbol attributes.
|
|
121
137
|
# field_mapping already contains only declared attributes.
|
|
122
138
|
def fields_to_symbol_attributes(fields)
|
|
@@ -139,9 +155,8 @@ module Airtable
|
|
|
139
155
|
parsed = response.body
|
|
140
156
|
|
|
141
157
|
if response.success?
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
response_by_id = response_records.index_by { |r| r[:id] }
|
|
158
|
+
response_records = (parsed.is_a?(Hash) && parsed["records"]) || []
|
|
159
|
+
response_by_id = response_records.index_by { |r| r["id"] }
|
|
145
160
|
|
|
146
161
|
batch.each do |record|
|
|
147
162
|
record_data = response_by_id[record.id]
|
|
@@ -154,7 +169,7 @@ module Airtable
|
|
|
154
169
|
end
|
|
155
170
|
else
|
|
156
171
|
batch.each { |record| result.failed << record }
|
|
157
|
-
error_message = parsed.is_a?(Hash) ? parsed.
|
|
172
|
+
error_message = parsed.is_a?(Hash) ? parsed.dig("error", "message") : parsed
|
|
158
173
|
ORM.config.logger.error(
|
|
159
174
|
"Airtable batch update failed: HTTP #{response.status}: #{error_message.to_s.truncate(200)}"
|
|
160
175
|
)
|
|
@@ -165,11 +180,10 @@ module Airtable
|
|
|
165
180
|
end
|
|
166
181
|
|
|
167
182
|
# Validate that record IDs match Airtable's format to prevent formula injection
|
|
168
|
-
# Airtable record IDs follow the pattern: rec + alphanumeric characters
|
|
169
183
|
def validate_record_ids(ids)
|
|
170
184
|
ids.map do |id|
|
|
171
185
|
id_str = id.to_s
|
|
172
|
-
unless id_str.match?(
|
|
186
|
+
unless id_str.match?(RECORD_ID_FORMAT)
|
|
173
187
|
raise ArgumentError,
|
|
174
188
|
"Invalid Airtable record ID: #{id_str.inspect}"
|
|
175
189
|
end
|
|
@@ -304,13 +318,12 @@ module Airtable
|
|
|
304
318
|
def apply_response_fields(data)
|
|
305
319
|
@previously_new_record = new_record?
|
|
306
320
|
|
|
307
|
-
|
|
308
|
-
symbol_attrs = self.class.fields_to_symbol_attributes(data[:fields])
|
|
321
|
+
symbol_attrs = self.class.fields_to_symbol_attributes(data["fields"])
|
|
309
322
|
symbol_attrs.each { |key, value| write_raw_attribute(key, value) }
|
|
310
323
|
|
|
311
324
|
assign_persistence_state(
|
|
312
|
-
id: data[
|
|
313
|
-
created_at:
|
|
325
|
+
id: data["id"],
|
|
326
|
+
created_at: self.class.parse_created_time(data),
|
|
314
327
|
persisted: true
|
|
315
328
|
)
|
|
316
329
|
changes_applied
|
|
@@ -38,8 +38,7 @@ module Airtable
|
|
|
38
38
|
raise ArgumentError, "find_by requires at least one condition" if conditions.empty?
|
|
39
39
|
|
|
40
40
|
clauses = conditions.map do |field, value|
|
|
41
|
-
|
|
42
|
-
"{#{field_id}} = #{format_formula_value(value)}"
|
|
41
|
+
"{#{formula_field_reference(field)}} = #{format_formula_value(value)}"
|
|
43
42
|
end
|
|
44
43
|
formula = clauses.size == 1 ? clauses.first : "AND(#{clauses.join(", ")})"
|
|
45
44
|
first(formula: formula)
|
|
@@ -65,21 +64,20 @@ module Airtable
|
|
|
65
64
|
when TrueClass then "TRUE()"
|
|
66
65
|
when FalseClass then "FALSE()"
|
|
67
66
|
when Integer, Float then value.to_s
|
|
67
|
+
# Time/DateTime before Date: DateTime is a Date subclass and must be UTC-normalized.
|
|
68
|
+
when Time, DateTime then "'#{value.getutc.iso8601}'"
|
|
68
69
|
when Date then "'#{value.iso8601}'"
|
|
69
|
-
when Time, DateTime then "'#{value.utc.iso8601}'"
|
|
70
70
|
else "'#{escape_formula_value(value)}'"
|
|
71
71
|
end
|
|
72
72
|
end
|
|
73
73
|
|
|
74
74
|
# Escape a value for use in an Airtable formula string literal.
|
|
75
|
-
#
|
|
75
|
+
# Only backslashes and single quotes — Airtable formula string literals have no
|
|
76
|
+
# escape sequences for control characters, so a newline stays a real newline.
|
|
76
77
|
def escape_formula_value(value)
|
|
77
78
|
value.to_s
|
|
78
79
|
.gsub("\\", "\\\\\\\\") # backslashes first
|
|
79
80
|
.gsub("'", "\\\\'") # single quotes
|
|
80
|
-
.gsub("\n", "\\n") # newlines
|
|
81
|
-
.gsub("\r", "\\r") # carriage returns
|
|
82
|
-
.gsub("\t", "\\t") # tabs
|
|
83
81
|
end
|
|
84
82
|
|
|
85
83
|
private
|
|
@@ -116,7 +114,15 @@ module Airtable
|
|
|
116
114
|
|
|
117
115
|
Airtable::ORM::Http::Client.raise_api_error(response.status, parsed_response) unless response.success?
|
|
118
116
|
|
|
119
|
-
|
|
117
|
+
records = parsed_response.is_a?(Hash) ? parsed_response["records"] : nil
|
|
118
|
+
unless records.is_a?(Array)
|
|
119
|
+
raise Airtable::ORM::ApiError.new(
|
|
120
|
+
"Malformed Airtable response: expected a records array (HTTP #{response.status})",
|
|
121
|
+
status: response.status, response: parsed_response
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
yield records
|
|
120
126
|
|
|
121
127
|
break unless paginate && parsed_response["offset"]
|
|
122
128
|
|
|
@@ -142,7 +148,7 @@ module Airtable
|
|
|
142
148
|
# Accepts: { field: :asc } or [[:field1, :desc], [:field2, :asc]]
|
|
143
149
|
# Converts symbol attributes to their Airtable field IDs
|
|
144
150
|
def normalize_sort_options(sort)
|
|
145
|
-
|
|
151
|
+
raise_invalid_sort(sort) unless sort.is_a?(Hash) || sort.is_a?(Array)
|
|
146
152
|
|
|
147
153
|
sort.map { |field, direction| { field: resolve_field_id(field), direction: direction.to_s } }
|
|
148
154
|
end
|
|
@@ -166,6 +172,15 @@ module Airtable
|
|
|
166
172
|
fields.map { |field| resolve_field_id(field) }
|
|
167
173
|
end
|
|
168
174
|
|
|
175
|
+
# Resolve a field for interpolation inside {} in a formula. Braces would terminate
|
|
176
|
+
# the reference and let a user-supplied field name inject arbitrary formula clauses.
|
|
177
|
+
def formula_field_reference(field)
|
|
178
|
+
field_id = resolve_field_id(field)
|
|
179
|
+
raise ArgumentError, "Invalid field reference for formula: #{field_id.inspect}" if field_id.match?(/[{}]/)
|
|
180
|
+
|
|
181
|
+
field_id
|
|
182
|
+
end
|
|
183
|
+
|
|
169
184
|
# Reverse sort order for last() method
|
|
170
185
|
def reverse_sort_order(sort)
|
|
171
186
|
return nil unless sort
|
|
@@ -175,8 +190,14 @@ module Airtable
|
|
|
175
190
|
case sort
|
|
176
191
|
when Hash then sort.transform_values(&flip)
|
|
177
192
|
when Array then sort.map { |field, dir| [field, flip.call(dir)] }
|
|
193
|
+
else raise_invalid_sort(sort)
|
|
178
194
|
end
|
|
179
195
|
end
|
|
196
|
+
|
|
197
|
+
def raise_invalid_sort(sort)
|
|
198
|
+
raise ArgumentError,
|
|
199
|
+
"sort must be a Hash or Array of [field, direction] pairs, got #{sort.inspect}"
|
|
200
|
+
end
|
|
180
201
|
end
|
|
181
202
|
end
|
|
182
203
|
end
|
data/lib/airtable/orm/schema.rb
CHANGED
|
@@ -19,10 +19,6 @@ module Airtable
|
|
|
19
19
|
|
|
20
20
|
private
|
|
21
21
|
|
|
22
|
-
def required_table_ids
|
|
23
|
-
ORM.config.table_ids
|
|
24
|
-
end
|
|
25
|
-
|
|
26
22
|
def fetch_from_api(base_id)
|
|
27
23
|
response = client.connection.get("/v0/meta/bases/#{base_id}/tables")
|
|
28
24
|
parsed_response = response.body
|
|
@@ -34,12 +30,13 @@ module Airtable
|
|
|
34
30
|
end
|
|
35
31
|
end
|
|
36
32
|
|
|
33
|
+
# Index the FULL base schema — no filtering by the tables configured at fetch time.
|
|
34
|
+
# The cached payload outlives the config (24h, shared store in Rails hosts), so a
|
|
35
|
+
# fetch-time filter would hide tables added to config after the cache warmed up.
|
|
36
|
+
# Reads are keyed lookups (fetch(base_id)[table_id]), so no read-time filter is needed.
|
|
37
37
|
def indexed_schema(parsed_response)
|
|
38
38
|
tables = parsed_response.deep_symbolize_keys[:tables]
|
|
39
39
|
|
|
40
|
-
# Filter only required tables
|
|
41
|
-
tables.select! { |table| required_table_ids.include?(table[:id]) }
|
|
42
|
-
|
|
43
40
|
# Transform fields into hash indexed by field id
|
|
44
41
|
tables.each do |table|
|
|
45
42
|
table[:fields] = table[:fields].index_by { |field| field[:id] }
|
data/lib/airtable/orm/version.rb
CHANGED
data/lib/airtable/orm.rb
CHANGED
|
@@ -29,6 +29,10 @@ module Airtable
|
|
|
29
29
|
|
|
30
30
|
def configure
|
|
31
31
|
yield config
|
|
32
|
+
ensure
|
|
33
|
+
# The client memoizes api_key/timeouts/rate_limit at first request — without this
|
|
34
|
+
# reset a later configure (e.g. rotating the API key) would be silently ignored.
|
|
35
|
+
Http::Client.reset!
|
|
32
36
|
end
|
|
33
37
|
|
|
34
38
|
# True when an API key is configured — hosts gate network-touching hooks on this.
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: airtable-orm
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Roman Sklenar
|
|
@@ -125,7 +125,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
125
125
|
- !ruby/object:Gem::Version
|
|
126
126
|
version: '0'
|
|
127
127
|
requirements: []
|
|
128
|
-
rubygems_version: 4.0.
|
|
128
|
+
rubygems_version: 4.0.16
|
|
129
129
|
specification_version: 4
|
|
130
130
|
summary: ActiveModel-style ORM for the Airtable API
|
|
131
131
|
test_files: []
|