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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 22ca62058d684f1b34fbf154b50afb7c8a5c37659b5235fef23614f97642d6c1
4
- data.tar.gz: 554987924ef0c43b3692adc311cdf72dd367d927e9396f92af7dc4ba1a2f12d5
3
+ metadata.gz: 970cedc7b68168b8add9fa80225982de0e902758bfb0dbfb7b3d04184c292871
4
+ data.tar.gz: 9e45c6326f0daa9b07eee7f6aa81601217437f40022ca05a34c85d0fd17714f1
5
5
  SHA512:
6
- metadata.gz: 52e3f9c34c7f34d6ffa17e2f85ebc97a072917611bac555ee300852c6b20799dd22c448485e69e07ab3d14cf85b88984fb6d52c054893b2a0f24e980293e2bfb
7
- data.tar.gz: bc2d2694f67595eac95ba645056ff3d2e9778d38dcf072737fd8312a2677eb046dbddbf9414238994f0f9c7f0a9d10c7f343a7f1fb7f97c32279e131b03818bd
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? ? Airtable::ORM::Collection.new([], model_class: klass) : klass.find_many(ids)
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
- id ? class_name.constantize.find(id) : nil
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 = all_ids.empty? ? {} : klass.find_many(all_ids).index_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
@@ -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
- @mutex.synchronize do
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
- def too_many_requests_in_last_second?
31
- return false unless @rps
32
- return false unless @requests.size >= @rps
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
- window_span < 1.0
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
- def wait
38
- wait_time = 1.0 - window_span
39
- @sleeper.call(wait_time)
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 window_span
43
- @requests.last - @requests.first
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?(/\Arec[a-zA-Z0-9_-]+\z/)
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
- field_id = resolve_field_id(field)
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
- # Handles backslashes, single quotes, and control characters.
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
- yield parsed_response["records"]
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
- return [] unless sort.respond_to?(:map)
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Airtable
4
4
  module ORM
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
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.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.12
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: []