diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e334be..757d278 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# 1.0.0 (unreleased) + +* 1.0.0 will introduce breaking changes, including removing support for symbols. To update, change snake-case symbols to their correct column names (for example, `record["First Name"]` instead of `record[:first_name]`) + +# 0.2.5 + +* Deprecate using symbols instead of strings + # 0.2.4 * Don't flag as dirty if change is equal diff --git a/README.md b/README.md index ce2a873..12267d8 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ class Tea < Airrecord::Table self.base_key = "app1" self.table_name = "Teas" - has_many :brews, class: 'Brew', column: "Brews" + has_many "Brews", class: 'Brew', column: "Brews" def self.chinese all(filter: '{Country} = "China"') @@ -31,11 +31,11 @@ class Tea < Airrecord::Table end def location - [self[:village], self[:country], self[:region]].compact.join(", ") + [self["Village"], self["Country"], self["Region"]].compact.join(", ") end def green? - self[:type] == "Green" + self["Type"] == "Green" end end @@ -43,22 +43,22 @@ class Brew < Airrecord::Table self.base_key = "app1" self.table_name = "Brews" - belongs_to :tea, class: 'Tea', column: 'Tea' + belongs_to "Tea", class: 'Tea', column: 'Tea' def self.hot all(filter: "{Temperature} > 90") end def done_brewing? - self[:created_at] + self[:duration] > Time.now + self["Created At"] + self["Duration"] > Time.now end end teas = Tea.all tea = teas.first -tea[:country] # access atribute +tea["Country"] # access atribute tea.location # instance methods -tea[:brews] # associated brews +tea["Brews"] # associated brews ``` A short-hand API for definitions and more ad-hoc querying is also available: @@ -67,7 +67,7 @@ A short-hand API for definitions and more ad-hoc querying is also available: Tea = Airrecord.table("api_key", "app_key", "Teas") Tea.all.each do |record| - puts "#{record.id}: #{record[:name]}" + puts "#{record.id}: #{record["Name"]}" end Tea.find("rec3838") @@ -107,7 +107,7 @@ class Tea < Airrecord::Table self.table_name = "Teas" def location - [self[:village], self[:country], self[:region]].compact.join(", ") + [self["Village"], self["Country"], self["Region"]].compact.join(", ") end end ``` @@ -158,17 +158,16 @@ The `sort` option can be used to sort results returned from the Airtable API. ```ruby # Sort teas by the Name column in ascending order -Tea.all(sort: { Name: "asc" }) +Tea.all(sort: { "Name" => "asc" }) # Sort teas by Type (green, black, oolong, ..) in descending order -Tea.all(sort: { Type: "desc" }) +Tea.all(sort: { "Type" => "desc" }) # Sort teas by price in descending order -Tea.all(sort: { Price: "desc" }) +Tea.all(sort: { "Price" => "desc" }) ``` -Note again that the key _must_ be the full column name. Snake-cased variants do -not work here. +Note again that the key _must_ be the full column name. As mentioned above, by default Airrecord will return results from all pages. This can be slow if you have 1000s of records. You may wish to use the `view` @@ -181,7 +180,7 @@ calls. Airrecord will _always_ fetch the maximum possible amount of records Tea.all(paginate: false) # Give me only the most recent teas -Tea.all(sort: { "Created At": "desc" }, paginate: false) +Tea.all(sort: { "Created At" => "desc" }, paginate: false) ``` ### Creating @@ -192,16 +191,15 @@ Creating a new record is done through `#create`. tea = Tea.new("Name" => "Feng Gang", "Type" => "Green", "Country" => "China") tea.create # creates the record tea.id # id of the new record -tea[:name] # "Feng Gang", accessed through snake-cased name +tea["Name"] # "Feng Gang" ``` -Note that when instantiating the new record the column names (keys of the passed -named parameters) need to match the exact column names in Airtable, otherwise -Airrecord will throw an error that no column matches it. +Note that column names need to match the exact column names in Airtable, +otherwise Airrecord will throw an error that no column matches it. -In the future I hope to provide more convient names for these (snake-cased), -however, this is error-prone without a proper schema API from Airtable which has -still not been released. +_Earlier versions of airrecord provided methods for snake-cased column names +and symbols, however this proved error-prone without a proper schema API from +Airtable which has still not been released._ ### Updating @@ -210,11 +208,7 @@ Airtable with `#save`. ```ruby tea = Tea.find("someid") -tea[:name] = "Feng Gang Organic" - -# Since the Village column is not set, we do not have access to a snake-cased -# variant since the mapping is not determined. For all we know, the correct column -# name could be "VilLlaGe". Therefore, we must use the proper column name. +tea["Name"] = "Feng Gang Organic" tea["Village"] = "Feng Gang" tea.save # persist to Airtable @@ -236,7 +230,7 @@ providing the URL. Unfortunately, it does not allow uploading directly. ```ruby word = World.find("cantankerous") -word["Pronounciation"] = [{url: "https://s3.ca-central-1.amazonaws.com/word-pronunciations/cantankerous.mp3}] +word["Pronounciation"] = [{url: "https://s3.ca-central-1.amazonaws.com/word-pronunciations/cantankerous.mp3"}] word.save ``` @@ -274,14 +268,14 @@ class Tea < Airrecord::Table self.base_key = "app1" self.table_name = "Teas" - has_many :brews, class: 'Brew', column: "Brews" + has_many "Brews", class: 'Brew', column: "Brews" end class Brew < Airrecord::Table self.base_key = "app1" self.table_name = "Brews" - belongs_to :tea, class: 'Tea', column: 'Tea' + belongs_to "Tea", class: 'Tea', column: 'Tea' end ``` @@ -296,14 +290,14 @@ To retrieve records from associations to a record: ```ruby tea = Tea.find('rec84') -tea[:brews] # brews associated with tea +tea["Brews"] # brews associated with tea ``` This in turn works the other way too: ```ruby brew = Brew.find('rec849') -brew[:tea] # the associated tea instance +brew["Tea"] # the associated tea instance ``` ### Creating associated records @@ -328,20 +322,12 @@ around. Tea = Airrecord.table("api_key", "app_key", "Teas") Tea.all.each do |record| - puts "#{record.id}: #{record[:name]}" + puts "#{record.id}: #{record["Name"]}" end Tea.find("rec3838") ``` -### Snake-cased helper methods - -When retrieving an existing record from Airtable, snake-cased helper names are -available to index attributes. These are _only_ available on retrieved records, -and _only_ if the column was set. If it's `nil`, it will not exist. That means -if you want to set column that has a `nil` value for a column type, you'll have -to fully type it out. - ### Production Middlewares For production use-cases, it's worth considering adding retries and circuit diff --git a/lib/airrecord/table.rb b/lib/airrecord/table.rb index 2d0f44c..ae4fd64 100644 --- a/lib/airrecord/table.rb +++ b/lib/airrecord/table.rb @@ -8,10 +8,24 @@ module Airrecord # Right now I bet there's a bunch of bugs around similar named column keys (in # terms of capitalization), it's inconsistent and non-obvious that `create` # doesn't use the same column keys as everything else. + # + # 2018-11-01 + # deprecate_symbols: long-term plan is to force everyone to use raw strings, + # to match the Airtable behavior. For now we'll just warn when using symbols + # with a deprecation notice. + class Table + def deprecate_symbols + self.class.deprecate_symbols + end + class << self attr_accessor :base_key, :table_name, :api_key, :associations + def deprecate_symbols + warn Kernel.caller.first + ": warning: Using symbols with airrecord is deprecated." + end + def client @@clients ||= {} @@clients[api_key] ||= Client.new(api_key) @@ -20,7 +34,7 @@ def client def has_many(name, options) @associations ||= [] @associations << { - field: name.to_sym, + field: name.to_sym, # todo: deprecate_symbols }.merge(options) end @@ -49,6 +63,7 @@ def records(filter: nil, sort: nil, view: nil, offset: nil, paginate: true, fiel if sort options[:sort] = sort.map { |field, direction| + deprecate_symbols if field.is_a? Symbol { field: field.to_s, direction: direction } } end @@ -106,8 +121,10 @@ def [](key) value = nil if fields[key] + deprecate_symbols if key.is_a? Symbol value = fields[key] elsif column_mappings[key] + deprecate_symbols if key.is_a? Symbol value = fields[column_mappings[key]] end @@ -125,11 +142,13 @@ def [](key) end def []=(key, value) + deprecate_symbols if key.is_a? Symbol if fields[key] return if fields[key] == value # no-op @updated_keys << key fields[key] = value elsif column_mappings[key] + deprecate_symbols return if fields[column_mappings[key]] == value # no-op @updated_keys << column_mappings[key] fields[column_mappings[key]] = value @@ -221,15 +240,15 @@ def association(key) def fields=(fields) @updated_keys = [] - @column_mappings = Hash[fields.keys.map { |key| [underscore(key), key] }] + @column_mappings = Hash[fields.keys.map { |key| [underscore(key), key] }] # TODO remove (deprecate_symbols) @fields = fields end - def self.underscore(key) + def self.underscore(key) # TODO remove (deprecate_symbols) key.to_s.strip.gsub(/\W+/, "_").downcase.to_sym end - def underscore(key) + def underscore(key) # TODO remove (deprecate_symbols) self.class.underscore(key) end @@ -249,6 +268,7 @@ def type_cast(value) value end end + end def self.table(api_key, base_key, table_name) diff --git a/lib/airrecord/version.rb b/lib/airrecord/version.rb index 737cd6d..96c21e3 100644 --- a/lib/airrecord/version.rb +++ b/lib/airrecord/version.rb @@ -1,3 +1,3 @@ module Airrecord - VERSION = "0.2.4" + VERSION = "0.2.5" end diff --git a/test/associations_test.rb b/test/associations_test.rb index e83ef1c..8b62113 100644 --- a/test/associations_test.rb +++ b/test/associations_test.rb @@ -5,7 +5,7 @@ class Tea < Airrecord::Table self.base_key = "app1" self.table_name = "Teas" - has_many :brews, class: "Brew", column: "Brews" + has_many "Brews", class: "Brew", column: "Brews" end class Brew < Airrecord::Table @@ -13,7 +13,7 @@ class Brew < Airrecord::Table self.base_key = "app1" self.table_name = "Brews" - belongs_to :tea, class: "Tea", column: "Tea" + belongs_to "Tea", class: "Tea", column: "Tea" end class AssociationsTest < MiniTest::Test @@ -25,42 +25,42 @@ def setup end def test_has_many_associations - tea = Tea.new(Name: "Dong Ding", Brews: ["rec2"]) + tea = Tea.new("Name" => "Dong Ding", "Brews" => ["rec2"]) - record = Brew.new(Name: "Good brew") + record = Brew.new("Name" => "Good brew") stub_find_request(record, id: "rec2", table: Brew) - assert_equal 1, tea[:brews].size - assert_kind_of Airrecord::Table, tea[:brews].first - assert_equal "rec2", tea[:brews].first.id + assert_equal 1, tea["Brews"].size + assert_kind_of Airrecord::Table, tea["Brews"].first + assert_equal "rec2", tea["Brews"].first.id end def test_belongs_to - brew = Brew.new(Name: "Good Brew", Tea: ["rec1"]) - tea = Tea.new(Name: "Dong Ding", Brews: ["rec2"]) + brew = Brew.new("Name" => "Good Brew", "Tea" => ["rec1"]) + tea = Tea.new("Name" => "Dong Ding", "Brews" => ["rec2"]) stub_find_request(tea, table: Tea, id: "rec1") - assert_equal "rec1", brew[:tea].id + assert_equal "rec1", brew["Tea"].id end def test_build_association_and_post_id - tea = Tea.new({Name: "Jingning", Brews: []}, id: "rec1") - brew = Brew.new(Name: "greeaat", Tea: [tea]) + tea = Tea.new({"Name" => "Jingning", "Brews" => []}, id: "rec1") + brew = Brew.new("Name" => "greeaat", "Tea" => [tea]) stub_post_request(brew, table: Brew) brew.create stub_find_request(tea, table: Tea, id: "rec1") - assert_equal tea.id, brew[:tea].id + assert_equal tea.id, brew["Tea"].id end def test_build_association_from_strings - tea = Tea.new({Name: "Jingning", Brews: ["rec2"]}) + tea = Tea.new({"Name" => "Jingning", "Brews" => ["rec2"]}) stub_post_request(tea, table: Tea) tea.create stub_find_request(Brew.new({}), table: Brew, id: "rec2") - assert_equal 1, tea[:brews].count + assert_equal 1, tea["Brews"].count end end diff --git a/test/table_test.rb b/test/table_test.rb index e855deb..0254ada 100644 --- a/test/table_test.rb +++ b/test/table_test.rb @@ -25,7 +25,7 @@ def setup builder.adapter :test, @stubs } - stub_request([{"Name": "omg", "Notes": "hello world", " Something else\n" => "hi"}, {"Name": "more", "Notes": "walrus"}]) + stub_request([{"Name" => "omg", "Notes" => "hello world"}, {"Name" => "more", "Notes" => "walrus"}]) end def test_table_overrides_key @@ -48,58 +48,54 @@ def test_different_clients_with_different_api_keys end def test_filter_records - stub_request([{"Name": "yes"}, {"Name": "no"}]) + stub_request([{"Name" => "yes"}, {"Name" => "no"}]) records = @table.records(filter: "Name") - assert_equal "yes", records[0][:name] + assert_equal "yes", records[0]["Name"] end def test_sort_records - stub_request([{"Name": "a"}, {"Name": "b"}]) + stub_request([{"Name" => "a"}, {"Name" => "b"}]) - records = @table.records(sort: { Name: 'asc' }) - assert_equal "a", records[0][:name] - assert_equal "b", records[1][:name] + records = @table.records(sort: { "Name" => 'asc' }) + assert_equal "a", records[0]["Name"] + assert_equal "b", records[1]["Name"] end def test_view_records - stub_request([{"Name": "a"}, {"Name": "a"}]) + stub_request([{"Name" => "a"}, {"Name" => "a"}]) records = @table.records(view: 'A') - assert_equal "a", records[0][:name] - assert_equal "a", records[1][:name] + assert_equal "a", records[0]["Name"] + assert_equal "a", records[1]["Name"] end def test_follow_pagination_by_default - stub_request([{"Name": "1"}, {"Name": "2"}], offset: 'dasfuhiu') - stub_request([{"Name": "3"}, {"Name": "4"}], offset: 'odjafio', clear: false) - stub_request([{"Name": "5"}, {"Name": "6"}], clear: false) + stub_request([{"Name" => "1"}, {"Name" => "2"}], offset: 'dasfuhiu') + stub_request([{"Name" => "3"}, {"Name" => "4"}], offset: 'odjafio', clear: false) + stub_request([{"Name" => "5"}, {"Name" => "6"}], clear: false) records = @table.records assert_equal 6, records.size end def test_dont_follow_pagination_if_disabled - stub_request([{"Name": "1"}, {"Name": "2"}], offset: 'dasfuhiu') - stub_request([{"Name": "3"}, {"Name": "4"}], offset: 'odjafio', clear: false) - stub_request([{"Name": "5"}, {"Name": "6"}], clear: false) + stub_request([{"Name" => "1"}, {"Name" => "2"}], offset: 'dasfuhiu') + stub_request([{"Name" => "3"}, {"Name" => "4"}], offset: 'odjafio', clear: false) + stub_request([{"Name" => "5"}, {"Name" => "6"}], clear: false) records = @table.records(paginate: false) assert_equal 2, records.size end def test_index_by_normalized_name - assert_equal "omg", first_record[:name] + assert_equal "omg", first_record["Name"] end def test_index_by_column_name assert_equal "omg", first_record["Name"] end - def test_cleans_bad_keys - assert_equal "hi", first_record[:something_else] - end - def test_id assert_instance_of String, first_record.id end @@ -120,20 +116,20 @@ def test_error_response def test_change_value record = first_record - record[:name] = "testest" - assert_equal "testest", record[:name] + record["Name"] = "testest" + assert_equal "testest", record["Name"] end def test_change_value_on_column_name record = first_record record["Name"] = "testest" - assert_equal "testest", record[:name] + assert_equal "testest", record["Name"] end def test_change_value_and_update record = first_record - record[:name] = "new_name" + record["Name"] = "new_name" stub_patch_request(record, ["Name"]) assert record.save @@ -142,7 +138,7 @@ def test_change_value_and_update def test_change_value_then_save_again_should_noop record = first_record - record[:name] = "new_name" + record["Name"] = "new_name" stub_patch_request(record, ["Name"]) assert record.save @@ -152,18 +148,18 @@ def test_change_value_then_save_again_should_noop def test_updates_fields_to_newest_values_after_update record = first_record - record[:name] = "new_name" + record["Name"] = "new_name" stub_patch_request(record, ["Name"], return_body: record.fields.merge("Notes" => "new animal")) assert record.save - assert_equal "new_name", record[:name] - assert_equal "new animal", record[:notes] + assert_equal "new_name", record["Name"] + assert_equal "new animal", record["Notes"] end def test_update_failure record = first_record - record[:name] = "new_name" + record["Name"] = "new_name" stub_patch_request(record, ["Name"], return_body: { error: { type: "oh noes", message: 'yes' } }, status: 401) assert_raises Airrecord::Error do @@ -174,7 +170,7 @@ def test_update_failure def test_update_failure_then_succeed record = first_record - record[:name] = "new_name" + record["Name"] = "new_name" stub_patch_request(record, ["Name"], return_body: { error: { type: "oh noes", message: 'yes' } }, status: 401) assert_raises Airrecord::Error do @@ -186,7 +182,7 @@ def test_update_failure_then_succeed end def test_update_raises_if_new_record - record = @table.new(Name: "omg") + record = @table.new("Name" => "omg") assert_raises Airrecord::Error do record.save @@ -198,7 +194,7 @@ def test_existing_record_is_not_new end def test_build_new_record - record = @table.new(Name: "omg") + record = @table.new("Name" => "omg") refute record.id refute record.created_at @@ -206,7 +202,7 @@ def test_build_new_record end def test_create_new_record - record = @table.new(Name: "omg") + record = @table.new("Name" => "omg") stub_post_request(record) @@ -214,7 +210,7 @@ def test_create_new_record end def test_create_existing_record_fails - record = @table.new(Name: "omg") + record = @table.new("Name" => "omg") stub_post_request(record) @@ -226,7 +222,7 @@ def test_create_existing_record_fails end def test_create_handles_error - record = @table.new(Name: "omg") + record = @table.new("Name" => "omg") stub_post_request(record, status: 401, return_body: { error: { type: "omg", message: "wow" }}) @@ -236,12 +232,12 @@ def test_create_handles_error end def test_find - record = @table.new(Name: "walrus") + record = @table.new("Name" => "walrus") stub_find_request(record, id: "iodfajsofja") record = @table.find("iodfajsofja") - assert_equal "walrus", record[:name] + assert_equal "walrus", record["Name"] assert_equal "iodfajsofja", record.id end @@ -254,7 +250,7 @@ def test_find_handles_error end def test_destroy_new_record_fails - record = @table.new(Name: "walrus") + record = @table.new("Name" => "walrus") assert_raises Airrecord::Error do record.destroy @@ -287,36 +283,36 @@ def test_error_handles_errors_without_body end def test_dates_are_type_casted - stub_request([{"Name": "omg", "Created": Time.now.to_s}]) + stub_request([{"Name" => "omg", "Created" => Time.now.to_s}]) record = first_record - assert_instance_of Time, record[:created] + assert_instance_of Time, record["Created"] end def test_comparison - alpha = @table.new("Name": "Name", "Created": Time.at(0)) - beta = @table.new("Name": "Name", "Created": Time.at(0)) + alpha = @table.new("Name" => "Name", "Created" => Time.at(0)) + beta = @table.new("Name" => "Name", "Created" => Time.at(0)) assert_equal alpha, beta end def test_comparison_different_classes - alpha = @table.new("Name": "Name", "Created": Time.at(0)) - beta = Walrus.new("Name": "Name", "Created": Time.at(0)) + alpha = @table.new("Name" => "Name", "Created" => Time.at(0)) + beta = Walrus.new("Name" => "Name", "Created" => Time.at(0)) refute_equal alpha, beta end def test_association_accepts_non_enumerable - walrus = Walrus.new("Name": "Wally") - foot = Foot.new("Name": "FrontRight", "walrus": walrus) + walrus = Walrus.new("Name" => "Wally") + foot = Foot.new("Name" => "FrontRight", "walrus" => walrus) foot.serializable_fields end def test_dont_update_if_equal - walrus = Walrus.new("Name": "Wally") - walrus[:name] = "Wally" + walrus = Walrus.new("Name" => "Wally") + walrus["Name"] = "Wally" assert walrus.updated_keys.empty? end end