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 +4 -4
- data/CHANGELOG.md +30 -0
- data/lib/airtable/orm/http/client.rb +5 -3
- data/lib/airtable/orm/persistence.rb +39 -16
- data/lib/airtable/orm/schema.rb +4 -7
- data/lib/airtable/orm/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: be3a4c1ecff6395abd45d69731ac804de74bf2fdd06127c2c4b5a9d3c9417c6b
|
|
4
|
+
data.tar.gz: 8dbb4c9e8a0c087a09d64685e7c974a044c0cea9b3f4eb554a952492065181ff
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
122
|
-
created_at:
|
|
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
|
-
|
|
152
|
-
|
|
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.
|
|
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
|
-
|
|
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[
|
|
321
|
-
created_at:
|
|
343
|
+
id: data["id"],
|
|
344
|
+
created_at: self.class.parse_created_time(data),
|
|
322
345
|
persisted: true
|
|
323
346
|
)
|
|
324
347
|
changes_applied
|
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