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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 22ca62058d684f1b34fbf154b50afb7c8a5c37659b5235fef23614f97642d6c1
4
- data.tar.gz: 554987924ef0c43b3692adc311cdf72dd367d927e9396f92af7dc4ba1a2f12d5
3
+ metadata.gz: dc3056cbf98026ea8a89b01d99c632ad01da304d85841544552b44565f897793
4
+ data.tar.gz: cf839f12c5812c4834604dc46c716fdb671c47110d94829fa863d82f5d56e489
5
5
  SHA512:
6
- metadata.gz: 52e3f9c34c7f34d6ffa17e2f85ebc97a072917611bac555ee300852c6b20799dd22c448485e69e07ab3d14cf85b88984fb6d52c054893b2a0f24e980293e2bfb
7
- data.tar.gz: bc2d2694f67595eac95ba645056ff3d2e9778d38dcf072737fd8312a2677eb046dbddbf9414238994f0f9c7f0a9d10c7f343a7f1fb7f97c32279e131b03818bd
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? ? 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
 
@@ -102,21 +111,28 @@ module Airtable
102
111
  record
103
112
  end
104
113
 
105
- # Instantiate a record from API response
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
- data = response.with_indifferent_access
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: data[:id],
113
- created_at: data[:createdTime] ? Time.iso8601(data[:createdTime].to_s) : nil,
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
- parsed = parsed.with_indifferent_access if parsed.is_a?(Hash)
143
- response_records = parsed[:records] || []
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.with_indifferent_access.dig(:error, :message) : 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?(/\Arec[a-zA-Z0-9_-]+\z/)
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
- data = data.with_indifferent_access
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[:id],
313
- created_at: data[:createdTime] ? Time.iso8601(data[:createdTime].to_s) : nil,
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
- 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
@@ -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] }
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Airtable
4
4
  module ORM
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.1"
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.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.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: []