Skip to content

Instantly share code, notes, and snippets.

@yoshida-eth0
Last active February 25, 2021 05:17
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save yoshida-eth0/25487c425ff613a9b9cf3d4a59c76b15 to your computer and use it in GitHub Desktop.
Save yoshida-eth0/25487c425ff613a9b9cf3d4a59c76b15 to your computer and use it in GitHub Desktop.
DynamoidのHash-Range Tableでhas_many/has_one/belongs_to/embeds_many/embeds_oneできるようにした。
DynamoidのHash-Range Tableでhas_many/has_one/belongs_to/embeds_many/embeds_oneできるようにした。
Dynamoid標準のhas_many/has_one/belongs_toは双方のドキュメントに相手のHash IDを保持する構成だけど、
これはActiveRecord同様にbelongs_toを定義した側にidを持つ。
hr_belongs_toで指定したhash_foreign_key/range_foreign_keyでwhere検索するのでsecondary indexを貼っておく。
DynamoDBではsecondary indexをuniqueにできないのでhas_oneはアクセサとしては1件だけど複数件保存できてしまうので事前に自分で削除する必要あり。
embedded_inは未対応。
dependency:
yoshida-eth0/dynamoid_embeds.rb
https://gist.github.com/yoshida-eth0/2d47dd43438282fad686dd2b84a5b1d1
require 'dynamoid'
module Dynamoid
module HashRangeAssociations
extend ActiveSupport::Concern
included do
class_attribute :hr_options, instance_accessor: false
self.hr_options = {}
end
module ClassMethods
# @params [name] attribute name
# @params [options]
# hash_foreign_key: #{hash_key}_hash_key
# range_foreign_key: #{range_key}_range_key
# inverse_of: #{document_name}
# class: target class
# class_name: target classname
# where: lambda
def hr_has_many(name, options = {})
option = HasOption.new(self, name, :plural, options)
hr_association(name, option, HasMany)
end
# @params [name] attribute name
# @params [options]
# hash_foreign_key: #{hash_key}_hash_key
# range_foreign_key: #{range_key}_range_key
# inverse_of: #{document_name}
# class: target class
# class_name: target classname
# where: lambda
def hr_has_one(name, options = {})
option = HasOption.new(self, name, :single, options)
hr_association(name, option, HasOne)
end
# @params [name] attribute name
# @params [options]
# hash_foreign_key: #{hash_key}_hash_key
# range_foreign_key: #{range_key}_range_key
# inverse_of: #{document_name}
# class: target class
# class_name: target classname
# where: lambda
def hr_belongs_to(name, options = {})
option = BelongsOption.new(self, name, :single, options)
hr_association(name, option, BelongsTo)
if !self.instance_methods.include?(option.hash_foreign_key)
field option.hash_foreign_key, option.hash_foreign_type
end
if option.target_class.range_key && !self.instance_methods.include?(option.range_foreign_key)
field option.range_foreign_key, option.range_foreign_type
end
end
# @params [name] attribute name
# @params [options]
# inverse_of: #{document_name}
# class: target class
# class_name: target classname
# autobuild: bool
def embeds_many(name, options = {})
option = HasOption.new(self, name, :plural, options)
options[:default] = options[:autobuild] ? -> { [] } : nil
self.hr_options[name] = option
field(name, Dynamoid::EmbedsMany(option.target_class), options)
end
# @params [name] attribute name
# @params [options]
# inverse_of: #{document_name}
# class: target class
# class_name: target classname
# autobuild: bool
def embeds_one(name, options = {})
option = HasOption.new(self, name, :single, options)
options[:default] = options[:autobuild] ? -> { option.target_class.new } : nil
self.hr_options[name] = option
field(name, option.target_class, options)
end
# @params [name] attribute name
# @params [options]
# inverse_of: #{document_name}
def embedded_in(name, options = {})
option = BelongsOption.new(self, name, :single, options)
self.hr_options[name] = option
# TODO: embeds側を参照できるようにする
end
def hr_association(name, option, association_class)
self.hr_options[name] = option
define_method(name) do
@hr_associations ||= {}
@hr_associations[name] ||= association_class.new(self, name)
end
define_method("#{name}=") do |value|
self.send(name).setter(value)
end
end
end
class Association < Delegator
def initialize(source, name)
@source = source
@name = name
@option = source.class.hr_options[name]
@target = nil
@loaded = false
end
def loaded?
@loaded
end
def target
unless loaded?
@target = find_target
@loaded = true
update_reference
end
@target
end
def reset
@target = nil
@loaded = false
end
def __getobj__
target
end
def __setobj__(obj)
setter(obj)
end
end
class ManyAssociation < Association
def add_loaded_target(object)
if loaded?
@target << object
end
end
end
class SingleAssociation < Association
def set_loaded_target(object)
@target = object
@loaded = true
end
end
class HasMany < ManyAssociation
def find_target
query = {}
query[@option.hash_foreign_key] = @source.hash_key
if @source.class.range_key
query[@option.range_foreign_key] = @source.range_value
end
@option.target_chain(@source).where(query).all.to_a
end
def setter(object)
while 0<target.length
delete(target.first)
end
self << object
object
end
def delete(object)
target.delete(object)
object.delete
end
def <<(object)
Array(object).each {|o|
o.send(@option.target_field_name).setter(@source)
@target << o
}
end
def update_reference
@target.each {|object|
object.send(@option.target_field_name).set_loaded_target(@source)
}
end
end
class HasOne < SingleAssociation
def find_target
query = {}
query[@option.hash_foreign_key] = @source.hash_key
if @source.class.range_key
query[@option.range_foreign_key] = @source.range_value
end
@option.target_chain(@source).where(query).first
end
def setter(object)
if target
delete(target)
end
self << object
object
end
def delete(object)
target.delete(object)
object.delete
end
def <<(object)
if object
object.send(@option.target_field_name).setter(@source)
end
@target = object
end
def update_reference
@target.send(@option.target_field_name).set_loaded_target(@source)
end
end
class BelongsTo < SingleAssociation
def find_target
query = {}
query[@option.target_class.hash_key] = @source.send(@option.hash_foreign_key)
if @option.target_class.range_key
query[@option.target_class.range_key] = @source.send(@option.range_foreign_key)
end
@option.target_chain(@source).where(query).first
end
def setter(object)
@target = object
if object
@source.send("#{@option.hash_foreign_key}=", object.hash_key)
if @option.target_class.range_key
@source.send("#{@option.range_foreign_key}=", object.range_value)
end
target_association = object.send(@option.target_field_name)
target_option = @option.target_option
if target_option.type==:plural
target_association.add_loaded_target(@source)
elsif target_option.type==:single
target_association.set_loaded_target(@source)
end
end
@loaded = true
object
end
def update_reference
target_option = @option.target_option
if target_option.type==:single
@target.send(@option.target_field_name).set_loaded_target(@source)
end
end
end
module Option
attr_reader :source_class
attr_reader :name
attr_reader :type
attr_reader :options
def initialize(source_class, name, type, options={})
@source_class = source_class
@name = name
@type = type
@options = options
end
def target_class
options[:class] || (options[:class_name] || name).to_s.classify.constantize
end
def where_lambda
options[:where]
end
def target_chain(source)
chain = Dynamoid::Criteria::Chain.new(target_class)
if where_lambda
if 0<where_lambda.arity
chain.instance_exec(source, &where_lambda)
else
chain.instance_exec(&where_lambda)
end
end
chain
end
end
class HasOption
include Option
def target_field_name
single_key_name = options[:inverse_of] || source_class.to_s.underscore.to_sym
if target_class.hr_options[single_key_name]&.type==:single
return single_key_name
end
raise "undefined association: #{target_class.to_s}##{single_key_name}"
end
def target_option
target_class.hr_options[target_field_name]
end
def hash_foreign_key
target_option.hash_foreign_key
end
def range_foreign_key
target_option.range_foreign_key
end
def autobuild?
!!options[:autobuild]
end
end
class BelongsOption
include Option
def target_field_name
plural_key_name = options[:inverse_of] || source_class.to_s.underscore.pluralize.to_sym
single_key_name = options[:inverse_of] || source_class.to_s.underscore.to_sym
if target_class.hr_options[plural_key_name]&.type==:plural
return plural_key_name
end
if target_class.hr_options[single_key_name]&.type==:single
return single_key_name
end
raise "undefined association: #{target_class.to_s}##{plural_key_name} or #{target_class.to_s}##{single_key_name}"
end
def target_option
target_class.hr_options[target_field_name]
end
def hash_foreign_key
options[:hash_foreign_key] || target_option.options[:hash_foreign_key] || "#{target_class.to_s.underscore}_hash_key"
end
def range_foreign_key
options[:range_foreign_key] || target_option.options[:range_foreign_key] || "#{target_class.to_s.underscore}_range_key"
end
def hash_foreign_type
target_class.attributes[target_class.hash_key][:type]
end
def range_foreign_type
target_class.attributes[target_class.range_key][:type]
end
end
end
module Components
include Dynamoid::HashRangeAssociations
end
end
require 'dynamoid'
module Dynamoid
module Document
def serializable_hash(options = nil)
attribute_names = attributes.keys.map(&:to_s)
return serializable_attributes(attribute_names) if options.blank?
if only = options[:only]
attribute_names &= Array(only).map(&:to_s)
elsif except = options[:except]
attribute_names -= Array(except).map(&:to_s)
end
hash = serializable_attributes(attribute_names)
Array(options[:methods]).each { |m| hash[m.to_s] = send(m) }
serializable_add_includes(options) do |association, records, opts|
hash[association.to_s] = if records.respond_to?(:to_ary)
records.to_ary.map { |a| a.serializable_hash(opts) }
else
records.serializable_hash(opts)
end
end
hash
end
def serializable_add_includes(options = {}) #:nodoc:
return unless includes = options[:include]
unless includes.is_a?(Hash)
includes = Hash[Array(includes).flat_map { |n| n.is_a?(Hash) ? n.to_a : [[n, {}]] }]
end
includes.each do |association, opts|
records = send(association)
if Dynamoid::HashRangeAssociations::HasMany===records || Dynamoid::HashRangeAssociations::HasOne===records || Dynamoid::HashRangeAssociations::BelongsTo===records
records = records.target
end
if records
yield association, records, opts
end
end
end
end
end
# path: /user/{user_name}/{item_title}
def show(event:, context:)
user_name = event["pathParameters"]["user_name"]
item_title = event["pathParameters"]["item_title"]
begin
item = Item.find(title: item_title, user_range_key: user_name)
{
statusCode: 200,
body: {
item: item,
user: item&.user,
}.to_json
}
rescue Dynamoid::Errors::RecordNotFound => e
{
statusCode: 404,
body: {
type: e.class.name,
message: e.message,
}.to_json
}
end
end
# path: /user/{user_name}/{item_title}
def create(event:, context:)
user_name = event["pathParameters"]["user_name"]
item_title = event["pathParameters"]["item_title"]
begin
user = User.find("DUMMY_POOL_ID", range_key: user_name)
item = Item.new(pool_id: "DUMMY_POOL_ID", title: item_title)
item.user = user
item.save
{
statusCode: 200,
body: {
item: item,
user: user,
items: user.items,
}.to_json
}
rescue Dynamoid::Errors::RecordNotFound => e
{
statusCode: 404,
body: {
type: e.class.name,
message: e.message,
}.to_json
}
end
end
# path: /user/{user_name}/{item_title}
def delete(event:, context:)
user_name = event["pathParameters"]["user_name"]
item_title = event["pathParameters"]["item_title"]
begin
item = Item.where(title: item_title, user_name: user_name).first
raise Dynamoid::Errors::RecordNotFound unless item
item.delete
{
statusCode: 200,
body: {
item: item,
}.to_json
}
rescue Dynamoid::Errors::RecordNotFound => e
{
statusCode: 404,
body: {
type: e.class.name,
message: e.message,
}.to_json
}
end
end
# path: /users
def index(event:, context:)
{
statusCode: 200,
body: {
users: User.all,
items: Item.all,
}.to_json
}
end
# path: /users/{user_name}
def show(event:, context:)
user_name = event["pathParameters"]["user_name"]
begin
user = User.find("DUMMY_POOL_ID", range_key: user_name)
{
statusCode: 200,
body: {
user: user,
items: user&.items,
}.to_json
}
rescue Dynamoid::Errors::RecordNotFound => e
{
statusCode: 404,
body: {
type: e.class.name,
message: e.message,
}.to_json
}
end
end
# path: /users/{user_name}
def create(event:, context:)
user_name = event["pathParameters"]["user_name"]
user = User.new(pool_id: "DUMMY_POOL_ID", name: user_name)
user.save
{
statusCode: 200,
body: {
user: user,
items: user&.items,
}.to_json
}
end
# path: /users/{user_name}
def delete(event:, context:)
user_name = event["pathParameters"]["user_name"]
begin
user = User.find("DUMMY_POOL_ID", range_key: user_name)
user.items.each {|item|
item.delete
}
user.delete
{
statusCode: 200,
body: {
user: user,
items: user&.items,
}.to_json
}
rescue Dynamoid::Errors::RecordNotFound => e
{
statusCode: 404,
body: {
type: e.class.name,
message: e.message,
}.to_json
}
end
end
$ curl -X POST http://localhost:3000/local/users/tanaka | jq .
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 153 100 153 0 0 105 0 0:00:01 0:00:01 --:--:-- 105
{
"user": {
"pool_id": "DUMMY_POOL_ID",
"name": "tanaka",
"created_at": "2021-02-18T16:39:48.682+09:00",
"updated_at": "2021-02-18T16:39:48.682+09:00"
},
"items": []
}
$ curl -X POST http://localhost:3000/local/users/satou | jq .
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 152 100 152 0 0 114 0 0:00:01 0:00:01 --:--:-- 114
{
"user": {
"pool_id": "DUMMY_POOL_ID",
"name": "satou",
"created_at": "2021-02-18T16:39:52.970+09:00",
"updated_at": "2021-02-18T16:39:52.970+09:00"
},
"items": []
}
$ curl -X POST http://localhost:3000/local/users/tanaka/apple | jq .
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 469 100 469 0 0 325 0 0:00:01 0:00:01 --:--:-- 325
{
"item": {
"pool_id": "DUMMY_POOL_ID",
"title": "apple",
"user_name": "tanaka",
"created_at": "2021-02-18T16:39:55.930+09:00",
"updated_at": "2021-02-18T16:39:55.930+09:00"
},
"user": {
"created_at": "2021-02-18T07:39:48.682+00:00",
"updated_at": "2021-02-18T07:39:48.682+00:00",
"pool_id": "DUMMY_POOL_ID",
"name": "tanaka"
},
"items": [
{
"created_at": "2021-02-18T07:39:55.930+00:00",
"updated_at": "2021-02-18T07:39:55.930+00:00",
"pool_id": "DUMMY_POOL_ID",
"title": "apple",
"user_name": "tanaka"
}
]
}
$ curl -X POST http://localhost:3000/local/users/tanaka/orange | jq .
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 626 100 626 0 0 450 0 0:00:01 0:00:01 --:--:-- 450
{
"item": {
"pool_id": "DUMMY_POOL_ID",
"title": "orange",
"user_name": "tanaka",
"created_at": "2021-02-18T16:39:58.798+09:00",
"updated_at": "2021-02-18T16:39:58.798+09:00"
},
"user": {
"created_at": "2021-02-18T07:39:48.682+00:00",
"updated_at": "2021-02-18T07:39:48.682+00:00",
"pool_id": "DUMMY_POOL_ID",
"name": "tanaka"
},
"items": [
{
"created_at": "2021-02-18T07:39:58.798+00:00",
"updated_at": "2021-02-18T07:39:58.798+00:00",
"pool_id": "DUMMY_POOL_ID",
"title": "orange",
"user_name": "tanaka"
},
{
"created_at": "2021-02-18T07:39:55.930+00:00",
"updated_at": "2021-02-18T07:39:55.930+00:00",
"pool_id": "DUMMY_POOL_ID",
"title": "apple",
"user_name": "tanaka"
}
]
}
$ curl -X POST http://localhost:3000/local/users/satou/banana | jq .
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 468 100 468 0 0 342 0 0:00:01 0:00:01 --:--:-- 342
{
"item": {
"pool_id": "DUMMY_POOL_ID",
"title": "banana",
"user_name": "satou",
"created_at": "2021-02-18T16:40:01.607+09:00",
"updated_at": "2021-02-18T16:40:01.607+09:00"
},
"user": {
"created_at": "2021-02-18T07:39:52.970+00:00",
"updated_at": "2021-02-18T07:39:52.970+00:00",
"pool_id": "DUMMY_POOL_ID",
"name": "satou"
},
"items": [
{
"created_at": "2021-02-18T07:40:01.607+00:00",
"updated_at": "2021-02-18T07:40:01.607+00:00",
"pool_id": "DUMMY_POOL_ID",
"title": "banana",
"user_name": "satou"
}
]
}
$ curl -X POST http://localhost:3000/local/users/satou/grape | jq .
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 621 100 621 0 0 430 0 0:00:01 0:00:01 --:--:-- 430
{
"item": {
"pool_id": "DUMMY_POOL_ID",
"title": "grape",
"user_name": "satou",
"created_at": "2021-02-18T16:40:04.612+09:00",
"updated_at": "2021-02-18T16:40:04.612+09:00"
},
"user": {
"created_at": "2021-02-18T07:39:52.970+00:00",
"updated_at": "2021-02-18T07:39:52.970+00:00",
"pool_id": "DUMMY_POOL_ID",
"name": "satou"
},
"items": [
{
"created_at": "2021-02-18T07:40:04.612+00:00",
"updated_at": "2021-02-18T07:40:04.612+00:00",
"pool_id": "DUMMY_POOL_ID",
"title": "grape",
"user_name": "satou"
},
{
"created_at": "2021-02-18T07:40:01.607+00:00",
"updated_at": "2021-02-18T07:40:01.607+00:00",
"pool_id": "DUMMY_POOL_ID",
"title": "banana",
"user_name": "satou"
}
]
}
$ curl -X GET http://localhost:3000/local/users | jq .
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 908 100 908 0 0 654 0 0:00:01 0:00:01 --:--:-- 654
{
"users": [
{
"created_at": "2021-02-18T07:39:52.970+00:00",
"updated_at": "2021-02-18T07:39:52.970+00:00",
"pool_id": "DUMMY_POOL_ID",
"name": "satou"
},
{
"created_at": "2021-02-18T07:39:48.682+00:00",
"updated_at": "2021-02-18T07:39:48.682+00:00",
"pool_id": "DUMMY_POOL_ID",
"name": "tanaka"
}
],
"items": [
{
"created_at": "2021-02-18T07:39:55.930+00:00",
"updated_at": "2021-02-18T07:39:55.930+00:00",
"pool_id": "DUMMY_POOL_ID",
"title": "apple",
"user_name": "tanaka"
},
{
"created_at": "2021-02-18T07:40:01.607+00:00",
"updated_at": "2021-02-18T07:40:01.607+00:00",
"pool_id": "DUMMY_POOL_ID",
"title": "banana",
"user_name": "satou"
},
{
"created_at": "2021-02-18T07:40:04.612+00:00",
"updated_at": "2021-02-18T07:40:04.612+00:00",
"pool_id": "DUMMY_POOL_ID",
"title": "grape",
"user_name": "satou"
},
{
"created_at": "2021-02-18T07:39:58.798+00:00",
"updated_at": "2021-02-18T07:39:58.798+00:00",
"pool_id": "DUMMY_POOL_ID",
"title": "orange",
"user_name": "tanaka"
}
]
}
$ curl -X GET http://localhost:3000/local/users/tanaka | jq .
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 463 100 463 0 0 336 0 0:00:01 0:00:01 --:--:-- 336
{
"user": {
"created_at": "2021-02-18T07:39:48.682+00:00",
"updated_at": "2021-02-18T07:39:48.682+00:00",
"pool_id": "DUMMY_POOL_ID",
"name": "tanaka"
},
"items": [
{
"created_at": "2021-02-18T07:39:58.798+00:00",
"updated_at": "2021-02-18T07:39:58.798+00:00",
"pool_id": "DUMMY_POOL_ID",
"title": "orange",
"user_name": "tanaka"
},
{
"created_at": "2021-02-18T07:39:55.930+00:00",
"updated_at": "2021-02-18T07:39:55.930+00:00",
"pool_id": "DUMMY_POOL_ID",
"title": "apple",
"user_name": "tanaka"
}
]
}
$ curl -X DELETE http://localhost:3000/local/users/tanaka/orange | jq .
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 164 100 164 0 0 119 0 0:00:01 0:00:01 --:--:-- 119
{
"item": {
"created_at": "2021-02-18T07:39:58.798+00:00",
"updated_at": "2021-02-18T07:39:58.798+00:00",
"pool_id": "DUMMY_POOL_ID",
"title": "orange",
"user_name": "tanaka"
}
}
$ curl -X GET http://localhost:3000/local/users/tanaka | jq .
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 307 100 307 0 0 225 0 0:00:01 0:00:01 --:--:-- 225
{
"user": {
"created_at": "2021-02-18T07:39:48.682+00:00",
"updated_at": "2021-02-18T07:39:48.682+00:00",
"pool_id": "DUMMY_POOL_ID",
"name": "tanaka"
},
"items": [
{
"created_at": "2021-02-18T07:39:55.930+00:00",
"updated_at": "2021-02-18T07:39:55.930+00:00",
"pool_id": "DUMMY_POOL_ID",
"title": "apple",
"user_name": "tanaka"
}
]
}
$ curl -X DELETE http://localhost:3000/local/users/tanaka | jq .
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 307 100 307 0 0 231 0 0:00:01 0:00:01 --:--:-- 231
{
"user": {
"created_at": "2021-02-18T07:39:48.682+00:00",
"updated_at": "2021-02-18T07:39:48.682+00:00",
"pool_id": "DUMMY_POOL_ID",
"name": "tanaka"
},
"items": [
{
"created_at": "2021-02-18T07:39:55.930+00:00",
"updated_at": "2021-02-18T07:39:55.930+00:00",
"pool_id": "DUMMY_POOL_ID",
"title": "apple",
"user_name": "tanaka"
}
]
}
$ curl -X GET http://localhost:3000/local/users | jq .
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 463 100 463 0 0 351 0 0:00:01 0:00:01 --:--:-- 351
{
"users": [
{
"created_at": "2021-02-18T07:39:52.970+00:00",
"updated_at": "2021-02-18T07:39:52.970+00:00",
"pool_id": "DUMMY_POOL_ID",
"name": "satou"
}
],
"items": [
{
"created_at": "2021-02-18T07:40:01.607+00:00",
"updated_at": "2021-02-18T07:40:01.607+00:00",
"pool_id": "DUMMY_POOL_ID",
"title": "banana",
"user_name": "satou"
},
{
"created_at": "2021-02-18T07:40:04.612+00:00",
"updated_at": "2021-02-18T07:40:04.612+00:00",
"pool_id": "DUMMY_POOL_ID",
"title": "grape",
"user_name": "satou"
}
]
}
class Item
include Dynamoid::Document
table key: :pool_id
range :title, :string
hr_belongs_to :user, hash_foreign_key: :pool_id, range_foreign_key: :user_name
local_secondary_index range_key: :user_name, projected_attributes: :all
end
class User
include Dynamoid::Document
table key: :pool_id
range :name, :string
hr_has_many :items
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment