tamuです。

Heroku ConnectでSalesforceのレコードを参照できるようになったので、 Ruby on Railsからその情報を扱ってみたいと思います。

例によって開発はローカル環境で行いたいので、環境構築のお話をしていきます。

環境について

Heroku Connectを有効にしたHerokuはこのような環境になります。 (Ruby on Railsでいうproduction環境)

Heroku側のスキーマ

xxxxx データベース(herokuが自動で命名する)の中に public スキーマと、 Salesforce側のデータをいじくることができる salesforce スキーマがあります。

これと同じような環境をローカルでも構築します。

また、salesforce スキーマで定義するテーブルの情報(SQL)は、ローカルで開発するHeroku Connect を参考に取得します。

方針

なるべくRuby on Rails標準の手順で環境構築ができるようにしたいです。

1
2
3
4
$ docker compose up
$ bundle exec rake db:craete
$ bundle exec rake db:migrate
$ bundle exec rails s

ローカルだとこんな感じで。 本番環境へのデプロイも docker compose up が無いだけでほぼ同じ手順でやりたいと考えています。

「ここで psql から以下のSQLを流します」という手順をあまり組み込みたくないな〜、と。

Heroku側のスキーマ

これと同じ状況にするには以下の準備が必要です。

  • salesforceスキーマの作成
  • salesforceスキーマ上のテーブルの作成
  • search_pathの追加

salesforceスキーマの作成

いろいろと考えましたが、migrationでやるのが良いと判断しました。

まず、migrationファイルを作成します。

1
$ bundle exec rails g migration CreateSalesforceschema

作成されたmigarationファイルを修正します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
class CreateSalesforceschema < ActiveRecord::Migration[6.1]
  def up
    if Rails.env.production?
      return
    end
    ActiveRecord::Base.connection.execute <<~SQL
      CREATE SCHEMA salesforce;
    SQL
  end

  def down
    if Rails.env.production?
      return
    end
    ActiveRecord::Base.connection.execute <<~SQL
      DROP SCHEMA salesforce CASCADE;
    SQL
  end
end

本番環境だとすでに Heroku Connect によって salesforce スキーマはつくられているので、 本番環境で動かないように Rails.env.production? で判断してます。

直でSQLを実行するのはあまりRuby on Railsっぽくないのですがしょうがないです。

余談) salesforceスキーマの準備方法について

本番環境では実行しない、開発環境とテスト環境だけでスキーマを作りたいという要求だったのでmigrationでやりました。 他のやり方としては以下のやり方があります。

  • docker で起動時にスキーマを準備する
  • タスクでスキーマを作成する
  • 環境構築準備に手オペを入れる

docker で起動時にスキーマを準備する

docker は起動時にスクリプトを実行できたりSQLを走らせたりすることができます。 その仕組みを使ってsalesforceスキーマを準備することはできないでしょうか?

  • salesforceスキーマの作成はできる
  • salesforceスキーマの中のテーブル作成は難しい

という結果になりました。

/docker-entrypoint-initdb.d 配下のファイルを実行してくれますが、それは初回のコンテナ起動時のみとなります。 2回目以降の起動では実行してくれません。

そのため、salesforceスキーマ側のテーブルが増えたときに、この方式では対応することができません。

タスクでスキーマを作成する

bundle exec rails g task CreateSalesforceSchema のようにしてタスクを作り、そのタスクの中でスキーマやスキーマ内のテーブルを作るのはどうでしょうか?

  • salesforceスキーマの作成はできる
  • salesforceスキーマの中のテーブル作成もできる
  • saelsforceスキーマの中のテーブルが追加されたことに気づきにくい

Rakeタスクはmigrationとは違い、実行しなくてもRailsを起動することができます。 そのため実際にテーブルを参照するときまでタスクを実行しないといけないことに気づきにくいという問題があります。

個人や数人で開発しているときは「タスク追加したので実行おねがいしま〜す」と言って回れば良いので大丈夫そうですが、 大人数で開発していると思わぬところで落ちる可能性があります。

またRakeタスクは実行順序の規定がないため、なにか順序が必要なDDLがあった際に困ることになりそうです。 (タスクを追加するたびにREADME.mdにその手順を追加する必要がありそう)

環境構築手順に手オペを入れる

なしで。

Rakeタスクよりもオペレーションが煩雑になりそうですね。

salesforceスキーマ内のテーブル作成

こちらもsalesforceスキーマの作成と同様にmigrationで行います。

1
$ bundle exec rails g migration CreateSalesforceProduct2

ファイルを修正します。

 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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
class CreateSalesforceProduct2 < ActiveRecord::Migration[6.1]
  def up
    if Rails.env.production?
      return
    end
    ActiveRecord::Base.connection.execute <<~SQL
      CREATE TABLE salesforce.product2 (
          externalproductid__c character varying(255),
          family character varying(255),
          externalid character varying(255),
          lastvieweddate timestamp without time zone,
          stockkeepingunit character varying(180),
          name character varying(255),
          externaldatasourceid character varying(18),
          displayurl character varying(1000),
          lastmodifieddate timestamp without time zone,
          isdeleted boolean,
          isactive boolean,
          systemmodstamp timestamp without time zone,
          lastmodifiedbyid character varying(18),
          createddate timestamp without time zone,
          quantityunitofmeasure character varying(255),
          createdbyid character varying(18),
          productcode character varying(255),
          description character varying(4000),
          lastreferenceddate timestamp without time zone,
          sfid character varying(18) COLLATE pg_catalog.ucs_basic,
          id integer NOT NULL,
          _hc_lastop character varying(32),
          _hc_err text
      );
      CREATE SEQUENCE salesforce.product2_id_seq
          AS integer
          START WITH 1
          INCREMENT BY 1
          NO MINVALUE
          NO MAXVALUE
          CACHE 1;

      ALTER SEQUENCE salesforce.product2_id_seq OWNED BY salesforce.product2.id;
      ALTER TABLE ONLY salesforce.product2 ALTER COLUMN id SET DEFAULT nextval('salesforce.product2_id_seq'::regclass);
      ALTER TABLE ONLY salesforce.product2
          ADD CONSTRAINT product2_pkey PRIMARY KEY (id);
      CREATE INDEX hc_idx_product2_lastmodifieddate ON salesforce.product2 USING btree (lastmodifieddate);
      CREATE INDEX hc_idx_product2_systemmodstamp ON salesforce.product2 USING btree (systemmodstamp);
      CREATE UNIQUE INDEX hcu_idx_product2_externalproductid__c ON salesforce.product2 USING btree (externalproductid__c);
      CREATE UNIQUE INDEX hcu_idx_product2_sfid ON salesforce.product2 USING btree (sfid);
    SQL
  end

  def down
    if Rails.env.production?
      return
    end
    ActiveRecord::Base.connection.execute <<~SQL
      DROP TABLE salesforce.product2;
    SQL
  end
end

salesforceスキーマのときと同様に、production環境では実行しないように制御しています。 index等はDROP TABLEすれば一緒に消えてくれるので、downDROP TABLEのみやっています。

search_pathの追加

config/database.ymlschema_search_path で設定します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
development: &development
  adapter: postgresql
  encoding: unicode
  database: <%= ENV['DB_NAME'] || 'heroku_rails_sample_development' %>
  username: <%= ENV['DB_USERNAME'] || 'root' %>
  password: <%= ENV['DB_PASSWORD'] || 'password' %>
  host: <%= ENV['DB_HOST'] || '127.0.0.1' %>
  port: <%= ENV['DB_PORT'] || '5432' %>
  schema_search_path: "public,salesforce"

...

public をつけないと私の環境ではmigrationができなかったのでつけています。

実際の動作

dockerを起動し、migartionを実行し、rails consoleから値の保存と参照までやってみます。

dockerの起動

docker-compose.yml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
version: "3"
services:
  dbserver:
    image: postgres:12
    container_name: herokuconndb
    ports:
      - 5432:5432
    environment:
      POSTGRES_USER: root
      POSTGRES_PASSWORD: password
      POSTGRES_INITDB_ARGS: "--encoding=UTF-8"
    volumes:
      - database:/var/lib/postgresql/data
volumes:
  database:
    driver: local
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
$ docker compose up
[+] Running 2/2
 ⠿ Volume "heroku-rails-sample_database"  Created                                                                                                                                                                                   0.0s
 ⠿ Container herokuconndb                 Started                                                                                                                                                                                   0.4s
Attaching to herokuconndb
herokuconndb  | The files belonging to this database system will be owned by user "postgres".
herokuconndb  | This user must also own the server process.
herokuconndb  |
herokuconndb  | The database cluster will be initialized with locale "en_US.utf8".
herokuconndb  | The default text search configuration will be set to "english".
herokuconndb  |
...
...
herokuconndb  | 2021-08-07 12:33:50.050 UTC [1] LOG:  starting PostgreSQL 12.8 (Debian 12.8-1.pgdg100+1) on x86_64-pc-linux-gnu, compiled by gcc (Debian 8.3.0-6) 8.3.0, 64-bit
herokuconndb  | 2021-08-07 12:33:50.051 UTC [1] LOG:  listening on IPv4 address "0.0.0.0", port 5432
herokuconndb  | 2021-08-07 12:33:50.051 UTC [1] LOG:  listening on IPv6 address "::", port 5432
herokuconndb  | 2021-08-07 12:33:50.053 UTC [1] LOG:  listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432"
herokuconndb  | 2021-08-07 12:33:50.064 UTC [77] LOG:  database system was shut down at 2021-08-07 12:33:49 UTC
herokuconndb  | 2021-08-07 12:33:50.068 UTC [1] LOG:  database system is ready to accept connections

rails データベース作成

config/database.yml

 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
development: &development
  adapter: postgresql
  encoding: unicode
  database: <%= ENV['DB_NAME'] || 'heroku_rails_sample_development' %>
  username: <%= ENV['DB_USERNAME'] || 'root' %>
  password: <%= ENV['DB_PASSWORD'] || 'password' %>
  host: <%= ENV['DB_HOST'] || '127.0.0.1' %>
  port: <%= ENV['DB_PORT'] || '5432' %>
  schema_search_path: "public,salesforce"

test: &test
  adapter: postgresql
  encoding: unicode
  database: <%= ENV['DB_NAME'] || 'heroku_rails_sample_test' %>
  username: <%= ENV['DB_USERNAME'] || 'root' %>
  password: <%= ENV['DB_PASSWORD'] || 'password' %>
  host: <%= ENV['DB_HOST'] || '127.0.0.1' %>
  port: <%= ENV['DB_PORT'] || '5432' %>
  schema_search_path: "public,salesforce"

production: &production
  adapter: postgresql
  encoding: unicode
  database: <%= ENV['DB_NAME'] %>
  username: <%= ENV['DB_USERNAME'] %>
  password: <%= ENV['DB_PASSWORD'] %>
  host: <%= ENV['DB_HOST'] %>
  port: <%= ENV['DB_PORT'] %>
  schema_search_path: "public,salesforce"
1
2
3
$ bundle exec rake db:create
Created database 'heroku_rails_sample_development'
Created database 'heroku_rails_sample_test'

rails データベースmigration

db/migrate/yyyymmddhhmmss_create_salesforce_schema.rb

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
class CreateSalesforceSchema < ActiveRecord::Migration[6.1]
  def up
    if Rails.env.production?
      return
    end
    ActiveRecord::Base.connection.execute <<~SQL
      CREATE SCHEMA salesforce;
    SQL
  end

  def down
    if Rails.env.production?
      return
    end
    ActiveRecord::Base.connection.execute <<~SQL
      DROP SCHEMA salesforce CASCADE;
    SQL
  end
end

db/migrate/yyyymmddhhmmss_create_salesforce_product2.rb

 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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
class CreateSalesforceProduct2 < ActiveRecord::Migration[6.1]
  def up
    if Rails.env.production?
      return
    end
    ActiveRecord::Base.connection.execute <<~SQL
      CREATE TABLE salesforce.product2 (
          externalproductid__c character varying(255),
          family character varying(255),
          externalid character varying(255),
          lastvieweddate timestamp without time zone,
          stockkeepingunit character varying(180),
          name character varying(255),
          externaldatasourceid character varying(18),
          displayurl character varying(1000),
          lastmodifieddate timestamp without time zone,
          isdeleted boolean,
          isactive boolean,
          systemmodstamp timestamp without time zone,
          lastmodifiedbyid character varying(18),
          createddate timestamp without time zone,
          quantityunitofmeasure character varying(255),
          createdbyid character varying(18),
          productcode character varying(255),
          description character varying(4000),
          lastreferenceddate timestamp without time zone,
          sfid character varying(18) COLLATE pg_catalog.ucs_basic,
          id integer NOT NULL,
          _hc_lastop character varying(32),
          _hc_err text
      );
      CREATE SEQUENCE salesforce.product2_id_seq
          AS integer
          START WITH 1
          INCREMENT BY 1
          NO MINVALUE
          NO MAXVALUE
          CACHE 1;

      ALTER SEQUENCE salesforce.product2_id_seq OWNED BY salesforce.product2.id;
      ALTER TABLE ONLY salesforce.product2 ALTER COLUMN id SET DEFAULT nextval('salesforce.product2_id_seq'::regclass);
      ALTER TABLE ONLY salesforce.product2
          ADD CONSTRAINT product2_pkey PRIMARY KEY (id);
      CREATE INDEX hc_idx_product2_lastmodifieddate ON salesforce.product2 USING btree (lastmodifieddate);
      CREATE INDEX hc_idx_product2_systemmodstamp ON salesforce.product2 USING btree (systemmodstamp);
      CREATE UNIQUE INDEX hcu_idx_product2_externalproductid__c ON salesforce.product2 USING btree (externalproductid__c);
      CREATE UNIQUE INDEX hcu_idx_product2_sfid ON salesforce.product2 USING btree (sfid);
    SQL
  end

  def down
    if Rails.env.production?
      return
    end
    ActiveRecord::Base.connection.execute <<~SQL
      DROP TABLE salesforce.product2;
    SQL

  end
end
1
2
3
4
5
6
$ bundle exec rake db:migrate
== 20210807151451 CreateSalesforceSchema: migrating ===========================
== 20210807151451 CreateSalesforceSchema: migrated (0.0033s) ==================

== 20210807234933 CreateSalesforceProduct2: migrating =========================
== 20210807234933 CreateSalesforceProduct2: migrated (0.0102s) ================

その他の設定

複数形の対応

Product2 というModelを使うと product2s というテーブルにアクセスしようとするので、 「product2 は複数形でも product2 として扱うよ」ということを教えてあげる必要があります。

config/initializers/inflections.rb

1
2
3
ActiveSupport::Inflector.inflections(:en) do |inflect|
  inflect.uncountable %w( product2 )
end

Modelの作成

Product2 というModelを用意します。

migrationファイルは不要なので --no-migration をつけます。

1
2
3
4
5
6
7
$ bundle exec rails g model Product2 --no-migration
Running via Spring preloader in process 16648
      invoke  active_record
      create    app/models/product2.rb
      invoke    test_unit
   identical      test/models/product2_test.rb
      create      test/fixtures/product2.yml

動作確認

salesforce.product2の参照

1
2
3
4
5
6
$ bundle exec rails console
Running via Spring preloader in process 16763
Loading development environment (Rails 6.1.4)
irb(main):001:0> Product2.all
  Product2 Load (1.2ms)  SELECT "product2".* FROM "product2" /* loading for inspect */ LIMIT $1  [["LIMIT", 11]]
=> #<ActiveRecord::Relation []>

ちゃんとアクセスできています。

余談ですが、 Inflector の設定を行わないと、ここで product2s というテーブルにアクセスしようとしてしまいます。

salesforce.product2への書き込み

1
2
3
4
5
6
irb(main):002:0> p = Product2.new(name: 'sample product')
irb(main):003:0> p.save
  TRANSACTION (1.0ms)  BEGIN
  Product2 Create (1.7ms)  INSERT INTO "product2" ("name") VALUES ($1) RETURNING "id"  [["name", "sample product"]]
  TRANSACTION (1.9ms)  COMMIT
=> true

いちど抜けてもう一度入り直し、Product2をすべて取得してみます。

1
2
3
4
5
6
$ bundle exec rails console
Running via Spring preloader in process 16908
Loading development environment (Rails 6.1.4)
irb(main):001:0> Product2.all
  Product2 Load (1.1ms)  SELECT "product2".* FROM "product2" /* loading for inspect */ LIMIT $1  [["LIMIT", 11]]
=> #<ActiveRecord::Relation [#<Product2 externalproductid__c: nil, family: nil, externalid: nil, lastvieweddate: nil, stockkeepingunit: nil, name: "sample product", externaldatasourceid: nil, displayurl: nil, lastmodifieddate: nil, isdeleted: nil, isactive: nil, systemmodstamp: nil, lastmodifiedbyid: nil, createddate: nil, quantityunitofmeasure: nil, createdbyid: nil, productcode: nil, description: nil, lastreferenceddate: nil, sfid: nil, id: 1, _hc_lastop: nil, _hc_err: nil>]>

無事に先程のデータが取得できました。

つづいて psql で入って、 salesforce.product2 に書き込まれているかを確認します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$ bundle exec rails dbconsole
Password for user root:
psql (13.3, server 12.8 (Debian 12.8-1.pgdg100+1))
Type "help" for help.

heroku_rails_sample_development=# select * from salesforce.product2;
 externalproductid__c | family | externalid | lastvieweddate | stockkeepingunit |      name      | externaldatasourceid | displayurl | lastmodifieddate | isdeleted | isactive | systemmodstamp | lastmodifiedbyid | createddate | quantityunitofmeasure | createdbyid | productcode | description | lastreferenceddate | sfid | id | _hc_lastop | _hc_err
----------------------+--------+------------+----------------+------------------+----------------+----------------------+------------+------------------+-----------+----------+----------------+------------------+-------------+-----------------------+-------------+-------------+-------------+--------------------+------+----+------------+---------
                      |        |            |                |                  | sample product |                      |            |                  |           |          |                |                  |             |                       |             |             |             |                    |      |  1 |            |
(1 row)

ちゃんと salesforce.product2 に書き込まれていました。

まとめ

Ruby on RailsでHeroku Connectの環境を作る場合

  • salesforceスキーマはmigrationで作る
  • salesforceスキーマのテーブルもmigarationで作る
  • search_pathの設定を忘れずに