airtable-orm 0.1.0 → 0.2.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/CHANGELOG.md +39 -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 +11 -3
- data/lib/airtable/orm/querying.rb +30 -9
- 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: 970cedc7b68168b8add9fa80225982de0e902758bfb0dbfb7b3d04184c292871
|
|
4
|
+
data.tar.gz: 9e45c6326f0daa9b07eee7f6aa81601217437f40022ca05a34c85d0fd17714f1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3812676e76a07ae378a285030f30c8175fc9bb23670d626d2103e44611894785f962554d3cd9737c9c88f2ba6aaf9b8d80c8d5ee6e9401ca0a5da6b413ba7dcd
|
|
7
|
+
data.tar.gz: 0bc1ea55739db84a90a92b8389d3df1c37121e496838388ec863621e3e8fb13adc6cbb54ee19e9f09f2c0eeebf8b20dd70521a6e827b6b6d384dca4230f9f35d
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.2.0] - 2026-07-14
|
|
4
|
+
|
|
5
|
+
Hardening release from a deep code review of the whole gem. Minor (not patch) because a few
|
|
6
|
+
observable contracts changed — see Changed below.
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- `find_by` validates field references before formula interpolation — a string key containing
|
|
11
|
+
braces raises `ArgumentError` instead of injecting formula clauses.
|
|
12
|
+
- `find` rejects IDs that don't match Airtable's record-ID format with `RecordNotFound` —
|
|
13
|
+
`find(nil)` no longer falls through to the list endpoint and returns a phantom record, and
|
|
14
|
+
IDs are no longer interpolated unvalidated into the URL path.
|
|
15
|
+
- `format_formula_value` normalizes `DateTime` values to UTC (they previously matched the
|
|
16
|
+
`Date` branch and kept their local offset) and no longer mutates the caller's `Time` object
|
|
17
|
+
(`getutc` instead of `utc`).
|
|
18
|
+
- Formula escaping keeps control characters intact — Airtable formula string literals have no
|
|
19
|
+
escape sequences, so values containing newlines or tabs now actually match.
|
|
20
|
+
- The rate limiter prunes its sliding window against the current time (no more spurious ~1 s
|
|
21
|
+
pause on the first request after an idle period) and sleeps outside its mutex, so a throttled
|
|
22
|
+
thread no longer serializes every other thread's request.
|
|
23
|
+
- A 2xx response without a records array (e.g. an HTML body from a proxy) raises `ApiError`
|
|
24
|
+
instead of `NoMethodError` deep inside `where`/`count`.
|
|
25
|
+
- `preload` and `has_many` readers slice linked-ID lists to the `find_many` per-request cap
|
|
26
|
+
(500), so associations with more links load instead of raising `ArgumentError`.
|
|
27
|
+
- `Airtable::ORM.configure` invalidates the memoized HTTP client, so reconfiguring after the
|
|
28
|
+
first request (e.g. rotating the API key or changing timeouts) takes effect instead of being
|
|
29
|
+
silently ignored.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- Invalid `sort:` arguments (anything but a Hash or Array) raise `ArgumentError` instead of
|
|
34
|
+
being silently discarded — `last(sort: :field)` previously ran unsorted and returned an
|
|
35
|
+
arbitrary record.
|
|
36
|
+
- A configured table missing from the fetched base schema raises the new
|
|
37
|
+
`Airtable::ORM::ConfigurationError` with a diagnostic message instead of `NoMethodError`
|
|
38
|
+
on `nil`.
|
|
39
|
+
- A stale `belongs_to` link to a deleted record reads as `nil` (matching the preloaded path)
|
|
40
|
+
instead of raising `RecordNotFound`.
|
|
41
|
+
|
|
3
42
|
## [0.1.0] - 2026-07-10
|
|
4
43
|
|
|
5
44
|
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
|
|
|
@@ -165,11 +174,10 @@ module Airtable
|
|
|
165
174
|
end
|
|
166
175
|
|
|
167
176
|
# Validate that record IDs match Airtable's format to prevent formula injection
|
|
168
|
-
# Airtable record IDs follow the pattern: rec + alphanumeric characters
|
|
169
177
|
def validate_record_ids(ids)
|
|
170
178
|
ids.map do |id|
|
|
171
179
|
id_str = id.to_s
|
|
172
|
-
unless id_str.match?(
|
|
180
|
+
unless id_str.match?(RECORD_ID_FORMAT)
|
|
173
181
|
raise ArgumentError,
|
|
174
182
|
"Invalid Airtable record ID: #{id_str.inspect}"
|
|
175
183
|
end
|
|
@@ -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/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.
|
|
4
|
+
version: 0.2.0
|
|
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: []
|