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,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Airtable
4
+ module ORM
5
+ module Querying
6
+ extend ActiveSupport::Concern
7
+
8
+ DEFAULT_PAGE_SIZE = 100
9
+ DEFAULT_MAX_RECORDS = 1000
10
+
11
+ class_methods do
12
+ # Fetch all records from the table. Always ignores max_records; use +where+ for limits.
13
+ def all(**options)
14
+ where(**options, max_records: nil)
15
+ end
16
+
17
+ # Query records with a formula
18
+ def where(formula: nil, sort: nil, view: nil, fields: nil, max_records: DEFAULT_MAX_RECORDS,
19
+ page_size: DEFAULT_PAGE_SIZE)
20
+ records(formula:, sort:, view:, fields:, max_records:, page_size:)
21
+ end
22
+
23
+ # Fetch the first record matching the criteria
24
+ def first(formula: nil, sort: nil)
25
+ where(formula:, sort:, max_records: 1).first
26
+ end
27
+
28
+ # Fetch the last record matching the criteria by reversing the sort order.
29
+ # NOTE: Unlike ActiveRecord, Airtable has no default sort order.
30
+ # Calling last without sort returns an arbitrary record (same as first).
31
+ def last(formula: nil, sort: nil)
32
+ where(formula:, sort: reverse_sort_order(sort), max_records: 1).first
33
+ end
34
+
35
+ # Find the first record matching attribute conditions.
36
+ # Example: Airtable::Case.find_by(slug: "abc-123")
37
+ def find_by(**conditions)
38
+ raise ArgumentError, "find_by requires at least one condition" if conditions.empty?
39
+
40
+ clauses = conditions.map do |field, value|
41
+ field_id = resolve_field_id(field)
42
+ "{#{field_id}} = #{format_formula_value(value)}"
43
+ end
44
+ formula = clauses.size == 1 ? clauses.first : "AND(#{clauses.join(", ")})"
45
+ first(formula: formula)
46
+ end
47
+
48
+ # Like find_by but raises RecordNotFound when no match.
49
+ def find_by!(**conditions)
50
+ find_by(**conditions) || raise(Airtable::ORM::RecordNotFound,
51
+ "Couldn't find #{name} with #{conditions.inspect}")
52
+ end
53
+
54
+ # Count records without instantiating model objects.
55
+ # Requests no fields to minimize payload (Airtable has no count-only endpoint).
56
+ def count(formula: nil)
57
+ count_records(formula:)
58
+ end
59
+
60
+ # Format a Ruby value for use in an Airtable formula comparison.
61
+ # Returns the correctly typed formula literal (quoted string, bare number, function call, etc.).
62
+ def format_formula_value(value)
63
+ case value
64
+ when NilClass then "BLANK()"
65
+ when TrueClass then "TRUE()"
66
+ when FalseClass then "FALSE()"
67
+ when Integer, Float then value.to_s
68
+ when Date then "'#{value.iso8601}'"
69
+ when Time, DateTime then "'#{value.utc.iso8601}'"
70
+ else "'#{escape_formula_value(value)}'"
71
+ end
72
+ end
73
+
74
+ # Escape a value for use in an Airtable formula string literal.
75
+ # Handles backslashes, single quotes, and control characters.
76
+ def escape_formula_value(value)
77
+ value.to_s
78
+ .gsub("\\", "\\\\\\\\") # backslashes first
79
+ .gsub("'", "\\\\'") # single quotes
80
+ .gsub("\n", "\\n") # newlines
81
+ .gsub("\r", "\\r") # carriage returns
82
+ .gsub("\t", "\\t") # tabs
83
+ end
84
+
85
+ private
86
+
87
+ # Fetch records from the API
88
+ def records(formula: nil, sort: nil, view: nil, fields: nil, max_records: nil, page_size: nil, offset: nil,
89
+ paginate: true)
90
+ all_records = []
91
+
92
+ each_page(formula:, sort:, view:, fields:, max_records:, page_size:, offset:, paginate:) do |page|
93
+ all_records.concat(page.map { |record_data| instantiate_from_api_response(record_data) })
94
+ end
95
+
96
+ Airtable::ORM::Collection.new(all_records, model_class: self)
97
+ end
98
+
99
+ # Count records without instantiating model objects.
100
+ def count_records(formula: nil)
101
+ total = 0
102
+ each_page(formula:, fields: []) { |page| total += page.size }
103
+ total
104
+ end
105
+
106
+ # Paginate through listRecords, yielding each page's raw records array.
107
+ def each_page(formula: nil, sort: nil, view: nil, fields: nil, max_records: nil, page_size: nil, offset: nil,
108
+ paginate: true)
109
+ current_offset = offset
110
+
111
+ loop do
112
+ options = build_query_options(formula:, sort:, view:, fields:, max_records:, page_size:,
113
+ offset: current_offset)
114
+ response = client.connection.post(api_path("listRecords"), options)
115
+ parsed_response = response.body
116
+
117
+ Airtable::ORM::Http::Client.raise_api_error(response.status, parsed_response) unless response.success?
118
+
119
+ yield parsed_response["records"]
120
+
121
+ break unless paginate && parsed_response["offset"]
122
+
123
+ current_offset = parsed_response["offset"]
124
+ end
125
+ end
126
+
127
+ # Build query options for the API
128
+ def build_query_options(formula:, sort:, view:, fields:, max_records:, page_size:, offset:)
129
+ {
130
+ returnFieldsByFieldId: true,
131
+ filterByFormula: formula,
132
+ sort: sort && normalize_sort_options(sort),
133
+ view: view,
134
+ fields: fields && resolve_field_ids(fields),
135
+ maxRecords: max_records,
136
+ pageSize: page_size,
137
+ offset: offset
138
+ }.compact
139
+ end
140
+
141
+ # Normalize sort options to Airtable API format
142
+ # Accepts: { field: :asc } or [[:field1, :desc], [:field2, :asc]]
143
+ # Converts symbol attributes to their Airtable field IDs
144
+ def normalize_sort_options(sort)
145
+ return [] unless sort.respond_to?(:map)
146
+
147
+ sort.map { |field, direction| { field: resolve_field_id(field), direction: direction.to_s } }
148
+ end
149
+
150
+ # Resolve symbol attribute to Airtable field ID
151
+ # If the field is already a string, return it as-is (assuming it's a valid field ID)
152
+ # If it's a symbol, look up the field ID from the attribute map and validate it exists
153
+ def resolve_field_id(field)
154
+ return field.to_s unless field.is_a?(Symbol)
155
+
156
+ field_id = field_mapping[field]
157
+ return field_id if field_id
158
+
159
+ # Raise error for unknown field symbols to prevent silent API errors
160
+ valid_fields = field_mapping.keys.join(", ")
161
+ raise ArgumentError, "Unknown field: #{field.inspect}. Valid fields: #{valid_fields}"
162
+ end
163
+
164
+ # Resolve an array of fields (symbols or strings) to field IDs
165
+ def resolve_field_ids(fields)
166
+ fields.map { |field| resolve_field_id(field) }
167
+ end
168
+
169
+ # Reverse sort order for last() method
170
+ def reverse_sort_order(sort)
171
+ return nil unless sort
172
+
173
+ flip = ->(dir) { dir.to_sym == :asc ? :desc : :asc }
174
+
175
+ case sort
176
+ when Hash then sort.transform_values(&flip)
177
+ when Array then sort.map { |field, dir| [field, flip.call(dir)] }
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Airtable
4
+ module ORM
5
+ class Schema
6
+ CACHE_KEY = "airtable:schema"
7
+ CACHE_EXPIRY = 24.hours
8
+
9
+ class << self
10
+ def fetch(base_id, force_refresh: false)
11
+ clear_cache(base_id) if force_refresh
12
+
13
+ cache.fetch(cache_key(base_id), expires_in: CACHE_EXPIRY) { fetch_from_api(base_id) }
14
+ end
15
+
16
+ def clear_cache(base_id)
17
+ cache.delete(cache_key(base_id))
18
+ end
19
+
20
+ private
21
+
22
+ def required_table_ids
23
+ ORM.config.table_ids
24
+ end
25
+
26
+ def fetch_from_api(base_id)
27
+ response = client.connection.get("/v0/meta/bases/#{base_id}/tables")
28
+ parsed_response = response.body
29
+
30
+ if response.success?
31
+ indexed_schema(parsed_response)
32
+ else
33
+ Http::Client.raise_api_error(response.status, parsed_response)
34
+ end
35
+ end
36
+
37
+ def indexed_schema(parsed_response)
38
+ tables = parsed_response.deep_symbolize_keys[:tables]
39
+
40
+ # Filter only required tables
41
+ tables.select! { |table| required_table_ids.include?(table[:id]) }
42
+
43
+ # Transform fields into hash indexed by field id
44
+ tables.each do |table|
45
+ table[:fields] = table[:fields].index_by { |field| field[:id] }
46
+ end
47
+
48
+ # Index tables by table id
49
+ tables.index_by { |table| table[:id] }
50
+ end
51
+
52
+ def cache_key(base_id)
53
+ "#{CACHE_KEY}:#{base_id}"
54
+ end
55
+
56
+ def cache
57
+ ORM.config.cache
58
+ end
59
+
60
+ def client
61
+ Http::Client.default
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Airtable
4
+ module ORM
5
+ # Opt-in RSpec test support: `require "airtable/orm/testing"`. Depends on rspec-mocks at
6
+ # runtime, which is why the directory is ignored by the gem's Zeitwerk loader.
7
+ module Testing
8
+ class << self
9
+ # Absolute path to a JSON dump of /v0/meta/bases/:id/tables — the host points this at
10
+ # its own fixture before using stub_airtable_schema.
11
+ attr_accessor :schema_fixture_path
12
+ end
13
+
14
+ # Include into RSpec (`config.include Airtable::ORM::Testing::StubHelpers, :airtable`)
15
+ # and call stub_airtable_http_client in a before hook — no HTTP leaves the process.
16
+ module StubHelpers
17
+ # Stub schema fetch to use the fixture file instead of the /v0/meta API.
18
+ # Returns the same schema data for all fetch calls, so tests work regardless
19
+ # of which base they're configured for.
20
+ def stub_airtable_schema
21
+ path = Testing.schema_fixture_path
22
+ raise ArgumentError, "Set Airtable::ORM::Testing.schema_fixture_path first" unless path
23
+
24
+ raw_schema = JSON.parse(File.read(path))
25
+ indexed_schema = Airtable::ORM::Schema.send(:indexed_schema, raw_schema)
26
+
27
+ allow(Airtable::ORM::Schema).to receive(:fetch).and_return(indexed_schema)
28
+ end
29
+
30
+ # Route every request through a Faraday test adapter (returned for stubbing) and
31
+ # bypass the API-key check in Http::Client.default with a real client instance so
32
+ # escape/other helpers keep working.
33
+ def stub_airtable_http_client
34
+ @stubs = Faraday::Adapter::Test::Stubs.new
35
+
36
+ stub_connection = Faraday.new do |builder|
37
+ builder.request :json
38
+ builder.response :json
39
+ builder.adapter :test, @stubs
40
+ end
41
+
42
+ fake_client = Airtable::ORM::Http::Client.new("test_api_key")
43
+ allow(fake_client).to receive(:connection).and_return(stub_connection)
44
+ allow(Airtable::ORM::Http::Client).to receive(:default).and_return(fake_client)
45
+
46
+ stub_airtable_schema
47
+
48
+ @stubs
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "testing/stub_helpers"
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Airtable
4
+ module ORM
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zeitwerk"
4
+ require "active_model"
5
+ require "active_support"
6
+ # The lib leans broadly on AS core exts standalone (pluck, deep_symbolize_keys, 24.hours,
7
+ # truncate…) — active_model's transitive requires load only a tiny subset.
8
+ require "active_support/core_ext"
9
+ require "faraday"
10
+ require "faraday/net_http_persistent"
11
+ require "logger"
12
+ require "time"
13
+
14
+ module Airtable; end
15
+
16
+ loader = Zeitwerk::Loader.for_gem_extension(Airtable)
17
+ loader.inflector.inflect("orm" => "ORM") # orm.rb / orm/ → ORM, not Orm
18
+ loader.ignore("#{__dir__}/orm/errors.rb") # one file, many constants — required eagerly below
19
+ # RSpec-dependent test support, opt-in via require "airtable/orm/testing".
20
+ loader.ignore("#{__dir__}/orm/testing.rb", "#{__dir__}/orm/testing")
21
+ loader.setup
22
+
23
+ module Airtable
24
+ module ORM
25
+ class << self
26
+ def config
27
+ @config ||= Configuration.new
28
+ end
29
+
30
+ def configure
31
+ yield config
32
+ end
33
+
34
+ # True when an API key is configured — hosts gate network-touching hooks on this.
35
+ def configured?
36
+ config.api_key.present?
37
+ end
38
+
39
+ def reset!
40
+ @config = nil
41
+ Http::Client.reset!
42
+ end
43
+ end
44
+ end
45
+ end
46
+
47
+ require_relative "orm/errors"
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Bundler.require resolves the dashed gem name to this file.
4
+ require_relative "airtable/orm"
metadata ADDED
@@ -0,0 +1,131 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: airtable-orm
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Roman Sklenar
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activemodel
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '7.1'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9'
32
+ - !ruby/object:Gem::Dependency
33
+ name: faraday
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - "~>"
37
+ - !ruby/object:Gem::Version
38
+ version: '2.9'
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - "~>"
44
+ - !ruby/object:Gem::Version
45
+ version: '2.9'
46
+ - !ruby/object:Gem::Dependency
47
+ name: faraday-net_http_persistent
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - "~>"
51
+ - !ruby/object:Gem::Version
52
+ version: '2.0'
53
+ type: :runtime
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '2.0'
60
+ - !ruby/object:Gem::Dependency
61
+ name: zeitwerk
62
+ requirement: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - "~>"
65
+ - !ruby/object:Gem::Version
66
+ version: '2.6'
67
+ type: :runtime
68
+ prerelease: false
69
+ version_requirements: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - "~>"
72
+ - !ruby/object:Gem::Version
73
+ version: '2.6'
74
+ description: CRUD, query DSL, associations, schema introspection, batch updates and
75
+ rate limiting for Airtable — behind a branded error hierarchy that never leaks the
76
+ underlying HTTP client.
77
+ email:
78
+ - mail@romansklenar.cz
79
+ executables: []
80
+ extensions: []
81
+ extra_rdoc_files: []
82
+ files:
83
+ - CHANGELOG.md
84
+ - LICENSE.txt
85
+ - README.md
86
+ - lib/airtable-orm.rb
87
+ - lib/airtable/orm.rb
88
+ - lib/airtable/orm/associations.rb
89
+ - lib/airtable/orm/attributes.rb
90
+ - lib/airtable/orm/base.rb
91
+ - lib/airtable/orm/batch_result.rb
92
+ - lib/airtable/orm/collection.rb
93
+ - lib/airtable/orm/configuration.rb
94
+ - lib/airtable/orm/core.rb
95
+ - lib/airtable/orm/errors.rb
96
+ - lib/airtable/orm/http/client.rb
97
+ - lib/airtable/orm/http/error_handler.rb
98
+ - lib/airtable/orm/http/rate_limiter.rb
99
+ - lib/airtable/orm/persistence.rb
100
+ - lib/airtable/orm/querying.rb
101
+ - lib/airtable/orm/schema.rb
102
+ - lib/airtable/orm/testing.rb
103
+ - lib/airtable/orm/testing/stub_helpers.rb
104
+ - lib/airtable/orm/version.rb
105
+ homepage: https://github.com/romansklenar/airtable-orm
106
+ licenses:
107
+ - MIT
108
+ metadata:
109
+ homepage_uri: https://github.com/romansklenar/airtable-orm
110
+ source_code_uri: https://github.com/romansklenar/airtable-orm
111
+ changelog_uri: https://github.com/romansklenar/airtable-orm/blob/main/CHANGELOG.md
112
+ bug_tracker_uri: https://github.com/romansklenar/airtable-orm/issues
113
+ rubygems_mfa_required: 'true'
114
+ rdoc_options: []
115
+ require_paths:
116
+ - lib
117
+ required_ruby_version: !ruby/object:Gem::Requirement
118
+ requirements:
119
+ - - ">="
120
+ - !ruby/object:Gem::Version
121
+ version: '3.2'
122
+ required_rubygems_version: !ruby/object:Gem::Requirement
123
+ requirements:
124
+ - - ">="
125
+ - !ruby/object:Gem::Version
126
+ version: '0'
127
+ requirements: []
128
+ rubygems_version: 4.0.12
129
+ specification_version: 4
130
+ summary: ActiveModel-style ORM for the Airtable API
131
+ test_files: []