Turbolinks Deivse 登录相关

我们想要在使用devise登录的时候实现用在form提交时候使用remote: true来提交表单。表单提交通过之后使用Turbolinks跳转到其他页面。
如果有验证错误则在当前页面显示验证错误。

为什么要这样做? 因为在Turbolinks-ios中无法用html方法提交表单。提交表单之后的跳转也必须由turbolinks来完成。

首先准备好需要render的js模版。

1
2
3
4
5
6
7
8
9
10
11
12
13
<% if resource && resource.errors.empty? %>
<% flash[:success] = "登录成功"; %>
Turbolinks.visit('<%=@redirect_to %>',{ action: "replace" });
<% else %>
$('#headFlash').html(`
<div class="alert alert-warning alert-dismissible fade in" role="alert">
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
<span><%= flash[:alert] %></span>
</div>
`);
<% end %>

我们需要overwrite devise的sessionsController中的create方法。

1
2
3
4
5
6
7
8
9
class Users::SessionsController < Devise::SessionsController

def create
self.resource = warden.authenticate!(auth_options)
sign_in(resource_name, resource)
yield resource if block_given?
@redirect_to = after_sign_in_path_for(resource)
end
end

注意需要在router配置中标明我们进行了Devise::SessionsController的overwrite。

跑一下。登录成功,跳转。

故意输错密码,登录失败,但是直接server端返回了401,而并没有渲染create.js.erb模版。

这个是为什么?

重点在这里。warden.authenticate!(auth_options) warden的默认策略直接跳过下面的步骤直接返回给我们401了。我们需要告诉warden我们期待的是什么处理。

这里需要对oauth_options中的recall 进行overwrite。

注意到使用remote: true默认的request的format是:js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Users::SessionsController < Devise::SessionsController

respond_to :js
def create
self.resource = warden.authenticate!(auth_options)
sign_in(resource_name, resource)
yield resource if block_given?
@redirect_to = after_sign_in_path_for(resource)
end

def failure
render :create
end
protected
def auth_options
{ scope: resource_name, recall: "#{controller_path}#failure" }
end
end

故意输错一下试一试,特么为什么还是直接返回401而没有使用我们定义的failure方法?
去看一下devise initializer里面配置的说明。

1
2
3
4
5
6
7
8
9
# ==> Navigation configuration
# Lists the formats that should be treated as navigational. Formats like
# :html, should redirect to the sign in page when the user does not have
# access, but formats like :xml or :json, should return 401.
# If you have any extra navigational formats, like :iphone or :mobile, you
# should add them to the navigational formats lists.
#
# The "*/*" below is required to match Internet Explorer requests.
# config.navigational_formats = ['*/*', :html]

那么就是说只要config.navigational_formats = ['*/*', :html, :js]就ok了。

添加之后再试一下,还是不行。搜索一番。据说要添加

1
config.http_authenticatable_on_xhr = false

这特么是什么鬼。看一下devise default的failure app。

1
2
3
4
5
6
7
8
9
def respond
if http_auth?
http_auth
elsif warden_options[:recall]
recall
else
redirect
end
end

我们看到只有http_auth? 为false我们才会调用recall。

1
2
3
4
5
6
7
def http_auth?
if request.xhr?
Devise.http_authenticatable_on_xhr
else
!(request_format && is_navigational_format?)
end
end

可以看到我们只有设置Devise.http_authenticatable_on_xhr 为 false才使得验证失败时候的处理落到我们自己定义的recall中。

以上。

Turbolinks-ios中使用Devise进行登录

在Turbolinks-ios使用Devise的网页页面进行登录需要注意将表单变成ajax形式提交。

Rails部分

  • Rails的页面中,中使用ajax提交登录表单。

  • overwrite devise的session的create方法。结束时候渲染create.js.erb模版。

  • create.js.erb中使用Turbolinks.visit(after_url, {action: "replace"})方法转向登录后的页面。

Swift 部分

  • delegate页面中的turbolinks的链接时候对应的处理。
1
2
3
4
5
6
7
8
9
10
11
extension ViewController: SessionDelegate {
func session(_ session: Session, didProposeVisitToURL URL: URL, withAction action: Action) {
visit(URL: URL as NSURL, action: action)
}

func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, withError error: NSError) {
let alert = UIAlertController(title: "Error", message: error.localizedDescription, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
present(alert, animated: true, completion: nil)
}
}
  • visit函数中按传来的action进行navigationController stack的替换。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func visit(URL: NSURL, action: Action) {
let visitable = WebViewController()
visitable.visitableURL = URL as URL!
switch action {
case .Advance:
pushViewController(visitable, animated: true)
break
case .Replace:
popViewController(animated: false)
pushViewController(visitable, animated: false)
break
default:
pushViewController(visitable, animated: true)
break
}
session.visit(visitable)
}

要特别注意的是处理replace的时候我们需要把登录页面从navigationController的历史里替换掉,否则我们登录之后点击返回上一页会返回登录页面的快照。
另外注意animated: false,这样用户就看不到我们替换的过程。

Memo related with Promise

Promise is a kind of object that when executed, it can be either resolved or rejected.

1
promise.then([onFulfilled], [onRejected])

how to create a Promise

1
2
3
4
new Promise((resolve, reject) => {  


})

the argument to initialize a promise is a function with two parameters. resolve, and reject, both are functions.

how to turn a function with callback into a promise ?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// function fetchURL(url, callback);
// function parsePage(content, callback);
let fetch = new Promise((resolve, reject) => {
fetchURL(url, (err,result)=>{
if(err){return reject(err);
} else {
return resolve(result);
}
})
})
let parse = new Promise((resolve, reject) => {
parsePage(content, (err,result)=>{
if(err){return reject(err);
} else {
return resolve(result);
}
})
})

then instead of calling

1
2
3
4
5
fetchURL("https://google.com", (err,content)=>{
parsePage(content, (err, result)=>{
//do something here
})
})

we can write something like

1
2
3
4
5
6
fetch("https://google.com").then(
(result)=> parse(result)
).then((result)=>{
//do something with the result;
}
)

then callback hell is turned into promise chain.

promisify

There exists a general way to turn callback-based function into one that returns a promise.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
module.exports.promisify = function(callbackBasedApi) {
return function promisified() {
const args = [].slice.call(arguments);
return new Promise((resolve, reject) => {
args.push((err, result) => {
if(err) {
return reject(err);
}
if(arguments.length <= 2) {
resolve(result);
} else {
resolve([].slice.call(arguments, 1));
}
});
callbackBasedApi.apply(null, args);
});
}
};

create a function called promisified, which return a promise.

in the promise initialize function, we push a callback function into the arguments into promisified and use the new arguments to call callbackBasedApi, in the callback we pushed into arguments, it use resolve and reject to fullfill a the promise.

Sequential Iteration

1
2
3
4
5
6
7
8
9
10
let tasks = [ /* ... */ ]
let promise = Promise.resolve();
tasks.forEach(task => {
promise = promise.then(() => {
return task();
});
});
promise.then(() => {
//All tasks completed
});

or instead of forEach, use reduce.

1
2
3
4
5
6
7
8
9
10
11
let tasks = [ /* ... */ ]
let promise = tasks.reduce((prev, task) => {
return prev.then(() => {
return task();
});
}, Promise.resolve());


promise.then(() => {
//All tasks completed
});

Both use a Promise.resove() to kick the first iteration.

Parallel execution

1
2
3
4
5
6
let promises = [ /* promises */ ]


Promise.all(promises).then(() => {
//All tasks completed
});

网文比纸上得来还浅

刚才解决了个困扰了很久的问题。从Ruby-china fork过来的forum的邮件总是无法发送。
ruby-china的邮件通过actionMailer的sidekiq adapter由sidekiq负责异步任务管理发送。
翻看后台的记录根本找不到任何error log,并且也有显示任务成功完成。那我的邮件被狗吃了么?

如果最近没人抱怨重置密码邮件无法发送我可能最近都会懒着不去找问题到底在哪里了。
谷歌了以下把原来的

1
bundle exec sidekiq -C config/sidekiq.yml

换成了

1
RAILS_ENV=production bundle exec sidekiq -C config/sidekiq.yml

尝试以下发送邮件,很顺利成功了。

总结来说,文档上没写,很多技术博客上也没带上production部署时候应该怎么搞,也只是摆个demo弄弄样子罢了。
更深一步,我们平时鼓捣很多东西,弄出个demo就沾沾自喜以为自己又学到了新的姿势,其实在实践面前也是不堪一击的。

以上。

Ghouse 2.0发布以及 wicked 这个gem 的一些坑

2月很快就过去了。过去的一周腰疼发作,起坐困难,挺着老腰站着工作了一周,这几天疼痛终于是渐渐减少了。
花粉症也是如期而至。眼角痒啊,鼻涕流啊,打喷嚏还怕闪着腰。

Ghouse 2.0 发布

前前后后做了一年的外包项目ghouse的第二版也终于发布了。这个项目后端包括管理后台和API基本由我独立完成,前端的Swift也参与改了不少。

要说过去的一年知道些什么know how,做过什么有意义的项目的话,大概都这个上面了吧。

gem wicked 的一些坑

用户通过简单邮箱密码注册后,收到确认注册邮件,用户电击邮件中的url,验证注册完毕,同时跳转到完善用户信息的页面。
用户信息分成两个部分,一个是基本信息,一个是学历信息。要用restful的方法实现该怎么写呢?
Rails 4 Application Development HOTSHOT
这本书推荐了一个叫wicked的gem。

还是从用户电击确认邮件的链接开始。我们overwrite Devise::ConfirmationsControllershowafter_confirmation_path_for两个方法。
show对应的的url是用户在邮件中电击的url。但是我们可以看到,用户点击这个url之后只能确认注册,而并不能直接登录。我们后面的操作需要用户已经登录,所以得先让用户登录。如注释,我们可以直接用sign_in方法将user_id 写到session中,这样用户就登录了。然后跳转到after_confirmation_path_for定义的path。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Users::ConfirmationsController < Devise::ConfirmationsController
def show
self.resource = resource_class.confirm_by_token(params[:confirmation_token])
yield resource if block_given?

if resource.errors.empty?
set_flash_message!(:notice, :confirmed)
sign_in(resource) # 跳转之前登录
respond_with_navigational(resource){ redirect_to after_confirmation_path_for(resource_name, resource) }
else
respond_with_navigational(resource.errors, status: :unprocessable_entity){ render :new }
end
end
private
def after_confirmation_path_for(resource_name, resource)
userinfos_path(id: :basic_info)
end
end

我们创建一个controller来实现userinfos_path(id: :basic_info)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class UserinfosController < ApplicationController
before_action :authenticate_user!
include Wicked::Wizard
steps :basic_info, :education
def show
@user = current_user
render_wizard
end
def update
@user = current_user
@user.update_attributes(user_params)
render_wizard @user
end
private
def finish_wizard_path
root_path
end
protected
def user_params
params.require(:user).permit(:user, :family_name, :family_name_kana,
:given_name, :given_name_kana, :gender, :birthday,
:major_category, :grade, :school_id, :education_level,
:department,:major, :grade_year, :education_category,
:japanese_level,:wechat,:phone,:job_category_id)
end

这个path会由UserinfosController#show来处理。我们定义的step分别是 :basic_info,:education

注意文档中说 wicked会默认将http parameter的id作为step的名称读取。所以这里我们明确使用了userinfos_path(id: :basic_info)。文档中类似userinfos_path(:basic_info)的用法会导致所有的step被跳过(生成的url中:baisc_info不是作为id)。所以文档是错误的。

我们创建两个template来按步骤对user进行更新。

1
2
3
4
5
6
7
8
9
# app/views/userinfos/basic_info.html.erb
<%= simple_form_for @user, url: wizard_path, method: :put do |f| %>
<%# my forms>
<% end %>

# app/views/userinfos/education.html.erb
<%= simple_form_for @user, url: wizard_path, method: :put do |f| %>
<%# my forms>
<% end %>

按照文档的说法我们需要让让form的helper知道我们用的是put方法提交表单,否则rails会找不到默认的post对应的update的方法。
这里比较坑的是,就算我们按照文档这么写,Rails还是会试图去寻找update方法,通过http parameters我们看到我们的action是update,虽然有个paramter_method=>'put',但是Rails依然告诉我们找不到update方法。 解决这个问题其实窑门显示得告诉路由我们的put对应的action是show(这样反而不restful),窑门还是老老实实就用post,然后去实现update方法。如上面的代码。经过纠结还是决定实现update方法。

update中调用render_wizard @user会试图保存当前user,如果保存成功则render下一个step,若保存失败则还是render本step。
这样以来代码就变得比价清晰了。

总的来说,wicked这个gem提出的思想和要解决的问题还是比较有意义的,但是这个文档写得实在是烂透了,只能亲自掉进坑里再想办法爬出来。

使用Devise的情况下在Doorkeeper验证跳转

当我们发起oauth2 request,从application跳转到第三方页面,然后填写用户名密码,登录,授权。
如果我们已经登录,那自动跳转回application。很多时候我们会搞混第三方页面登录后跳转和跳转回application这两件事情。

application这个跳转是Doorkeeper帮我们搞定的,其实这个过程可以看成是我们普通浏览一个url,这个url需要登录后才能浏览。
而处理这个url的Doorkeeper能够帮我们跳转回application。

Devise在我们使用用户名密码登录之后,一般我们会有照网站用户登录的行为跳转回主页的设定,但是,这样对于application就麻烦了。
我们需要的是在Devise验证登录之后,跳转到我们application发起的request。(注意这里不是跳转回application,我们还需要通过Doorkeeper进一步处理,由Doorkeeper跳转回Application)

这样逻辑就清晰很多了,做法就是把最初request的url写入session之后重载Devise的registrations和sessions等controller提供的方法。

1
2
3
4
5
6
# config/initializers/doorkeeper.rb
resource_owner_authenticator do
session[:return_to] = request.fullpath
current_user || redirect_to(new_user_registration_path)
#warden.authenticate!(scope: :user)
end
1
2
3
4
5
6
7
8
9
### app/controllers/users/registrations_controller.rb
def after_sign_up_path_for(resource)
session[:return_to] ? session[:return_to] : super(resource)
end

#app/controllers/users/sessions_controller.rb
def after_sign_in_path_for(resource)
session[:return_to] ? session[:return_to] : super(resource)
end

Elixir的Library与OTP Application的关系

所有的library必须是独立的OTP application.

1
2
3
4
5
6
7
8
9
10
11
12
defp deps do
[{:phoenix, "~> 1.2.1"},
{:phoenix_pubsub, "~> 1.0"},
{:phoenix_ecto, "~> 3.0"},
{:postgrex, ">= 0.0.0"},
{:phoenix_html, "~> 2.6"},
{:phoenix_live_reload, "~> 1.0", only: :dev},
{:gettext, "~> 0.11"},
{:comeonin, "~> 2.5"},
{:guardian, "~> 0.14"},
{:cowboy, "~> 1.0"}]
end

application的依赖写在dpes中,如果该依赖只存在编译的时候,那往往就足够了,如果运行时需要此依赖,则必须在 application 函数中列出。

1
2
3
4
5
def application do
[mod: {Til, []},
applications: [:phoenix, :phoenix_pubsub, :phoenix_html, :cowboy, :logger, :gettext,
:phoenix_ecto, :postgrex, :comeonin]]
end

application 函数中, mod 是一个callback。这个callback需要我们在指定的module中给出实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
defmodule Til do
use Application

# See https://elixir-lang.org/docs/stable/elixir/Application.html
# for more information on OTP Applications
def start(_type, _args) do
import Supervisor.Spec

# Define workers and child supervisors to be supervised
children = [
# Start the Ecto repository
supervisor(Til.Repo, []),
# Start the endpoint when the application starts
supervisor(Til.Endpoint, []),
# Start your own worker by calling: Til.Worker.start_link(arg1, arg2, arg3)
# worker(Til.Worker, [arg1, arg2, arg3]),
]

# See https://elixir-lang.org/docs/stable/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: Til.Supervisor]
Supervisor.start_link(children, opts)
end

# Tell Phoenix to update the endpoint configuration
# whenever the application is updated.
def config_change(changed, _new, removed) do
Til.Endpoint.config_change(changed, removed)
:ok
end
end

这里主要就是建立本应用的supervisor tree。

textarea 的open tag和close tag不能分成两行写

以前习惯了用template engine的各种helper写html tag,今天在写Electron的view的时候发现了个很蠢的问题,placeholder 怎么死活不能在textarea里面显示出来。

1
2
<textarea placeholder="something">
</textarea>

虽然很蠢还是保持谦虚的态度搜了一下,发现因为textarea里面是可以显示多行的,如果把close tag写到第二行,那相当于在textarea里面放了一个换行,自然placehodler是无法显示了。正确的做法是close tag和open tag要放到一行里面。

1
<textarea placeholder="something"></textarea>

然后补完了韩剧《도깨비》,虽然感觉逻辑很混乱时代安排也不是那么的好,但是还是会让人感叹命运真棒,爱的死去活来真好。

 

ELK docker-compose 全家桶实现Nginx access log 可视化

ELK docker-compose 全家桶实现Nginx access log可视化

Elasticsearch Logstash 和 Kibana 组成的log可视化工具链基本已经成为低成本服务的标配。
但是这三个东西繁杂的配置有时候还是会让人奔溃。

尝试寻找可用docker container来解决繁杂的配置问题是一种常用的偷懒思路。
今天从docker-elk这个全家桶开始尝试简单配置即用的log可视化构成。

clone下来之后看说明,其实已经差不多可用了。
修改一下logstash的config。使其能够对应nginx的log格式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
input {
tcp {
port => 5000
}
}

## Add your filters / logstash plugins configuration here
filter {
grok {
match => [ "message" , "%{COMBINEDAPACHELOG}+%{GREEDYDATA:extra_fields}"]
overwrite => [ "message" ]
}

mutate {
convert => ["response", "integer"]
convert => ["bytes", "integer"]
convert => ["responsetime", "float"]
}

geoip {
source => "clientip"
target => "geoip"
add_tag => [ "nginx-geoip" ]
}

date {
match => [ "timestamp" , "dd/MMM/YYYY:HH:mm:ss Z" ]
remove_field => [ "timestamp" ]
}

useragent {
source => "agent"
}
}

output {
elasticsearch {
hosts => "elasticsearch:9200"
}
}

好了已经差不多能跑了试一下。

1
2
3
$ sudo sysctl -w vm.max_map_count=262144
$ docker-compose up -d
nc localhost 5000 < /path/to/logfile.log

果然打开localhost:5601就可以用kibana看到各种统计数据和图表了。
但是默认的设置有个问题,就是无法实时对acess log 进行监控,我们每次都需要跑一下 nc localhost 5000 < /path/to/logfile.log 把log 喂给logstash。这样是很蛋疼的。我们希望logstash能够实时watch log文件然后随时更新数据。

思路很简单,我们可以把/var/log/nginx/access.log symlink到当前目录,然后把目录挂在到docker container中。

1
2
3
$ cd docker-elk
$ mkdir access_log
$ sudo ln -s /var/log/nginx/access.log ./access_log/access.log

修改logstash的config, 将input block 改成 file block。

1
2
3
4
5
6
7
input {
file {
path => "/access_log/access.log"
start_position => "beginning"
}
}
#以下省略

修改docker-compose.yml 将 ./access_log 挂到 /access_log

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#以上省略
logstash:
build: logstash/
command: -f /etc/logstash/conf.d/
volumes:
- ./logstash/config:/etc/logstash/conf.d
- ./access_log:/access_log
ports:
- "5000:5000"
networks:
- docker_elk
depends_on:
- elasticsearch

#以下省略

重新build一下开跑。

1
$docker-compose stop && docker-compose up -d

再此打开localhost:5601,发现特么什么狗屁都没有了。这个是为什么?
翻看docker-compose启动时候的console log,发现logstash说自己没有访问/access_log/access.log的权限。

我们执行docker exec -it dockerelk_logstash_1 /bin/bash 进入到container内部一探究竟。

1
2
3
4
5
6
7
8
root@7c7edc22ff14:/# ps aux |grep logstash
logstash 1 10.2 24.7 4197892 507684 ? Ssl 13:36 2:50 /usr/lib/jvm/java-8-openjdk-amd64/jre/bin/java -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction=75 -XX:+UseCMSInitiatingOccupancyOnly -XX:+DisableExplicitGC -Djava.awt.headless=true -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -Xmx1g -Xms256m -Xss2048k -Djffi.boot.library.path=/usr/share/logstash/vendor/jruby/lib/jni -Xbootclasspath/a:/usr/share/logstash/vendor/jruby/lib/jruby.jar -classpath : -Djruby.home=/usr/share/logstash/vendor/jruby -Djruby.lib=/usr/share/logstash/vendor/jruby/lib -Djruby.script=jruby -Djruby.shell=/bin/sh org.jruby.Main /usr/share/logstash/lib/bootstrap/environment.rb logstash/runner.rb -f /etc/logstash/conf.d/
root@7c7edc22ff14:/# cd /access_log
root@7c7edc22ff14:/access_log# ls -la
total 24
drwxrwxr-x 2 1000 1000 4096 Feb 13 13:15 .
drwxr-xr-x 66 root root 4096 Feb 13 13:36 ..
-rw-rw---- 2 www-data adm 15198 Feb 13 13:37 access.log

跑logstash的是用户logstash,而logstash用户没有access.log的访问权限。
在docker container中挂在的目录,其用户权限是和host相同的。所以我们必须在host中增加logstash用户的权限。
退出docker container,在host中我们尝试个logstash用户添加access.log的访问权限。

1
2
$ sudo setfacl -m u:logstash:r access.log
setfacl: Option -m: Invalid argument near character 6

host系统中并没有logstash用户,所以map不到相关的uid。那就去container中找一下logstash的uid就是了。

1
2
3
4
5
root@7c7edc22ff14:/# cat /etc/passwd

...
...
logstash:x:999:999:LogStash Service User:/usr/share/logstash:/usr/sbin/nologin

container中logstash uid是999,我们在host中直接用uid设置权限。

1
$sudo setfacl -m u:999:r access.log

重新build一下,然后docker-compose up, 再打开kibana,这次终于看到了期待的结果。

Polymorphic has_and_belongs_to_many relationship in ActiveRecord

While I was checking this awesome Rails plugin, I was wondering how it can use only one model for different has_and_belongs_to_many relations.

Check readme on github to see what it can do and let’s start to see how it can.

Here is the only migration file

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class CreateActions < ActiveRecord::Migration[5.0]
def change
create_table :actions do |t|
t.string :action_type, null: false
t.string :action_option
t.string :target_type
t.integer :target_id
t.string :user_type
t.integer :user_id

t.timestamps
end

add_index :actions, [:user_type, :user_id, :action_type]
add_index :actions, [:target_type, :target_id, :action_type]
end
end

and its model

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Auto generate with action-store gem.
class Action < ActiveRecord::Base
include ActionStore::Model
end

module ActionStore
module Model
extend ActiveSupport::Concern

included do
# puts "Initialize ActionStore::Model"
belongs_to :target, polymorphic: true
belongs_to :user, polymorphic: true
end
end
end

we need to define the belongs_to relationship using polymorphic: true

to see what has many looks like

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# in action-store/lib/action_store/mixin.rb

user_klass.send :has_many, has_many_name, has_many_scope, class_name: 'Action'

user_klass.send :has_many, has_many_through_name,
through: has_many_name,
source: :target,
source_type: target_klass.name


target_klass.send :has_many, has_many_name_for_target, has_many_scope,
foreign_key: :target_id,
class_name: 'Action'
target_klass.send :has_many, has_many_through_name_for_target,
through: has_many_name_for_target,
source_type: user_klass.name,

its ok to ignore those metaprogramming tricks, so to make it more readable
it’s something like

1
2
3
4
5
6
7
8
9
class User < ActiveRecord::Base
has_many :like_topic_actions, -> {where(action_type: "like", target_type: "Topic", user_type: "User")}, class_name: "Action"
has_many :like_topics, through: "like_topic_actions", source: :target, source_type: "Topic"
end

class Topic < ActiveRecord::Base
has_many :like_by_user_actions, -> {where(action_type: "like", target_type: "Topic", user_type: "User")}, class_name: "Action"
has_many :like_by_users, through: "like_by_user_actions", source: :user, source_type: "User"
end

When we call user.like_topics, ActiveRecord knows we first find the relation through like_topic_actions whose class indeed is Action, and when we get a collection of like_topic_actions, we know we are going to find a collection of the target, so we will find the collection by looking for the target_id in the collections, and the source_type tell us the target_id is the id of Topic
so we should query Topic table for the results.

Since we can define different relation type, it’s easy for us to define different relations between those models.

Here provide a better example.
https://stackoverflow.com/questions/9500922/need-help-to-understand-source-type-option-of-has-one-has-many-through-of-rails