airtable-orm 0.2.0 → 0.2.2

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: 970cedc7b68168b8add9fa80225982de0e902758bfb0dbfb7b3d04184c292871
4
- data.tar.gz: 9e45c6326f0daa9b07eee7f6aa81601217437f40022ca05a34c85d0fd17714f1
3
+ metadata.gz: be3a4c1ecff6395abd45d69731ac804de74bf2fdd06127c2c4b5a9d3c9417c6b
4
+ data.tar.gz: 8dbb4c9e8a0c087a09d64685e7c974a044c0cea9b3f4eb554a952492065181ff
5
5
  SHA512:
6
- metadata.gz: 3812676e76a07ae378a285030f30c8175fc9bb23670d626d2103e44611894785f962554d3cd9737c9c88f2ba6aaf9b8d80c8d5ee6e9401ca0a5da6b413ba7dcd
7
- data.tar.gz: 0bc1ea55739db84a90a92b8389d3df1c37121e496838388ec863621e3e8fb13adc6cbb54ee19e9f09f2c0eeebf8b20dd70521a6e827b6b6d384dca4230f9f35d
6
+ metadata.gz: c09d85ccb058caa27c77d568c82c8e72eed4950a610e358278994eed7c0456ef45798f125ee6729a65a903b865ae167e7180a5b1a26db1107c79e0c549beceb9
7
+ data.tar.gz: 6727276b68304892c7df6e896bcb1117efbc0a4266da3fe40e5ce9f0b4c46f8327e964f3ada9537b6093ea92fbe4ed4485c3abccaa94aa4af38093c335e7cde2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.2] - 2026-09-24
4
+
5
+ ### Fixed
6
+
7
+ - `find` raises `RecordNotFound` for a deleted/nonexistent record ID. Airtable answers such a
8
+ GET with `403 INVALID_PERMISSIONS_OR_MODEL_NOT_FOUND`, not 404, so it used to surface as a
9
+ plain `ApiError`. Because a revoked token or lost table permission returns the same 403, `find`
10
+ now disambiguates with one probe of the table (a one-record, no-fields list request): readable
11
+ → `RecordNotFound`, otherwise the original `ApiError` is re-raised; a `ConnectionError` during
12
+ the probe propagates. The 404 mapping is unchanged. `reload` and `belongs_to` (which reads a
13
+ stale link as `nil`) benefit accordingly.
14
+ - `ApiError` is raised (instead of a `TypeError`) for Airtable's bare string error body such as
15
+ `{"error": "NOT_FOUND"}`, which previously broke the 404 → `RecordNotFound` mapping in `find`.
16
+
17
+ ## [0.2.1] - 2026-07-15
18
+
19
+ ### Fixed
20
+
21
+ - The schema cache stores the full base schema instead of only the tables configured at fetch
22
+ time — a table added to `config.tables` after the cache warmed up (24 h expiry, shared store
23
+ in Rails hosts) now resolves immediately instead of raising `ConfigurationError` until expiry.
24
+
25
+ ### Changed
26
+
27
+ - Record payloads are parsed by string keys directly instead of deep-converting every record
28
+ with `with_indifferent_access` — one avoided deep copy per record on `where`/`all`/`find`,
29
+ two per record on batch updates. The (undocumented) `instantiate_from_api_response` and
30
+ `apply_response_fields` now expect string-keyed parsed payloads; `createdTime` parsing lives
31
+ in the single `Persistence.parse_created_time` helper.
32
+
3
33
  ## [0.2.0] - 2026-07-14
4
34
 
5
35
  Hardening release from a deep code review of the whole gem. Minor (not patch) because a few
@@ -66,11 +66,13 @@ module Airtable
66
66
  ERB::Util.url_encode(string)
67
67
  end
68
68
 
69
- # Parse an Airtable API error response and raise an ApiError.
69
+ # Parse an Airtable API error response and raise an ApiError. Airtable sends either
70
+ # {"error" => {"type", "message"}} or a bare {"error" => "NOT_FOUND"} (e.g. on 404).
70
71
  def self.raise_api_error(status, error)
71
- type = (error.is_a?(Hash) && error.dig("error", "type")) || "Communication error"
72
+ detail = error.is_a?(Hash) ? error["error"] : nil
73
+ type = (detail.is_a?(Hash) ? detail["type"] : detail) || "Communication error"
72
74
  msg = case error
73
- when Hash then error.dig("error", "message")
75
+ when Hash then detail.is_a?(Hash) ? detail["message"] : nil
74
76
  when String then error
75
77
  when NilClass then "invalid or empty response body (not valid JSON)"
76
78
  else error.inspect
@@ -74,7 +74,9 @@ module Airtable
74
74
  Airtable::ORM::Http::Client.raise_api_error(response.status, parsed_response)
75
75
  end
76
76
  rescue Airtable::ORM::ApiError => e
77
- raise Airtable::ORM::RecordNotFound, "Couldn't find record with id=#{id}" if e.status == 404
77
+ if e.status == 404 || (missing_record_forbidden?(e) && table_readable?)
78
+ raise Airtable::ORM::RecordNotFound, "Couldn't find record with id=#{id}"
79
+ end
78
80
 
79
81
  raise
80
82
  end
@@ -111,21 +113,28 @@ module Airtable
111
113
  record
112
114
  end
113
115
 
114
- # Instantiate a record from API response
116
+ # Instantiate a record from a parsed API payload. Expects string keys (the shape the
117
+ # JSON middleware produces) — this runs once per record on every where/all/find, so no
118
+ # per-record deep conversion here.
115
119
  def instantiate_from_api_response(response)
116
- data = response.with_indifferent_access
117
- symbol_attrs = fields_to_symbol_attributes(data[:fields])
118
-
119
- record = new(**symbol_attrs)
120
+ record = new(**fields_to_symbol_attributes(response["fields"]))
120
121
  record.send(:assign_persistence_state,
121
- id: data[:id],
122
- created_at: data[:createdTime] ? Time.iso8601(data[:createdTime].to_s) : nil,
122
+ id: response["id"],
123
+ created_at: parse_created_time(response),
123
124
  persisted: true)
124
125
  record.clear_changes_information
125
126
 
126
127
  record
127
128
  end
128
129
 
130
+ # @api private — createdTime is ISO-8601 UTC, parsed with Time.iso8601 (Time.zone
131
+ # doesn't exist outside Rails); nil-guarded. The single parse site for both
132
+ # instantiate_from_api_response and #apply_response_fields.
133
+ def parse_created_time(data)
134
+ created_time = data["createdTime"]
135
+ created_time ? Time.iso8601(created_time.to_s) : nil
136
+ end
137
+
129
138
  # Convert API field IDs to symbol attributes.
130
139
  # field_mapping already contains only declared attributes.
131
140
  def fields_to_symbol_attributes(fields)
@@ -138,6 +147,22 @@ module Airtable
138
147
 
139
148
  private
140
149
 
150
+ # Airtable answers a GET for a nonexistent (e.g. deleted) record ID with this 403, not
151
+ # 404 — the same response a token without access to the table/base gets.
152
+ def missing_record_forbidden?(error)
153
+ error.status == 403 && error.response.is_a?(Hash) &&
154
+ error.response.dig("error", "type") == "INVALID_PERMISSIONS_OR_MODEL_NOT_FOUND"
155
+ end
156
+
157
+ # Disambiguates that 403 with one cheap list request (one record, no fields): readable
158
+ # table → the record is gone. Never map the 403 blindly — a revoked token or lost
159
+ # permission would then look like "every record deleted" to the host. A ConnectionError
160
+ # still propagates (retryable, not evidence either way).
161
+ def table_readable?
162
+ response = client.connection.post(api_path("listRecords"), { maxRecords: 1, fields: [] })
163
+ response.success?
164
+ end
165
+
141
166
  # Send a single batch PATCH request to Airtable.
142
167
  def send_batch_update(batch, result)
143
168
  body = {
@@ -148,9 +173,8 @@ module Airtable
148
173
  parsed = response.body
149
174
 
150
175
  if response.success?
151
- parsed = parsed.with_indifferent_access if parsed.is_a?(Hash)
152
- response_records = parsed[:records] || []
153
- response_by_id = response_records.index_by { |r| r[:id] }
176
+ response_records = (parsed.is_a?(Hash) && parsed["records"]) || []
177
+ response_by_id = response_records.index_by { |r| r["id"] }
154
178
 
155
179
  batch.each do |record|
156
180
  record_data = response_by_id[record.id]
@@ -163,7 +187,7 @@ module Airtable
163
187
  end
164
188
  else
165
189
  batch.each { |record| result.failed << record }
166
- error_message = parsed.is_a?(Hash) ? parsed.with_indifferent_access.dig(:error, :message) : parsed
190
+ error_message = parsed.is_a?(Hash) ? parsed.dig("error", "message") : parsed
167
191
  ORM.config.logger.error(
168
192
  "Airtable batch update failed: HTTP #{response.status}: #{error_message.to_s.truncate(200)}"
169
193
  )
@@ -312,13 +336,12 @@ module Airtable
312
336
  def apply_response_fields(data)
313
337
  @previously_new_record = new_record?
314
338
 
315
- data = data.with_indifferent_access
316
- symbol_attrs = self.class.fields_to_symbol_attributes(data[:fields])
339
+ symbol_attrs = self.class.fields_to_symbol_attributes(data["fields"])
317
340
  symbol_attrs.each { |key, value| write_raw_attribute(key, value) }
318
341
 
319
342
  assign_persistence_state(
320
- id: data[:id],
321
- created_at: data[:createdTime] ? Time.iso8601(data[:createdTime].to_s) : nil,
343
+ id: data["id"],
344
+ created_at: self.class.parse_created_time(data),
322
345
  persisted: true
323
346
  )
324
347
  changes_applied
@@ -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.2.0"
5
+ VERSION = "0.2.2"
6
6
  end
7
7
  end
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.2.0
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roman Sklenar