airctiverecord 0.2.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9dfcc33cb71a75dadf8007c2f4acab0d9274d617fa5b6a3d97dd95061a155c42
4
+ data.tar.gz: 22b3443fdebc4ced36b670b02f77289b4749c204aed23b3719a8e30b65ee28b1
5
+ SHA512:
6
+ metadata.gz: 4cc51162eb066c606ae86b331bc793a0b34803d03298daa07abb6d8509649337c3369676ab2bcefb252730c0916520b45b6aad2b932af436229aee52d76c6171
7
+ data.tar.gz: 92988e793b0fdce815872a9fa64c9d7a60df73ef71deea731456e8e407b9f9a75fd5794986880a2f4c546c48f7818a988170e7d92d6aca5517fea17f219778a7
@@ -0,0 +1,136 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <module type="RUBY_MODULE" version="4">
3
+ <component name="ModuleRunConfigurationManager">
4
+ <shared />
5
+ </component>
6
+ <component name="NewModuleRootManager">
7
+ <content url="file://$MODULE_DIR$">
8
+ <sourceFolder url="file://$MODULE_DIR$/features" isTestSource="true" />
9
+ <sourceFolder url="file://$MODULE_DIR$/spec" isTestSource="true" />
10
+ <sourceFolder url="file://$MODULE_DIR$/test" isTestSource="true" />
11
+ </content>
12
+ <orderEntry type="jdk" jdkName="mise: 3.4.4" jdkType="RUBY_SDK" />
13
+ <orderEntry type="sourceFolder" forTests="false" />
14
+ <orderEntry type="module-library">
15
+ <library name="airrel (v0.1.0) [path][gem]" type="rubylib">
16
+ <properties>
17
+ <option name="additionalInfo">
18
+ <AdditionalInfo>
19
+ <option name="authors" value="24c02" />
20
+ <option name="email" value="163450896+24c02@users.noreply.github.com" />
21
+ <option name="homepage" value="https://github.com/24c02/airrel" />
22
+ <option name="summary" value="arel-like relational algebra for airtable" />
23
+ </AdditionalInfo>
24
+ </option>
25
+ <option name="dependencies">
26
+ <list>
27
+ <Dependency>
28
+ <option name="bounds">
29
+ <list>
30
+ <Bound>
31
+ <option name="operator" value="&gt;=" />
32
+ <option name="version" value="0.1.0" />
33
+ </Bound>
34
+ </list>
35
+ </option>
36
+ <option name="doRequire" value="true" />
37
+ <option name="name" value="norairrecord" />
38
+ </Dependency>
39
+ </list>
40
+ </option>
41
+ <option name="fromPath" value="true" />
42
+ <option name="name" value="airrel" />
43
+ <option name="requirePaths">
44
+ <list>
45
+ <option value="lib" />
46
+ </list>
47
+ </option>
48
+ <option name="url" value="file://$MODULE_DIR$/../airrel" />
49
+ <option name="version" value="0.1.0" />
50
+ </properties>
51
+ <CLASSES>
52
+ <root url="file://$MODULE_DIR$/../airrel/bin" />
53
+ <root url="file://$MODULE_DIR$/../airrel/lib" />
54
+ <root url="file://$MODULE_DIR$/../airrel/sig" />
55
+ <root url="file://$MODULE_DIR$/../airrel/.git" />
56
+ <root url="file://$MODULE_DIR$/../airrel/spec" />
57
+ <root url="file://$MODULE_DIR$/../airrel/.idea" />
58
+ <root url="file://$MODULE_DIR$/../airrel/.github" />
59
+ <root url="file://$MODULE_DIR$/../airrel/examples" />
60
+ </CLASSES>
61
+ <JAVADOC />
62
+ <SOURCES>
63
+ <root url="file://$MODULE_DIR$/../airrel/bin" />
64
+ <root url="file://$MODULE_DIR$/../airrel/lib" />
65
+ <root url="file://$MODULE_DIR$/../airrel/sig" />
66
+ <root url="file://$MODULE_DIR$/../airrel/.git" />
67
+ <root url="file://$MODULE_DIR$/../airrel/spec" />
68
+ <root url="file://$MODULE_DIR$/../airrel/.idea" />
69
+ <root url="file://$MODULE_DIR$/../airrel/.github" />
70
+ <root url="file://$MODULE_DIR$/../airrel/examples" />
71
+ </SOURCES>
72
+ <excluded>
73
+ <root url="file://$MODULE_DIR$/../airrel/bin" />
74
+ <root url="file://$MODULE_DIR$/../airrel/.git" />
75
+ <root url="file://$MODULE_DIR$/../airrel/spec" />
76
+ <root url="file://$MODULE_DIR$/../airrel/.idea" />
77
+ <root url="file://$MODULE_DIR$/../airrel/.github" />
78
+ <root url="file://$MODULE_DIR$/../airrel/examples" />
79
+ </excluded>
80
+ </library>
81
+ </orderEntry>
82
+ <orderEntry type="library" scope="PROVIDED" name="activemodel (v8.1.1, mise: 3.4.4) [gem]" level="application" />
83
+ <orderEntry type="library" scope="PROVIDED" name="activesupport (v8.1.1, mise: 3.4.4) [gem]" level="application" />
84
+ <orderEntry type="library" scope="PROVIDED" name="ast (v2.4.3, mise: 3.4.4) [gem]" level="application" />
85
+ <orderEntry type="library" scope="PROVIDED" name="base64 (v0.3.0, mise: 3.4.4) [gem]" level="application" />
86
+ <orderEntry type="library" scope="PROVIDED" name="bigdecimal (v3.3.1, mise: 3.4.4) [gem]" level="application" />
87
+ <orderEntry type="library" scope="PROVIDED" name="bundler (v2.7.2, mise: 3.4.4) [gem]" level="application" />
88
+ <orderEntry type="library" scope="PROVIDED" name="concurrent-ruby (v1.3.5, mise: 3.4.4) [gem]" level="application" />
89
+ <orderEntry type="library" scope="PROVIDED" name="connection_pool (v2.5.4, mise: 3.4.4) [gem]" level="application" />
90
+ <orderEntry type="library" scope="PROVIDED" name="date (v3.5.0, mise: 3.4.4) [gem]" level="application" />
91
+ <orderEntry type="library" scope="PROVIDED" name="diff-lcs (v1.6.2, mise: 3.4.4) [gem]" level="application" />
92
+ <orderEntry type="library" scope="PROVIDED" name="drb (v2.2.3, mise: 3.4.4) [gem]" level="application" />
93
+ <orderEntry type="library" scope="PROVIDED" name="erb (v6.0.0, mise: 3.4.4) [gem]" level="application" />
94
+ <orderEntry type="library" scope="PROVIDED" name="faraday (v2.14.0, mise: 3.4.4) [gem]" level="application" />
95
+ <orderEntry type="library" scope="PROVIDED" name="faraday-net_http (v3.4.2, mise: 3.4.4) [gem]" level="application" />
96
+ <orderEntry type="library" scope="PROVIDED" name="faraday-net_http_persistent (v2.3.1, mise: 3.4.4) [gem]" level="application" />
97
+ <orderEntry type="library" scope="PROVIDED" name="i18n (v1.14.7, mise: 3.4.4) [gem]" level="application" />
98
+ <orderEntry type="library" scope="PROVIDED" name="io-console (v0.8.1, mise: 3.4.4) [gem]" level="application" />
99
+ <orderEntry type="library" scope="PROVIDED" name="irb (v1.15.3, mise: 3.4.4) [gem]" level="application" />
100
+ <orderEntry type="library" scope="PROVIDED" name="json (v2.16.0, mise: 3.4.4) [gem]" level="application" />
101
+ <orderEntry type="library" scope="PROVIDED" name="language_server-protocol (v3.17.0.5, mise: 3.4.4) [gem]" level="application" />
102
+ <orderEntry type="library" scope="PROVIDED" name="lint_roller (v1.1.0, mise: 3.4.4) [gem]" level="application" />
103
+ <orderEntry type="library" scope="PROVIDED" name="logger (v1.7.0, mise: 3.4.4) [gem]" level="application" />
104
+ <orderEntry type="library" scope="PROVIDED" name="minitest (v5.26.1, mise: 3.4.4) [gem]" level="application" />
105
+ <orderEntry type="library" scope="PROVIDED" name="net-http (v0.8.0, mise: 3.4.4) [gem]" level="application" />
106
+ <orderEntry type="library" scope="PROVIDED" name="net-http-persistent (v4.0.6, mise: 3.4.4) [gem]" level="application" />
107
+ <orderEntry type="library" scope="PROVIDED" name="norairrecord (v0.5.0, mise: 3.4.4) [gem]" level="application" />
108
+ <orderEntry type="library" scope="PROVIDED" name="parallel (v1.27.0, mise: 3.4.4) [gem]" level="application" />
109
+ <orderEntry type="library" scope="PROVIDED" name="parser (v3.3.10.0, mise: 3.4.4) [gem]" level="application" />
110
+ <orderEntry type="library" scope="PROVIDED" name="pp (v0.6.3, mise: 3.4.4) [gem]" level="application" />
111
+ <orderEntry type="library" scope="PROVIDED" name="prettyprint (v0.2.0, mise: 3.4.4) [gem]" level="application" />
112
+ <orderEntry type="library" scope="PROVIDED" name="prism (v1.6.0, mise: 3.4.4) [gem]" level="application" />
113
+ <orderEntry type="library" scope="PROVIDED" name="psych (v5.2.6, mise: 3.4.4) [gem]" level="application" />
114
+ <orderEntry type="library" scope="PROVIDED" name="racc (v1.8.1, mise: 3.4.4) [gem]" level="application" />
115
+ <orderEntry type="library" scope="PROVIDED" name="rainbow (v3.1.1, mise: 3.4.4) [gem]" level="application" />
116
+ <orderEntry type="library" scope="PROVIDED" name="rake (v13.3.1, mise: 3.4.4) [gem]" level="application" />
117
+ <orderEntry type="library" scope="PROVIDED" name="rdoc (v6.15.1, mise: 3.4.4) [gem]" level="application" />
118
+ <orderEntry type="library" scope="PROVIDED" name="regexp_parser (v2.11.3, mise: 3.4.4) [gem]" level="application" />
119
+ <orderEntry type="library" scope="PROVIDED" name="reline (v0.6.3, mise: 3.4.4) [gem]" level="application" />
120
+ <orderEntry type="library" scope="PROVIDED" name="rspec (v3.13.2, mise: 3.4.4) [gem]" level="application" />
121
+ <orderEntry type="library" scope="PROVIDED" name="rspec-core (v3.13.6, mise: 3.4.4) [gem]" level="application" />
122
+ <orderEntry type="library" scope="PROVIDED" name="rspec-expectations (v3.13.5, mise: 3.4.4) [gem]" level="application" />
123
+ <orderEntry type="library" scope="PROVIDED" name="rspec-mocks (v3.13.7, mise: 3.4.4) [gem]" level="application" />
124
+ <orderEntry type="library" scope="PROVIDED" name="rspec-support (v3.13.6, mise: 3.4.4) [gem]" level="application" />
125
+ <orderEntry type="library" scope="PROVIDED" name="rubocop (v1.81.7, mise: 3.4.4) [gem]" level="application" />
126
+ <orderEntry type="library" scope="PROVIDED" name="rubocop-ast (v1.48.0, mise: 3.4.4) [gem]" level="application" />
127
+ <orderEntry type="library" scope="PROVIDED" name="ruby-progressbar (v1.13.0, mise: 3.4.4) [gem]" level="application" />
128
+ <orderEntry type="library" scope="PROVIDED" name="securerandom (v0.4.1, mise: 3.4.4) [gem]" level="application" />
129
+ <orderEntry type="library" scope="PROVIDED" name="stringio (v3.1.8, mise: 3.4.4) [gem]" level="application" />
130
+ <orderEntry type="library" scope="PROVIDED" name="tsort (v0.2.0, mise: 3.4.4) [gem]" level="application" />
131
+ <orderEntry type="library" scope="PROVIDED" name="tzinfo (v2.0.6, mise: 3.4.4) [gem]" level="application" />
132
+ <orderEntry type="library" scope="PROVIDED" name="unicode-display_width (v3.2.0, mise: 3.4.4) [gem]" level="application" />
133
+ <orderEntry type="library" scope="PROVIDED" name="unicode-emoji (v4.1.0, mise: 3.4.4) [gem]" level="application" />
134
+ <orderEntry type="library" scope="PROVIDED" name="uri (v1.1.1, mise: 3.4.4) [gem]" level="application" />
135
+ </component>
136
+ </module>
data/.idea/modules.xml ADDED
@@ -0,0 +1,8 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="ProjectModuleManager">
4
+ <modules>
5
+ <module fileurl="file://$PROJECT_DIR$/.idea/airctiverecord.iml" filepath="$PROJECT_DIR$/.idea/airctiverecord.iml" />
6
+ </modules>
7
+ </component>
8
+ </project>
data/.idea/vcs.xml ADDED
@@ -0,0 +1,6 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="VcsDirectoryMappings">
4
+ <mapping directory="$PROJECT_DIR$" vcs="Git" />
5
+ </component>
6
+ </project>
@@ -0,0 +1,81 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="AutoImportSettings">
4
+ <option name="autoReloadType" value="SELECTIVE" />
5
+ </component>
6
+ <component name="ChangeListManager">
7
+ <list default="true" id="dfa53ab4-5893-4d80-a45d-4d6fd4aa7ea3" name="Changes" comment="">
8
+ <change afterPath="$PROJECT_DIR$/.github/workflows/main.yml" afterDir="false" />
9
+ <change afterPath="$PROJECT_DIR$/.gitignore" afterDir="false" />
10
+ <change afterPath="$PROJECT_DIR$/.idea/airctiverecord.iml" afterDir="false" />
11
+ <change afterPath="$PROJECT_DIR$/.idea/modules.xml" afterDir="false" />
12
+ <change afterPath="$PROJECT_DIR$/.rspec" afterDir="false" />
13
+ <change afterPath="$PROJECT_DIR$/.rubocop.yml" afterDir="false" />
14
+ <change afterPath="$PROJECT_DIR$/Gemfile" afterDir="false" />
15
+ <change afterPath="$PROJECT_DIR$/README.md" afterDir="false" />
16
+ <change afterPath="$PROJECT_DIR$/Rakefile" afterDir="false" />
17
+ <change afterPath="$PROJECT_DIR$/airctiverecord.gemspec" afterDir="false" />
18
+ <change afterPath="$PROJECT_DIR$/bin/console" afterDir="false" />
19
+ <change afterPath="$PROJECT_DIR$/bin/setup" afterDir="false" />
20
+ <change afterPath="$PROJECT_DIR$/lib/airctiverecord.rb" afterDir="false" />
21
+ <change afterPath="$PROJECT_DIR$/lib/airctiverecord/version.rb" afterDir="false" />
22
+ <change afterPath="$PROJECT_DIR$/sig/airctiverecord.rbs" afterDir="false" />
23
+ <change afterPath="$PROJECT_DIR$/spec/airctiverecord_spec.rb" afterDir="false" />
24
+ <change afterPath="$PROJECT_DIR$/spec/spec_helper.rb" afterDir="false" />
25
+ </list>
26
+ <option name="SHOW_DIALOG" value="false" />
27
+ <option name="HIGHLIGHT_CONFLICTS" value="true" />
28
+ <option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
29
+ <option name="LAST_RESOLUTION" value="IGNORE" />
30
+ </component>
31
+ <component name="Git.Settings">
32
+ <option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
33
+ </component>
34
+ <component name="ProjectColorInfo"><![CDATA[{
35
+ "associatedIndex": 7
36
+ }]]></component>
37
+ <component name="ProjectId" id="35d5ItrVNjTScvPedtmAAPuMY9c" />
38
+ <component name="ProjectViewState">
39
+ <option name="hideEmptyMiddlePackages" value="true" />
40
+ <option name="showLibraryContents" value="true" />
41
+ </component>
42
+ <component name="PropertiesComponent"><![CDATA[{
43
+ "keyToString": {
44
+ "ModuleVcsDetector.initialDetectionPerformed": "true",
45
+ "RunOnceActivity.ShowReadmeOnStart": "true",
46
+ "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
47
+ "com.intellij.lang.ruby.rbs.tools.collection.workspace.sync.RbsCollectionUpdateProjectActivity#LAST_UPDATE_TIMESTAMP": "1763427092403",
48
+ "git-widget-placeholder": "main",
49
+ "node.js.detected.package.eslint": "true",
50
+ "node.js.detected.package.tslint": "true",
51
+ "node.js.selected.package.eslint": "(autodetect)",
52
+ "node.js.selected.package.tslint": "(autodetect)",
53
+ "nodejs_package_manager_path": "npm",
54
+ "ruby.structure.view.model.defaults.configured": "true",
55
+ "settings.editor.selected.configurable": "preferences.lookFeel",
56
+ "vue.rearranger.settings.migration": "true"
57
+ }
58
+ }]]></component>
59
+ <component name="SharedIndexes">
60
+ <attachedChunks>
61
+ <set>
62
+ <option value="bundled-js-predefined-d6986cc7102b-9c94529fcfe0-JavaScript-RM-252.26199.157" />
63
+ </set>
64
+ </attachedChunks>
65
+ </component>
66
+ <component name="SpringUtil" SPRING_PRE_LOADER_OPTION="true" RAKE_SPRING_PRE_LOADER_OPTION="true" RAILS_SPRING_PRE_LOADER_OPTION="true" />
67
+ <component name="TaskManager">
68
+ <task active="true" id="Default" summary="Default task">
69
+ <changelist id="dfa53ab4-5893-4d80-a45d-4d6fd4aa7ea3" name="Changes" comment="" />
70
+ <created>1763427032396</created>
71
+ <option name="number" value="Default" />
72
+ <option name="presentableId" value="Default" />
73
+ <updated>1763427032396</updated>
74
+ <workItem from="1763427034886" duration="4408000" />
75
+ </task>
76
+ <servers />
77
+ </component>
78
+ <component name="TypeScriptGeneratedFilesManager">
79
+ <option name="version" value="3" />
80
+ </component>
81
+ </project>
data/README.md ADDED
@@ -0,0 +1,341 @@
1
+ # AirctiveRecord
2
+
3
+ activerecord-ish API for airtable, built on [norairrecord](https://github.com/24c02/norairrecord)
4
+
5
+ ## what you get
6
+
7
+ * **chainable queries** via [Airrel](https://github.com/24c02/airrel) - lazy-loading relations just like ActiveRecord
8
+ * activemodel validations (presence, format, numericality, etc.)
9
+ * activemodel callbacks (before_save, after_create, etc.)
10
+ * activemodel dirty tracking (changed? / was / change)
11
+ * activerecord-style attributes with `field` mappings for airtable fields with spaces
12
+ * chainable scopes that return relations
13
+ * associations (has_many, belongs_to, has_one, through:)
14
+ * all the norairrecord goodness (batch ops, transactions, comments, STI, etc.)
15
+
16
+ ## installation
17
+
18
+ ```ruby
19
+ gem 'airctiverecord'
20
+ ```
21
+
22
+ ```bash
23
+ bundle install
24
+ ```
25
+
26
+ ## setup
27
+
28
+ ```ruby
29
+ Norairrecord.api_key = ENV['AIRTABLE_API_KEY']
30
+
31
+ class AirpplicationRecord < AirctiveRecord::Base
32
+ self.base_key = ENV['AIRTABLE_BASE_KEY']
33
+ end
34
+ ```
35
+
36
+ ## usage
37
+
38
+ ### basic model
39
+
40
+ ```ruby
41
+ class User < AirpplicationRecord
42
+ self.table_name = "Users"
43
+
44
+ # map ruby names to airtable field names
45
+ field :first_name, "First Name"
46
+ field :last_name, "Last Name"
47
+ field :email, "Email Address"
48
+ field :phone, "Phone Number"
49
+
50
+ # validations
51
+ validates :first_name, :last_name, presence: true
52
+ validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }
53
+
54
+ # callbacks
55
+ before_save :normalize_email
56
+ after_create :send_welcome_email
57
+
58
+ private
59
+
60
+ def normalize_email
61
+ self.email = email&.downcase&.strip
62
+ end
63
+
64
+ def send_welcome_email
65
+ # ...
66
+ end
67
+ end
68
+ ```
69
+
70
+ ### field mappings
71
+
72
+ lots of airtable fields have spaces, ruby doesn't like that. use `field`:
73
+
74
+ ```ruby
75
+ class Contact < AirpplicationRecord
76
+ self.table_name = "Contacts"
77
+
78
+ field :first_name, "First Name"
79
+ field :company_name, "Company Name"
80
+ field :is_vip, "VIP?"
81
+ field :date_added, "Date Added"
82
+ end
83
+
84
+ contact = Contact.new(
85
+ first_name: "Jane", # writes to "First Name"
86
+ company_name: "Acme", # writes to "Company Name"
87
+ is_vip: true # writes to "VIP?"
88
+ )
89
+
90
+ contact.first_name # => "Jane"
91
+ contact.first_name? # => true (presence check)
92
+ contact.first_name_changed? # => dirty tracking works
93
+ ```
94
+
95
+ ### CRUD
96
+
97
+ ```ruby
98
+ # create
99
+ user = User.create(first_name: "Alice", email: "alice@example.com")
100
+ user = User.create!(first_name: "Alice", email: "alice@example.com") # raises on validation error
101
+
102
+ # read
103
+ user = User.find("recXXXXXXXXXXXXXX")
104
+ users = User.all
105
+ user = User.first
106
+ user = User.find_by(email: "alice@example.com")
107
+ user = User.find_by!(email: "alice@example.com") # raises if not found
108
+
109
+ # update
110
+ user.update(first_name: "Alicia")
111
+ user.first_name = "Alicia"
112
+ user.save
113
+ user.update!(first_name: "Alicia") # raises on validation error
114
+
115
+ # delete
116
+ user.destroy
117
+
118
+ # reload
119
+ user.reload
120
+ ```
121
+
122
+ ### querying (now with chainable relations!)
123
+
124
+ ```ruby
125
+ # chainable queries (powered by Airrel)
126
+ User.where(role: "admin").where(active: true).order(created_at: :desc).limit(10)
127
+
128
+ # hash queries (converted to airtable formulas)
129
+ User.where(role: "admin", active: true)
130
+ User.where(age: 18..65) # range queries
131
+ User.where(role: ["admin", "mod"]) # IN queries
132
+ User.where(email: nil) # BLANK() checks
133
+
134
+ # raw airtable formulas still work
135
+ User.where("{Age} > 18")
136
+ User.where("AND({Email} != '', {Active} = TRUE())")
137
+
138
+ # sorting
139
+ User.order(:name)
140
+ User.order(name: :asc, age: :desc)
141
+ User.order(:created_at).reverse_order
142
+
143
+ # limiting
144
+ User.limit(10)
145
+ User.offset(20)
146
+
147
+ # lazy loading - queries don't execute until you iterate
148
+ users = User.where(role: "admin") # no API call yet
149
+ users.each { |u| puts u.name } # now it executes
150
+
151
+ # scopes are chainable now!
152
+ class User < AirpplicationRecord
153
+ scope :active, -> { where(active: true) }
154
+ scope :admins, -> { where(role: "admin") }
155
+ scope :recent, -> { order(created_at: :desc).limit(10) }
156
+ end
157
+
158
+ User.active.admins.recent # chains perfectly!
159
+ ```
160
+
161
+ ### validations
162
+
163
+ ```ruby
164
+ class User < AirctiveRecord::Base
165
+ validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
166
+ validates :age, numericality: { greater_than_or_equal_to: 18 }
167
+ validates :username, length: { minimum: 3, maximum: 20 }
168
+ validates :role, inclusion: { in: %w[admin user guest] }
169
+
170
+ validate :custom_validation
171
+
172
+ private
173
+
174
+ def custom_validation
175
+ errors.add(:base, "nope") if some_condition?
176
+ end
177
+ end
178
+
179
+ user = User.new(email: "invalid")
180
+ user.valid? # => false
181
+ user.errors.full_messages # => ["Email is invalid"]
182
+ user.save # => false
183
+ user.save! # => raises AirctiveRecord::RecordInvalid
184
+ ```
185
+
186
+ ### callbacks
187
+
188
+ ```ruby
189
+ class User < AirctiveRecord::Base
190
+ before_validation :normalize_data
191
+ after_validation :log_errors
192
+
193
+ before_save :encrypt_password
194
+ after_save :clear_cache
195
+
196
+ before_create :set_defaults
197
+ after_create :send_notification
198
+
199
+ before_update :check_changes
200
+ after_update :sync_with_service
201
+
202
+ before_destroy :cleanup_associations
203
+ after_destroy :log_deletion
204
+ end
205
+ ```
206
+
207
+ ### dirty tracking
208
+
209
+ ```ruby
210
+ user = User.find("recXXX")
211
+ user.first_name = "New Name"
212
+
213
+ user.changed? # => true
214
+ user.first_name_changed? # => true
215
+ user.first_name_was # => "Old Name"
216
+ user.first_name_change # => ["Old Name", "New Name"]
217
+ user.changes # => { "first_name" => ["Old Name", "New Name"] }
218
+
219
+ user.save
220
+ user.changed? # => false
221
+ ```
222
+
223
+ ### norairrecord features
224
+
225
+ you still get all of norairrecord since we inherit from it:
226
+
227
+ ```ruby
228
+ # batch ops
229
+ User.batch_create([user1, user2, user3])
230
+ User.batch_update([user1, user2, user3])
231
+ User.batch_upsert(users, ["Email"])
232
+
233
+ # transactions
234
+ user.transaction do |u|
235
+ u["First Name"] = "New Name"
236
+ u["Email"] = "new@example.com"
237
+ end
238
+
239
+ # comments
240
+ user.comment("great customer!")
241
+
242
+ # direct field access
243
+ user["Custom Field Name"] = "value"
244
+
245
+ # airtable URL
246
+ user.airtable_url # => "https://airtable.com/appXXX/tblYYY/recZZZ"
247
+
248
+ # subtypes
249
+ class Animal < AirctiveRecord::Base
250
+ has_subtypes "Type", {
251
+ "dog" => "Dog",
252
+ "cat" => "Cat"
253
+ }
254
+ end
255
+
256
+ class Dog < Animal; end
257
+ class Cat < Animal; end
258
+
259
+ Animal.all # => [<Dog>, <Cat>, <Dog>]
260
+ ```
261
+
262
+ ## security & escaping
263
+
264
+ string values are properly escaped using airtable's formula syntax (backslash escaping):
265
+
266
+ ```ruby
267
+ User.where(name: "O'Reilly")
268
+ # => {name} = 'O\'Reilly'
269
+
270
+ Contact.where(email: "test') & malicious")
271
+ # => safe! escaped to "{email} = 'test\') & malicious'"
272
+ ```
273
+
274
+ field names from `field` mappings are used as-is. if you're dynamically generating field names from user input, validate them first.
275
+
276
+ ## architecture
277
+
278
+ **relation classes**
279
+
280
+ each model automatically gets its own Relation subclass. this means:
281
+ - scopes are isolated per model (User.active doesn't pollute Post)
282
+ - field mappings are applied correctly
283
+ - you can define model-specific query methods
284
+
285
+ ```ruby
286
+ User.relation_class # => User's own Relation class
287
+ Post.relation_class # => Post's own Relation class
288
+ User.relation_class == Post.relation_class # => false
289
+ ```
290
+
291
+ **query flow**
292
+
293
+ 1. `User.where(role: "admin")` → creates a `User::Relation` instance
294
+ 2. `.where(active: true)` → returns new relation with merged conditions
295
+ 3. `.order(:name)` → returns new relation with ordering
296
+ 4. `.to_a` or `.each` → executes the query via norairrecord
297
+
298
+ field mappings are applied when:
299
+ - building formulas from hash conditions
300
+ - converting field names in order clauses
301
+ - accessing attributes on records
302
+
303
+ ## performance
304
+
305
+ **count is expensive**
306
+
307
+ `.count` loads all matching records. use `.any?` or `.exists?` to check existence:
308
+
309
+ ```ruby
310
+ User.where(role: "admin").count # loads ALL admins
311
+ User.where(role: "admin").any? # only loads 1 record ✓
312
+ ```
313
+
314
+ **first/last are optimized**
315
+
316
+ automatically use `limit(1)` to avoid loading unnecessary records:
317
+
318
+ ```ruby
319
+ User.first # limit(1)
320
+ User.last # limit(1) with reversed order
321
+ User.first(10) # limit(10)
322
+ ```
323
+
324
+ **large tables**
325
+
326
+ for tables with 25k+ records, process in batches:
327
+
328
+ ```ruby
329
+ # batch processing
330
+ offset = 0
331
+ loop do
332
+ batch = User.limit(100).offset(offset).to_a
333
+ break if batch.empty?
334
+ batch.each { |user| process(user) }
335
+ offset += 100
336
+ end
337
+ ```
338
+
339
+ ## license
340
+
341
+ MIT
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]