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 +7 -0
- data/.idea/airctiverecord.iml +136 -0
- data/.idea/modules.xml +8 -0
- data/.idea/vcs.xml +6 -0
- data/.idea/workspace.xml +81 -0
- data/README.md +341 -0
- data/Rakefile +12 -0
- data/examples/associations_example.rb +88 -0
- data/examples/basic_usage.rb +91 -0
- data/examples/boolean_fields.rb +52 -0
- data/examples/chainable_queries.rb +60 -0
- data/examples/field_mapping_example.rb +101 -0
- data/examples/readonly_fields.rb +52 -0
- data/examples/scope_isolation.rb +67 -0
- data/lib/airctiverecord/associations.rb +48 -0
- data/lib/airctiverecord/attribute_methods.rb +98 -0
- data/lib/airctiverecord/base.rb +136 -0
- data/lib/airctiverecord/callbacks.rb +20 -0
- data/lib/airctiverecord/relation.rb +54 -0
- data/lib/airctiverecord/scoping.rb +68 -0
- data/lib/airctiverecord/validations.rb +24 -0
- data/lib/airctiverecord/version.rb +5 -0
- data/lib/airctiverecord.rb +20 -0
- data/sig/airctiverecord.rbs +4 -0
- metadata +121 -0
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=">=" />
|
|
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
data/.idea/workspace.xml
ADDED
|
@@ -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
|