airtable-orm 0.1.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.
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module Airtable
6
+ module ORM
7
+ # Every host touchpoint is injected here — the gem never reads Rails.*, ENV, or
8
+ # credentials itself. Wired by the host application via Airtable::ORM.configure
9
+ # (in Rails typically from an initializer, inside to_prepare).
10
+ class Configuration
11
+ attr_accessor :api_key, :base_id, :tables, :api_url,
12
+ :open_timeout, :read_timeout, :rate_limit,
13
+ :logger, :http_logger, :cache
14
+
15
+ def initialize
16
+ @tables = {}
17
+ @api_url = "https://api.airtable.com"
18
+ @open_timeout = 5
19
+ @read_timeout = 10
20
+ @rate_limit = 5 # Airtable throttles for 30 seconds above 5 requests/s per base
21
+ @logger = Logger.new(IO::NULL)
22
+ @http_logger = nil # set to a Logger to enable Faraday request logging
23
+ @cache = MemoryCache.new # schema cache default; hosts inject Rails.cache
24
+ end
25
+
26
+ # Example: table_fields(:case) => { _id: "id", advisors: "fldgU5KU1qlN8eJ3v", ... }
27
+ def table_fields(table_name)
28
+ tables.dig(table_name.to_sym, :fields)
29
+ end
30
+
31
+ # Example: table_id(:case) => "tblQeKH7yYesvWf5p"
32
+ def table_id(table_name)
33
+ tables.dig(table_name.to_sym, :id)
34
+ end
35
+
36
+ def table_ids
37
+ tables.values.pluck(:id)
38
+ end
39
+
40
+ # Default in-process schema cache so an unconfigured consumer doesn't hit the /v0/meta
41
+ # API on every Schema.fetch (and burn the 5 RPS budget on metadata). Hosts inject a real
42
+ # store (Rails.cache); this one honours only :expires_in. The mutex is held across the
43
+ # fetch block (all keys serialize behind it, and nesting fetch calls would deadlock) —
44
+ # fine for the rare schema refresh this exists for.
45
+ class MemoryCache
46
+ def initialize
47
+ @mutex = Mutex.new
48
+ @store = {}
49
+ end
50
+
51
+ def fetch(key, expires_in: nil)
52
+ @mutex.synchronize do
53
+ # Hit/miss by key presence, not value truthiness — false/nil are cacheable
54
+ # (matching Rails.cache semantics).
55
+ if (entry = @store[key])
56
+ value, expires_at = entry
57
+ return value if expires_at.nil? || Time.now < expires_at
58
+ end
59
+
60
+ yield.tap { |fresh| @store[key] = [fresh, expires_in && (Time.now + expires_in)] }
61
+ end
62
+ end
63
+
64
+ def delete(key)
65
+ @mutex.synchronize { @store.delete(key) }
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Airtable
4
+ module ORM
5
+ module Core
6
+ extend ActiveSupport::Concern
7
+
8
+ # Instance comparison
9
+ def ==(other)
10
+ return false unless other.is_a?(self.class)
11
+ return false if new_record? || other.new_record?
12
+
13
+ id == other.id
14
+ end
15
+
16
+ alias eql? ==
17
+
18
+ def hash
19
+ [self.class, id].hash
20
+ end
21
+
22
+ # Inspect method for better debugging
23
+ def inspect
24
+ attribute_string = self.class.attribute_types.map do |name, _type|
25
+ value = read_raw_attribute(name.to_sym)
26
+ "#{name}: #{value.inspect}"
27
+ end.join(", ")
28
+
29
+ "#<#{self.class.name} id: #{id.inspect}, #{attribute_string}>"
30
+ end
31
+
32
+ # Convert to hash with symbol keys
33
+ def to_h
34
+ {
35
+ id: id,
36
+ created_at: created_at,
37
+ **symbol_attributes
38
+ }
39
+ end
40
+
41
+ # Freeze the record
42
+ def freeze
43
+ @id.freeze
44
+ @created_at.freeze
45
+ super
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Airtable
4
+ module ORM
5
+ # Base error class for all Airtable-related errors
6
+ class Error < StandardError; end
7
+
8
+ # Raised when a record cannot be found
9
+ class RecordNotFound < Error; end
10
+
11
+ # Raised when trying to access an unknown field
12
+ class UnknownFieldError < Error; end
13
+
14
+ # Raised when an invalid attribute is provided
15
+ class InvalidAttributeError < Error; end
16
+
17
+ # Raised when a record fails validation.
18
+ # Use the #record method to retrieve the record which did not validate.
19
+ class RecordInvalid < Error
20
+ attr_reader :record
21
+
22
+ def initialize(record = nil)
23
+ if record
24
+ @record = record
25
+ errors = @record.errors.full_messages.join(", ")
26
+ super("Validation failed: #{errors}")
27
+ else
28
+ super("Record invalid")
29
+ end
30
+ end
31
+ end
32
+
33
+ # Raised when trying to perform operations on a non-persisted record
34
+ # (e.g., trying to destroy or reload a new record)
35
+ class RecordNotPersisted < Error; end
36
+
37
+ # Raised when trying to save a new record that fails
38
+ class RecordNotSaved < Error; end
39
+
40
+ # Raised when a record cannot be destroyed
41
+ class RecordNotDestroyed < Error; end
42
+
43
+ # Raised when API communication fails
44
+ class ApiError < Error
45
+ attr_reader :status, :response
46
+
47
+ def initialize(message, status: nil, response: nil)
48
+ super(message)
49
+ @status = status
50
+ @response = response
51
+ end
52
+ end
53
+
54
+ # Raised when the request never completed — a read timeout, dropped/failed connection, TLS error,
55
+ # etc. — mapped from the raw transport error by Airtable::ORM::Http::ErrorHandler so callers only
56
+ # ever deal in Airtable::* exceptions, never the HTTP client's. Deliberately a sibling of ApiError,
57
+ # not a subclass: the API never responded, so it isn't an "API error". Being outside the ApiError
58
+ # branch also means persistence's `rescue ApiError` ignores it, so a blip propagates (to retry)
59
+ # instead of being swallowed into a `false` save.
60
+ class ConnectionError < Error; end
61
+ end
62
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rate_limiter"
4
+ require_relative "error_handler"
5
+
6
+ module Airtable
7
+ module ORM
8
+ module Http
9
+ class Client
10
+ attr_reader :api_key
11
+
12
+ # Shared client instance with credential validation.
13
+ # Used by Persistence and Schema to avoid duplicating client/key setup.
14
+ def self.default
15
+ @default ||= begin
16
+ key = ORM.config.api_key
17
+ if key.blank?
18
+ raise Airtable::ORM::Error,
19
+ "Airtable API key is missing. Set api_key via Airtable::ORM.configure."
20
+ end
21
+
22
+ new(key)
23
+ end
24
+ end
25
+
26
+ def self.reset!
27
+ @default = nil
28
+ end
29
+
30
+ def initialize(api_key)
31
+ @api_key = api_key
32
+ end
33
+
34
+ def connection
35
+ @connection ||= Faraday.new(url: api_uri, headers: headers) do |builder|
36
+ builder.options.open_timeout = ORM.config.open_timeout
37
+ builder.options.timeout = ORM.config.read_timeout
38
+ # Outermost middleware so its rescue wraps every inner handler and the adapter — maps raw
39
+ # Faraday transport errors to Airtable::ORM::ConnectionError before they leave the client.
40
+ builder.request :airtable_error_handler
41
+ builder.request :json
42
+ builder.request :airtable_rate_limiter, requests_per_second: ORM.config.rate_limit
43
+ builder.response :json
44
+ builder.adapter :net_http_persistent
45
+
46
+ if (http_logger = ORM.config.http_logger)
47
+ builder.response :logger, http_logger, bodies: { request: true, response: false }, headers: false,
48
+ errors: true, log_level: :debug
49
+ end
50
+ end
51
+ end
52
+
53
+ def api_uri
54
+ @api_uri ||= URI.parse(ORM.config.api_url)
55
+ end
56
+
57
+ def headers
58
+ {
59
+ "Authorization" => "Bearer #{api_key}",
60
+ "Content-Type" => "application/json",
61
+ "User-Agent" => "airtable-orm/#{VERSION}"
62
+ }
63
+ end
64
+
65
+ def escape(string)
66
+ ERB::Util.url_encode(string)
67
+ end
68
+
69
+ # Parse an Airtable API error response and raise an ApiError.
70
+ def self.raise_api_error(status, error)
71
+ type = (error.is_a?(Hash) && error.dig("error", "type")) || "Communication error"
72
+ msg = case error
73
+ when Hash then error.dig("error", "message")
74
+ when String then error
75
+ when NilClass then "invalid or empty response body (not valid JSON)"
76
+ else error.inspect
77
+ end
78
+
79
+ raise Airtable::ORM::ApiError.new("HTTP #{status}: #{type}: #{msg.to_s.truncate(200, omission: "…")}",
80
+ status: status, response: error)
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+
5
+ module Airtable
6
+ module ORM
7
+ module Http
8
+ # Maps the whole Faraday::Error family to Airtable::ORM::ConnectionError so no Faraday type
9
+ # leaks past the client (rationale on the class in error.rb). Safe to catch the base: we don't
10
+ # enable Faraday's :raise_error, so HTTP 4xx/5xx come back as responses (→ Airtable::ORM::ApiError
11
+ # downstream), never raised through here. The original error is kept as #cause.
12
+ class ErrorHandler < Faraday::Middleware
13
+ def call(env)
14
+ @app.call(env)
15
+ rescue Faraday::Error => e
16
+ raise Airtable::ORM::ConnectionError, "Airtable request failed: #{e.class}: #{e.message}"
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+
23
+ Faraday::Request.register_middleware(
24
+ airtable_error_handler: Airtable::ORM::Http::ErrorHandler
25
+ )
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Airtable
4
+ module ORM
5
+ module Http
6
+ class RateLimiter < Faraday::Middleware
7
+ def initialize(app, requests_per_second: nil, sleeper: nil)
8
+ super(app)
9
+ @rps = requests_per_second
10
+ @sleeper = sleeper || ->(seconds) { sleep(seconds) }
11
+ @mutex = Mutex.new
12
+ @requests = []
13
+ end
14
+
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
21
+ @app.call(env)
22
+ end
23
+
24
+ def clear
25
+ @mutex.synchronize { @requests = [] }
26
+ end
27
+
28
+ private
29
+
30
+ def too_many_requests_in_last_second?
31
+ return false unless @rps
32
+ return false unless @requests.size >= @rps
33
+
34
+ window_span < 1.0
35
+ end
36
+
37
+ def wait
38
+ wait_time = 1.0 - window_span
39
+ @sleeper.call(wait_time)
40
+ end
41
+
42
+ def window_span
43
+ @requests.last - @requests.first
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+
50
+ Faraday::Request.register_middleware(
51
+ airtable_rate_limiter: Airtable::ORM::Http::RateLimiter
52
+ )
@@ -0,0 +1,361 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Airtable
6
+ module ORM
7
+ module Persistence
8
+ extend ActiveSupport::Concern
9
+
10
+ included do
11
+ attr_reader :id, :created_at
12
+
13
+ # Track if this record has been persisted to Airtable
14
+ define_model_callbacks :save, :create, :update, :destroy
15
+ end
16
+
17
+ BATCH_SIZE = 10
18
+ MAX_FIND_MANY_IDS = 500
19
+
20
+ class_methods do
21
+ # Batch update records (up to 10 per API request).
22
+ # Triages locally: new/invalid → failed, unchanged → skipped, changed → sent.
23
+ def update_many(records)
24
+ result = Airtable::ORM::BatchResult.new
25
+
26
+ pending = []
27
+ records.each do |record|
28
+ if record.new_record? || record.invalid?
29
+ result.failed << record
30
+ elsif !record.changed?
31
+ record.clear_changes_information
32
+ result.skipped << record
33
+ else
34
+ pending << record
35
+ end
36
+ end
37
+
38
+ pending.each_slice(BATCH_SIZE) do |batch|
39
+ send_batch_update(batch, result)
40
+ end
41
+
42
+ result
43
+ end
44
+
45
+ # Get the API client
46
+ def client
47
+ Airtable::ORM::Http::Client.default
48
+ end
49
+
50
+ # Build API path for this table (appends returnFieldsByFieldId=true).
51
+ def api_path(resource_id = nil)
52
+ base_path = "/v0/#{ORM.config.base_id}/#{client.escape(table_id)}"
53
+ full_path = resource_id ? "#{base_path}/#{resource_id}" : base_path
54
+ "#{full_path}?returnFieldsByFieldId=true"
55
+ end
56
+
57
+ # Find a record by ID
58
+ def find(id)
59
+ response = client.connection.get(api_path(id))
60
+ parsed_response = response.body
61
+
62
+ if response.success?
63
+ instantiate_from_api_response(parsed_response)
64
+ else
65
+ Airtable::ORM::Http::Client.raise_api_error(response.status, parsed_response)
66
+ end
67
+ rescue Airtable::ORM::ApiError => e
68
+ raise Airtable::ORM::RecordNotFound, "Couldn't find record with id=#{id}" if e.status == 404
69
+
70
+ raise
71
+ end
72
+
73
+ # Find multiple records by IDs, preserving order.
74
+ # Uses an OR formula (single API request) and sorts in memory.
75
+ def find_many(ids)
76
+ return Airtable::ORM::Collection.new([], model_class: self) if ids.empty?
77
+
78
+ validated_ids = validate_record_ids(ids)
79
+
80
+ if validated_ids.size > MAX_FIND_MANY_IDS
81
+ raise ArgumentError, "find_many supports at most #{MAX_FIND_MANY_IDS} IDs (got #{validated_ids.size})"
82
+ end
83
+
84
+ position_hash = validated_ids.each_with_index.to_h
85
+
86
+ or_args = validated_ids.map { |id| "RECORD_ID() = '#{id}'" }.join(",")
87
+ formula = "OR(#{or_args})"
88
+ where(formula: formula).sort_by { |record| position_hash[record.id] || validated_ids.length }
89
+ end
90
+
91
+ # Create a new record and save it
92
+ def create(attributes = {})
93
+ record = new(attributes)
94
+ record.save
95
+ record
96
+ end
97
+
98
+ # Create a new record and save it, raising an error if validation fails
99
+ def create!(attributes = {})
100
+ record = new(attributes)
101
+ record.save!
102
+ record
103
+ end
104
+
105
+ # Instantiate a record from API response
106
+ 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)
111
+ record.send(:assign_persistence_state,
112
+ id: data[:id],
113
+ created_at: data[:createdTime] ? Time.iso8601(data[:createdTime].to_s) : nil,
114
+ persisted: true)
115
+ record.clear_changes_information
116
+
117
+ record
118
+ end
119
+
120
+ # Convert API field IDs to symbol attributes.
121
+ # field_mapping already contains only declared attributes.
122
+ def fields_to_symbol_attributes(fields)
123
+ return {} unless fields.is_a?(Hash)
124
+
125
+ field_mapping.each_with_object({}) do |(symbol, field_id), hash|
126
+ hash[symbol] = fields[field_id] if fields.key?(field_id)
127
+ end
128
+ end
129
+
130
+ private
131
+
132
+ # Send a single batch PATCH request to Airtable.
133
+ def send_batch_update(batch, result)
134
+ body = {
135
+ records: batch.map { |record| { id: record.id, fields: record.fields_for_update } }
136
+ }
137
+
138
+ response = client.connection.patch(api_path, body)
139
+ parsed = response.body
140
+
141
+ 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] }
145
+
146
+ batch.each do |record|
147
+ record_data = response_by_id[record.id]
148
+ if record_data
149
+ record.send(:apply_response_fields, record_data)
150
+ result.updated << record
151
+ else
152
+ result.failed << record
153
+ end
154
+ end
155
+ else
156
+ batch.each { |record| result.failed << record }
157
+ error_message = parsed.is_a?(Hash) ? parsed.with_indifferent_access.dig(:error, :message) : parsed
158
+ ORM.config.logger.error(
159
+ "Airtable batch update failed: HTTP #{response.status}: #{error_message.to_s.truncate(200)}"
160
+ )
161
+ end
162
+ rescue Airtable::ORM::ApiError => e
163
+ batch.each { |record| result.failed << record }
164
+ ORM.config.logger.error("Airtable batch update error: #{e.message}")
165
+ end
166
+
167
+ # Validate that record IDs match Airtable's format to prevent formula injection
168
+ # Airtable record IDs follow the pattern: rec + alphanumeric characters
169
+ def validate_record_ids(ids)
170
+ ids.map do |id|
171
+ id_str = id.to_s
172
+ unless id_str.match?(/\Arec[a-zA-Z0-9_-]+\z/)
173
+ raise ArgumentError,
174
+ "Invalid Airtable record ID: #{id_str.inspect}"
175
+ end
176
+
177
+ id_str
178
+ end
179
+ end
180
+ end
181
+
182
+ # Initialize a record with symbol attributes
183
+ # Usage: new(email: "test@example.com", first_name: "John")
184
+ def initialize(**attributes)
185
+ # Initialize as a new record (not persisted, not destroyed)
186
+ @id = nil
187
+ @created_at = nil
188
+ @persisted = false
189
+ @destroyed = false
190
+ @previously_new_record = false
191
+
192
+ # Initialize ActiveModel::Attributes
193
+ super()
194
+
195
+ # Set attributes using ActiveModel's assign_attributes
196
+ assign_attributes(attributes) if attributes.present?
197
+ end
198
+
199
+ # Check if this is a new record (not yet saved)
200
+ def new_record?
201
+ !@persisted && !@destroyed
202
+ end
203
+
204
+ # Check if this record has been saved
205
+ def persisted?
206
+ @persisted == true
207
+ end
208
+
209
+ # Check if this record has been destroyed
210
+ def destroyed?
211
+ @destroyed == true
212
+ end
213
+
214
+ # Check if this record was new before the last save
215
+ def previously_new_record?
216
+ @previously_new_record == true
217
+ end
218
+
219
+ # Save the record (create or update)
220
+ def save(validate: true)
221
+ return false if validate && invalid?
222
+
223
+ result = run_callbacks(:save) do
224
+ new_record? ? create_record : update_record
225
+ end
226
+
227
+ result != false
228
+ rescue Airtable::ORM::ApiError
229
+ false
230
+ end
231
+
232
+ # Save the record, raising an error if it fails.
233
+ # Unlike save, lets ApiError propagate with full error details.
234
+ def save!(validate: true)
235
+ raise Airtable::ORM::RecordInvalid, self if validate && invalid?
236
+
237
+ result = run_callbacks(:save) do
238
+ new_record? ? create_record : update_record
239
+ end
240
+
241
+ raise Airtable::ORM::RecordNotSaved, "Failed to save the record" if result == false
242
+
243
+ true
244
+ end
245
+
246
+ # Update attributes and save
247
+ def update(attributes)
248
+ assign_attributes(attributes)
249
+ save
250
+ end
251
+
252
+ # Update attributes and save, raising an error if it fails
253
+ def update!(attributes)
254
+ assign_attributes(attributes)
255
+ save!
256
+ end
257
+
258
+ # Destroy the record
259
+ def destroy
260
+ raise Airtable::ORM::RecordNotDestroyed, "Cannot destroy a new record" if new_record?
261
+
262
+ run_callbacks :destroy do
263
+ response = client.connection.delete(self.class.api_path(id))
264
+ parsed_response = response.body
265
+
266
+ if response.success?
267
+ @persisted = false
268
+ @destroyed = true
269
+ freeze
270
+ self
271
+ else
272
+ Airtable::ORM::Http::Client.raise_api_error(response.status, parsed_response)
273
+ end
274
+ end
275
+ end
276
+
277
+ # Reload the record from the API
278
+ def reload
279
+ raise Airtable::ORM::RecordNotPersisted, "Cannot reload a new record" if new_record?
280
+
281
+ fresh_record = self.class.find(id)
282
+
283
+ # Copy attributes from fresh record using raw attribute access
284
+ self.class.attribute_types.each_key do |name|
285
+ write_raw_attribute(name.to_sym, fresh_record.read_raw_attribute(name.to_sym))
286
+ end
287
+
288
+ @created_at = fresh_record.created_at
289
+ clear_changes_information
290
+
291
+ self
292
+ end
293
+
294
+ # Assign persistence metadata (id, created_at, persisted flag)
295
+ def assign_persistence_state(id:, created_at:, persisted:)
296
+ @id = id
297
+ @created_at = created_at
298
+ @persisted = persisted
299
+ end
300
+
301
+ # Apply response fields from a create, update, or batch operation.
302
+ # Uses changes_applied (not clear_changes_information) so after_save
303
+ # callbacks can inspect previous_changes / saved_change_to_*? methods.
304
+ def apply_response_fields(data)
305
+ @previously_new_record = new_record?
306
+
307
+ data = data.with_indifferent_access
308
+ symbol_attrs = self.class.fields_to_symbol_attributes(data[:fields])
309
+ symbol_attrs.each { |key, value| write_raw_attribute(key, value) }
310
+
311
+ assign_persistence_state(
312
+ id: data[:id],
313
+ created_at: data[:createdTime] ? Time.iso8601(data[:createdTime].to_s) : nil,
314
+ persisted: true
315
+ )
316
+ changes_applied
317
+ end
318
+
319
+ def client
320
+ self.class.client
321
+ end
322
+
323
+ # Handle API response from create/update operations
324
+ def handle_persistence_response(response)
325
+ parsed = response.body
326
+
327
+ if response.success?
328
+ apply_response_fields(parsed)
329
+ else
330
+ Airtable::ORM::Http::Client.raise_api_error(response.status, parsed)
331
+ end
332
+ end
333
+
334
+ # Create a new record via API
335
+ def create_record
336
+ run_callbacks :create do
337
+ body = { fields: fields_for_create }
338
+ response = client.connection.post(self.class.api_path, body)
339
+ handle_persistence_response(response)
340
+ true
341
+ end
342
+ end
343
+
344
+ # Update only changed fields via PATCH. Fields changed TO nil are sent
345
+ # (to clear them in Airtable); unchanged nil fields are not sent.
346
+ def update_record
347
+ unless changed?
348
+ clear_changes_information
349
+ return true
350
+ end
351
+
352
+ run_callbacks :update do
353
+ body = { fields: fields_for_update }
354
+ response = client.connection.patch(self.class.api_path(id), body)
355
+ handle_persistence_response(response)
356
+ true
357
+ end
358
+ end
359
+ end
360
+ end
361
+ end