Madogiwa Blog

主に技術系の学習メモに使っていきます。

Ruby on Rails: deviseが使用する項目とユーザー情報のモデルを分離して肥大化を抑制するMEMO

最近deviseをちょっと触ってて自分が特に何も考えずに使っていくとUserモデルがどんどん肥大化していってしまうなぁと思い、、、

deviseのために追加する項目(email, encrypted_password等)とプロフィール的な項目(nickname, birthday等)でモデルを分けると下記のようなメリットがあって良さそうかなと思ったので、

  • 認証とプロフィール的な部分が分離出来るのでUserモデルの肥大化が抑えられる
  • 認証システムをdeviseから変更する場合にもアプリケーションで使用するユーザーの情報への影響を防げる
  • 認証部分をユーザー情報のカラム定義変更等によるテーブルへのロックの影響から防げる

deviseのコードとかを読んだりしながらいい方法かは微妙ですが、実現方法等を色々考えたことをMEMOしておきます📝

実現したいこと

下記のようにUserにdeviseに必要な項目を持たせて、User::Profileにアプリケーションで必要な情報をもたせるような構成を目指していこうと思います。

class User < ApplicationRecord
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable,
         :confirmable, :lockable, :trackable, :timeoutable

  has_one :profile, class_name: 'User::Profile', dependent: :destroy inverse_of: :user
end

class User::Profile < ApplicationRecord
  belongs_to :user

  validates :nickname, presence: true
end

なぜdeviseを使っているとUserモデルにカラムを追加したくなってしまうのかの個人的な考え

一旦そもそもdeviseを使っているとなぜUserモデルにすべてを入れてしまいたくなってしまうのかをdeviseのコードを読んで考えてみました、下記がdeviseのユーザー作成時のコードを抜粋したものになっています。

class Devise::RegistrationsController < DeviseController
  def create
    build_resource(sign_up_params)

    resource.save
    yield resource if block_given?
    if resource.persisted?
      if resource.active_for_authentication?
        set_flash_message! :notice, :signed_up
        sign_up(resource_name, resource)
        respond_with resource, location: after_sign_up_path_for(resource)
      else
        set_flash_message! :notice, :"signed_up_but_#{resource.inactive_message}"
        expire_data_after_sign_in!
        respond_with resource, location: after_inactive_sign_up_path_for(resource)
      end
    else
      clean_up_passwords resource
      set_minimum_password_length
      respond_with resource
    end
  end

一見メソッド内にyield resource if block_given?があるのでForm必要な項目を追加 + strong_parameterを調整した上で下記のようにControllerのアクションをオーバーライドしてあげれば一応、Userの作成時にUser::Profileも作成出来そうなのですが、

yield resource if block_given?前にresource.saveが実行されているため、UserUser::Profileトランザクションが別になってしまうので、バリデーションが必要な項目等がUser::Profileに含まれているとUserは作成されるけどUser::Profileは作成されないといったことが発生する恐れがある気もしました😢

class Users::RegistrationsController < Devise::RegistrationsController
  def create
    super do |user|
      user.create_profile(nickname: sign_up_params[:nickname])
    end
  end
end

こんな感じでやるとトランザクションが同じになり、User::Profileの作成に失敗したときにUserの作成をロールバックできそうですが、トランザクションの範囲が広いのと、エラーハンドリング周り等は作り込みが必要になります。。。

class Users::RegistrationsController < Devise::RegistrationsController
  def create
    ActiveRecord::Base.transaction do
      super do |user|
         user.create_profile!(nickname: sign_up_params[:nickname])
      end
    end
  end
end

このような感じでdevise実装を活かしつつ、複数のモデルを扱うのは、deviseの既存実装を追わないとなかなか難しそうな気がしたのでUserにカラムを追加していくという判断がされやすいのかなと思いました🙇‍♂️

どうやるのがよさそうか個人的に考えた方法

nested_attributes_forを使う

nested_attributes_forは、結構ハマりどころが多いのですが今回のケースだと複数のモデルを同一トランザクションで扱いかつ割とスッキリ書けそうかなと・・・!(ちょっとdevise_parameter_sanitizerのオーバーライドの箇所だけあれですが💦)

ポイントはnested_attributes_forを使うことで既存実装のresouce.saveで関連モデルも作成出来るとこでしょうか。

class User < ApplicationRecord
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable,
         :confirmable, :lockable, :trackable, :timeoutable

  has_one :profile, class_name: 'User::Profile', dependent: :destroy, required: true, inverse_of: :user
  accepts_nested_attributes_for :profile
end

class User::Profile < ApplicationRecord
  belongs_to :user

  validates :nickname, presence: true
end
class Users::RegistrationsController < Devise::RegistrationsController
  before_action :configure_sign_up_params, only: [:create]
  before_action :configure_account_update_params, only: [:update]

  def new
    super do |user|
      user.build_profile # formにprofileの要素を表示するためにbuildしとく
    end
  end

  # If you have extra params to permit, append them to the sanitizer.
  def configure_sign_up_params
    devise_parameter_sanitizer.permit(:sign_up) do |user|
      user.permit(
        *Devise::ParameterSanitizer::DEFAULT_PERMITTED_ATTRIBUTES[:sign_up],
        :email, profile_attributes: [:nickname, :id]
      )
    end
  end

  # If you have extra params to permit, append them to the sanitizer.
  def configure_account_update_params
    devise_parameter_sanitizer.permit(:account_update) do |user|
      user.permit(
        *Devise::ParameterSanitizer::DEFAULT_PERMITTED_ATTRIBUTES[:account_update],
        :email, profile_attributes: [:nickname, :id]
      )
    end
  end
end

UserとUser::Profileの更新が同一トランザクションになってます👀

Started POST "/users" for 172.29.0.1 at 2020-08-30 07:34:45 +0000
Processing by Users::RegistrationsController#create as HTML
  Parameters: {"authenticity_token"=>"[FILTERED]", "user"=>{"profile_attributes"=>{"nickname"=>"testaaaa"}, "email"=>"test1@example.com", "password"=>"[FILTERED]", "password_confirmation"=>"[FILTERED]"}, "commit"=>"Sign up"}
   (0.7ms)  BEGIN
  User Exists? (1.4ms)  SELECT 1 AS one FROM "users" WHERE "users"."email" = $1 LIMIT $2  [["email", "test1@example.com"], ["LIMIT", 1]]
  User Create (4.2ms)  INSERT INTO "users" ("email", "encrypted_password", "confirmation_token", "confirmation_sent_at", "created_at", "updated_at") VALUES ($1, $2, $3, $4, $5, $6) RETURNING "id"  [["email", "test1@example.com"], ["encrypted_password", "$2a$12$2dlJsGlGxCauby1HwZ8VkOmlrVQHHrgdRsm8t2nPCmS6eMujijnyW"], ["confirmation_token", "gezndhZwPZayruyXoxsQ"], ["confirmation_sent_at", "2020-08-30 07:34:46.193200"], ["created_at", "2020-08-30 07:34:46.192922"], ["updated_at", "2020-08-30 07:34:46.192922"]]
  User::Profile Create (3.2ms)  INSERT INTO "user_profiles" ("user_id", "nickname", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id"  [["user_id", 118], ["nickname", "testaaaa"], ["created_at", "2020-08-30 07:34:46.202306"], ["updated_at", "2020-08-30 07:34:46.202306"]]
   (3.1ms)  COMMIT

またdeviseで扱うscopeをUserではなくてAccountにして、アプリケーションで管理する情報をUserに保存してあげてcurrent_userを下記のようにしてあげると、current_user.attributesとかをしてもemailとか認証用に保持している情報が出力されにくくなるので良いのかもしれない🤔

class ApplicationController < ActionController::Base
  before_action :authenticate_user!

  def current_user
    current_account.user
  end
end

deviseのregistrationとは別画面でアプリケーションに必要な情報を入力させる

deviseのユーザー作成ページではあくまで認証で使用するemailpasswordの入力だけにしておく(こうしておくとSNS認証とかページのレイアウトを制御出来ない場合にも対応できそう)とdeviseのcontollerをいじらなくて済みますし、ログイン出来るようになるまでに入力項目がたくさんあると、そもそもアカウントを作ってもらえなくなってしまう恐れもあるので入力項目が多い場合は分けたほうがいいのかなと思いました😓

下記のような感じのUser::Profileに値がなかったらプロフィール入力画面に遷移させるようなメソッドを定義しておいて、before_actionで遷移させるとかするといいのかも知れないですね👀

class ApplicationController < ActionController::Base
  before_action :authenticate_user!

  private

  def required_profile
    redirect_to new_profile_path if current_user.profile.nil?
  end
end

※deviseのcontrollerはデフォルトだとApplicationControllerを継承しているのでrequired_profileを実行する場所は注意

頑張って既存のメソッドをオーバーライドする

既存のメソッドを頑張ってオーバーライドするのはセキュリティパッチ等があった場合にも自分で対応しないと行けないので割と大変そうなので、なんとか最終手段にしときたいですね😓

おわりに

個人的には最初は既存の作成/更新ロジックに手を入れなくて済むので追加項目が非常に少ないならnested_attributes_forにしておいて、項目がある程度あるなら画面を分けて、要件的にかなり独自色があり厳しくなってきたら、deviseのコードを理解した上で、メソッドを適切にオーバーライドしつつFormObjectとかにうまいこと切り出してあげるのがいいのかなぁと思いました。

deviseは非常に便利な反面、deviseが想定していないような作りのものを実現しようとすると、割と慎重な判断が求められる部分でもありますし、どう実装するのが良いか適切に判断してくのが難しいですね💦

参考

medium.com

qiita.com

rubydoc.info

Vue.js: 親コンポーネントの文字列を置換して動的に子コンポーネントをレンダリングするMEMO

テキスト内のリンクを検出して自動的にリンク用のコンポーネントレンダリングするみたいなことをするときにどうするのが良いのかよくわかってなかったので調べて、ちょっと実装してみた内容をメモしておきます📝(もっといいいやり方があるかも知れないです💦)

v-htmlを使用していているのでXSSには十分に注意してください。

実装サンプルはComposition APIを使って記載しているので、そのあたりの内容は下記を参照してください。

madogiwa0124.hatenablog.com

ケース: テキスト内の特定の文字列をリンク用の子コンポーネントに変換する

今回は下記のようなpropsでテキストを渡してあげるとリンクnの形式の文字列が含まれていたら、<a href="/">リンクn</a>に変換してくれるようなコンポーネントを考えてみます。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>home</title>
  </head>
  <body>
    <div id="vue-root">
      <linked-text initialize-text="これがリンク1だよ。これもリンク2だよ"/>
    </div>
  </body>
</html>

リンク用のコンポーネントは下記のような感じになっています。

<template>
  <a href="/">{{ message }}</a>
</template>
<script lang="ts">
import Vue from "vue";
import VueCompositionApi, { defineComponent } from "@vue/composition-api";
Vue.use(VueCompositionApi);

type Props = { initializeMessage: string };

export default defineComponent({
  name: "Link",
  props: {
    initializeMessage: {
      type: String,
      default: "",
    },
  },
  setup(props: Props) {
    const message = props.initializeMessage;
    return {
      message,
    };
  },
});
</script>
<style></style>

これがリンク1だよ。これもリンク2だよを渡したときの表示イメージはこんな感じです🎨

f:id:madogiwa0124:20200825161038p:plain

対応方法MEMO

色々調べて方針としては下記のような感じで考えてみました。

  • テキストの表示部分はv-htmlを指定
  • テキスト内の特定の文字列(リンクn)を変換しやすいようにタグの文字列(<span class="link">リンクn</span>)に置換
  • マウント後、生成したタグを持つ要素を取得して子コンポーネントに置換

実際に実装してみたのが以下です。

<template>
  <div class="link-list">
    <p v-html="text" />
  </div>
</template>
<script lang="ts">
import Vue from "vue";
import VueCompositionApi, { defineComponent, onMounted } from "@vue/composition-api";
import Link from "@js/components/Link.vue";
Vue.use(VueCompositionApi);

const LINK_TAG_STR = ["<span class='link'>", "</span>"];
const LINK_TAG_SELECTOR = "span.link";
const LINK_STR_REGXP = /リンク./g;

function buildChildComponent(props: object) {
  // Vue.extendで対象コンポーネントのコンストラクタを取得
  // https://012-jp.vuejs.org/guide/components.html
  const LinkComponent = Vue.extend(Link);
  // 子要素のinstanceを生成して返す
  return new LinkComponent({ propsData: props });
}

function sanitize(text: string) {
  // 本来ならXSSが発生しないように適切にsanitizeする必要があるが割愛
  return text;
}

function replaceLinkTag(text: string) {
  // 特定の文字列をタグの文字列に変換
  return text.replace(LINK_STR_REGXP, `${LINK_TAG_STR[0]}$&${LINK_TAG_STR[1]}`);
}

export default defineComponent({
  name: "LinkList",
  components: { Link },
  props: {
    initializeText: {
      type: String,
      required: true,
    },
  },
  setup(props: { initializeText: string }) {
    // サニタイズしつつ置換対象の特定の文字列をタグに変換
    const text = replaceLinkTag(sanitize(props.initializeText));
    onMounted(() => {
      document.querySelectorAll(LINK_TAG_SELECTOR).forEach((link) => {
        // Componentからインスタンスを生成
        const instance = buildChildComponent({ initializeMessage: link.textContent });
        instance.$mount(); // インスタンスをマウント
        // 対象のnodeをLinkコンポーネントで置換
        if (link.parentNode) link.parentNode.replaceChild(instance.$el, link);
      });
    });
    return {
      text,
    };
  },
});
</script>
<style></style>

ポイントは、onMountedの中でVue.extend(CompnentClass)で対象のコンポーネント用のコンストラクタを取得してComponentのインスタンスを生成してあげて、それをinstance.$mount()後にinstance.$elで要素として取得し生成したタグと置換してあげているところかなと思います💦

function buildChildComponent(props: object) {
  // Vue.extendで対象コンポーネントのコンストラクタを取得
  // https://012-jp.vuejs.org/guide/components.html
  const LinkComponent = Vue.extend(Link);
  // 子要素のinstanceを生成して返す
  return new LinkComponent({ propsData: props });
}

リンクなど動的にコンポーネントに置換したいようなケースは、そこそこありそうなのでXSSには注意しながらいい感じに実装していきたいですね、それでは👋

参考資料

012-jp.vuejs.org

ryotah.hatenablog.com

qiita.com

Vue.js: Composition APIを試してみたので使い方とかMEMO

Composition APIを色々触ってみたので使い方等をメモしておきます📝

Composition APIとは

Compostion APIとはVue.js 3.0から導入される予定のAPIです✨

Composition API: a set of additive, function-based APIs that allow flexible composition of component logic. https://composition-api.vuejs.org/#summary

概要は上記の通り、コンポーネントのロジックをfunctionベースの記載できるようなAPIとなっており、 公式ドキュメントに記載されたComposition APIが導入されたモチベーションは下記のようなものみたいです。

Logic Reuse & Code Organization
Better Type Inference
https://composition-api.vuejs.org/#summary

Composition APIを使うとコードの再利用とTypeScriptのサポートが受けやすくなるようです👀

※ちなみに従来の記載方法はOptions APIと呼ばれているようです。

次から実際の使い方を軽く見ていきます。

簡単な使い方

install

Vue.js 3はまだ正式にリリースされているわけではないので、今回はnpm packageとして切り出されたComposition APIを使っていきます📦

npm install --save-prod @vue/composition-api

有効化

Vue.useを使ってCompostion APIを有効化します⚡

import Vue from "vue";
import VueCompositionApi from "@vue/composition-api";
Vue.use(VueCompositionApi);

Componentの定義

Componentを定義するためにはdefineComponentを使用します。

<template>
  <div />
</template>
<script lang="ts">
import Vue from "vue";
import VueCompositionApi, { defineComponent } from "@vue/composition-api";
Vue.use(VueCompositionApi);

export default defineComponent({
  name: "Sample",
});
</script>
<style></style>

data

dataを使う場合はreactiveを使用します⚡ setup内でreactiveの引数にobjectを継承した方を持つ値を渡すことでリアクティブな属性を定義できます。

dataの型を定義するにはreactiveジェネリック型として任意の型を渡してあげます。

そしてsetupreturn { state }として値を返してあげます。

※従来のようにtemplate内で{{message}}と呼び出したい場合はreturn { ...toRefs(state) }としてあげます。toRefsをつけないとリアクティブでなくなってしまう💦

<template>
  <div>{{ state.message }}</div>
</template>
<script lang="ts">
import Vue from "vue";
import VueCompositionApi, { defineComponent, reactive } from "@vue/composition-api";
Vue.use(VueCompositionApi);

type State = { message: string };

export default defineComponent({
  name: "Sample",
  setup() {
    const state = reactive<State>({ message: "" });
    return { state };
  },
});
</script>
<style></style>

computed

computedを使う場合はcomputedを使用します⚡ setup内でcomputedの引数に関数を渡して上げることで定義することが出来ます。

そしてsetupreturn { state, strongMessage}としてcomputedで定義したものを合わせて返してあげます。

<template>
  <div>
    <p>{{ state.message }}</p>
    <p>{{ strongMessage }}</p>
  </div>
</template>
<script lang="ts">
import Vue from "vue";
import VueCompositionApi, { defineComponent, reactive, computed } from "@vue/composition-api";
Vue.use(VueCompositionApi);

type State = { message: string };

export default defineComponent({
  name: "Sample",
  setup() {
    const state = reactive<State>({ message: "" });
    const strongMessage = computed(() => state.message.toUpperCase());

    return {
      state,
      strongMessage,
    };
  },
});

methods

methodsは単純に関数を定義して、setupでreturnするオブジェクトに定義した関数を入れてあげます。

<template>
  <div>
    <p>{{ state.message }}</p>
    <p>{{ strongMessage }}</p>
    <p>{{ weakMessage(state.message) }}</p>
  </div>
</template>
<script lang="ts">
import Vue from "vue";
import VueCompositionApi, { defineComponent, reactive, computed } from "@vue/composition-api";
Vue.use(VueCompositionApi);

type State = { message: string };

export default defineComponent({
  name: "Sample",
  setup() {
    const state = reactive<State>({ message: "" });
    const strongMessage = computed(() => state.message.toUpperCase());
    const weakMessage = (message: string) => message.toLowerCase();

    return {
      state,
      strongMessage,
      weakMessage,
    };
  },
});
</script>
<style></style>

props

propsを使う場合は従来どおりpropsを定義したあとにsetupの引数を定義することで使用出来ます。 propsの型を定義するにはsetupの引数に型を定義してあげます。

<template>
  <div>
    <p>{{ state.message }}</p>
    <p>{{ strongMessage }}</p>
    <p>{{ weakMessage(state.message) }}</p>
  </div>
</template>
<script lang="ts">
import Vue from "vue";
import VueCompositionApi, { defineComponent, reactive, computed } from "@vue/composition-api";
Vue.use(VueCompositionApi);

type State = { message: string };
type Props = { initializeMessage: string };

export default defineComponent({
  name: "Sample",
  props: {
    initializeMessage: {
      type: String,
      default: "",
    },
  },
  setup(props: Props) {
    const state = reactive<State>({ message: "" });
    const strongMessage = computed(() => state.message.toUpperCase());
    const weakMessage = (message: string) => message.toLowerCase();

    return {
      state,
      strongMessage,
      weakMessage,
    };
  },
});
</script>
<style></style>

emit

emitを使うにはsetupの第2引数を定義して使用します。

あとはthis.$emitの代わりにcontext.emitを使用してあげます。

<template>
  <div>
    <p>{{ state.message }}</p>
    <p>{{ strongMessage }}</p>
    <p>{{ weakMessage(state.message) }}</p>
    <button @click="handleOnOK">
      OK
    </button>
  </div>
</template>
<script lang="ts">
import Vue from "vue";
import VueCompositionApi, { defineComponent, reactive, computed, SetupContext } from "@vue/composition-api";
Vue.use(VueCompositionApi);

type State = { message: string };
type Props = { initializeMessage: string };

export default defineComponent({
  name: "Sample",
  props: {
    initializeMessage: {
      type: String,
      default: "",
    },
  },
  setup(props: Props, context: SetupContext) {
    const state = reactive<State>({ message: "" });
    const strongMessage = computed(() => state.message.toUpperCase());
    const weakMessage = (message: string) => message.toLowerCase();
    const handleOnOK = () => {
      context.emit("ok");
    };

    return {
      state,
      strongMessage,
      weakMessage,
      handleOnOK,
    };
  },
});
</script>
<style></style>

Options APIからComposition APIへの書き換え

実際に適当なComponentを書き換えたみたものを一応のせておきます。

Options API

<template>
  <div class="ts-counter">
    <button @click="decrement(1)">
      -
    </button>
    {{ count }}
    <button @click="increment(1)">
      +
    </button>
  </div>
</template>
<script lang="ts">
import Vue from "vue";

interface Data {
  count: number;
}

export default Vue.extend({
  props: {
    initCount: {
      type: Number,
      default: 0,
    },
  },
  data(): Data {
    return {
      count: this.initCount,
    };
  },
  methods: {
    decrement(num: number) {
      this.count -= num;
    },
    increment(num: number) {
      this.count += num;
    },
  },
});
</script>
<style lang="scss" scoped>
.ts-counter {
  color: blue;
}
</style>

Composition API

<template>
  <div class="ts-counter">
    <button @click="decrement(1)">
      -
    </button>
    {{ count }}
    <button @click="increment(1)">
      +
    </button>
  </div>
</template>
<script lang="ts">
import Vue from "vue";
import VueCompositionApi, { defineComponent, reactive, toRefs } from "@vue/composition-api";
Vue.use(VueCompositionApi);

interface Data {
  count: number;
}

interface Props {
  initCount: number;
}

export default defineComponent({
  props: {
    initCount: {
      type: Number,
      default: 0,
    },
  },
  setup(props: Props) {
    const state = reactive<Data>({ count: props.initCount });
    const increment = (n: number) => {
      state.count += n;
    };
    const decrement = (n: number) => {
      state.count -= n;
    };
    return {
      ...toRefs(state),
      increment,
      decrement,
    };
  },
});
</script>
<style lang="scss" scoped>
.ts-counter {
  color: blue;
}
</style>

おわりに

Composition APIを使っていろいろやってみたのですが、最初のモチベーション部分のコードの再利用等はまだ大規模なフロントエンドを経験したことが無いので、ちょっとわからない部分も多かったのですが、タイプスクリプトのサポートの方はthisに依存するコードがなくなって非常に快適になったように感じました。

Option APIのほうがComponentのデータとロジックがプロパティで別れていてClassぽくて分かりやすいかなと個人的には思っていたのですが、Composition APIも書きやすいですね✨

参考資料

composition-api.vuejs.org

qiita.com

techblog.zozo.com

TypeScript: classに定義したstaticメソッドをinterfaceで型定義するときのMEMO

classに定義したstaticメソッドをinterfaceで型定義しようとして下記のように書いたらエラーになってしまったのでどうすればいいのか調べたのでMEMO📝

interface MyClassInterface {
  id: number,
  name: string,
  method(): void,
  static staticMethod(): void // 'static' 修飾子は型メンバーでは使用できません。ts(1070)
}

const MyClass: MyClassConstructor = class MyClass implements MyClassInterface {
  constructor(public id: number, public name: string){}
  method(){}
  static staticMethod(){}
}

色々調べた結果、公式ドキュメントにまさにな内容が書いてあった🙌

simple way is to use class expressions https://www.typescriptlang.org/docs/handbook/interfaces.html#difference-between-the-static-and-instance-sides-of-classes

クラス式を使ってclassの返り値を変数代入時に型チェックすることでConstructorのインターフェースを満たしているか検証できるんですね✨

interface MyClassConstructor {
  new (id: number, name: string): MyClassInterface
  staticMethod(): void
}

interface MyClassInterface {
  id: number,
  name: string,
  method(): void
}

const MyClass: MyClassConstructor = class MyClass implements MyClassInterface {
  constructor(public id: number, public name: string){}
  method(){}
  static staticMethod(){}
}

TypeScriptのissueでもこの辺のstaticのinterfaceの定義方法は議論されているみたいですね👀

github.com

あまり型を使う言語になれてないので色々勉強になる😓

TypeScript: ジェネリック型を使って配列を扱うときに`extends Type`して`T[]`とするのと`extends Type[]`して`T`とする場合の扱われ方の違いMEMO

下記のような場合に<T extends unknown>(arg: T[]) => T[]と定義した場合だけ、TypeScriptのコンパイル時にエラーが発生して🤔となったけど納得したのでMEMOしておきます📝

type TypeA = <T extends unknown>(arg: T[]) => T[]
type TypeB = <T extends unknown[]>(arg: T) => T
const funcA: a = (...args) => args
const funcB: b = (...args) => args

funcA("aaa", 1) // 型 'number' の引数を型 'string' のパラメーターに割り当てることはできません。ts(2345)
funcB("aaa", 2)

検証した環境は下記の通りです。

$ npx tsc -v
Version 3.9.7

個人的には最初は同じかと思っていたのですが、、、TypeScriptは実際には下記ような形で型を設定していました。

const funcA: <string>(...args: string[]) => string[]
const funcB: <[string, number]>(args_0: string, args_1: number) => [string, number]

エラーが発生していたfuncA...args: string[]となっているためfuncA("aaa", 1)number型の引数1を渡しているため型エラーが発生していたようです。

流れとしてはジェネリック型は呼び出し時に動的に型が決まります。 funcAの場合は<T extends unknown>としており、funcA("aaa", 1)の最初の引数の型がstringのためジェネリックTstringに置き換わり、string[]型が指定されてconst funcA: <string>(...args: string[]) => string[]となっているようです👀

// 最初の定義
const funcA: <T extends unknown>(...args: T[]) => T[]
// funcA("aaa", 1)の呼び出しによって第一引数の型がTに反映される
const funcA: <string>(...args: string[]) => string[]

ジェネリック型のTが配列として定義されていないので最初に合致したstring型に置き換わってしまうのがポイントっぽいですね。

逆にfuncBの場合は<T extends unknown[]>としているので可変長引数がタプルとして扱い、そのままT[string, number]に置き換わっているのでエラーにならないようです👀

// 最初の定義
const funcB: <T extends unknown[]>(arg: T) => T
// funcB("aaa", 1)の呼び出しによって引数のタプルがTに反映される
const funcB: <[string, number]>(args_0: string, args_1: number) => [string, number]

どうやらジェネリック型の宣言で指定したものと合致するように解釈して型を推論してくれているみたいですね。

一応、funcAも下記のように明示的に指定してあげると(string|number)[]と判断してくれてエラーは発生しなくなりました😅

funcA<string | number>("aaa", 1)
const funcA: <string | number>(...args: (string | number)[]) => (string | number)[]

ジェネリック型の型推論難しい・・・😓

Markdown形式のテキストをRSpec形式に変換するGemを作りました💎

画面仕様書等、Markdownで整理していたものからRSpecを書き直すのが意外と手間と思うことがあったのでMarkdownで書いたものをRSpec形式のテキストに変換するGemを作りました。

github.com

使い方

使い方は、

$ gem install markdown_to_rspec
# CLI
$ markdown_to_rspec -f `MARKDOWN_FILE_PATH`
#=> return A string in RSpec format
$ markdown_to_rspec -t `MARKDOWN__TEXT`
#=> return A string in RSpec format

上記ようにgemをinstall後に引数に以下の値を渡すことでRSpec形式の文字列に変換します。

例えばこのような画面仕様書的なマークダウンを変換すると、

# Details.
A screen to check something
ref: https://example/com/tickets/1

## Initial Display.

### When a record exists.
* The title must be displayed.
* The text must be displayed.

### When the record does not exist.
* The title should not be displayed.
* The text should not be displayed.

### Other cases.
* 500 pages to be displayed.

# Index.
A screen to check something

## Initial Display.
* The items must be displayed.

以下のようなRSpec形式の文字列を取得出来ます✨

RSpec.describe 'Details.' do
  # A screen to check something
  # ref: https://example/com/tickets/1
  describe 'Initial Display.' do
    context 'When a record exists.' do
      it 'The title must be displayed.' do
      end
      it 'The text must be displayed.' do
      end
    end
    context 'When the record does not exist.' do
      it 'The title should not be displayed.' do
      end
      it 'The text should not be displayed.' do
      end
    end
    context 'Other cases.' do
      it '500 pages to be displayed.' do
      end
    end
  end
end
RSpec.describe 'Index.' do
  # A screen to check something
  describe 'Initial Display.' do
    it 'The items must be displayed.' do
    end
  end
end

※インデントの数やMarkdown#のどのレベルをdescribe or contextにする等は一旦固定値になっているので調整出来ません🙏💦

個人的な技術Topic

作った上で個人的な技術Topicをちょっと書いときます。

RDoc::Markdownを使ってマークダウンから中間オブジェクトを生成

MarkdownからRSpecに変換する際になんかしらの中間的なオブジェクトを生成する必要がありそうだなぁと思い、 なんかいい感じのgemを探していたのですが見つからず。。。

そういればRDocってMarkdown形式でかけるなと思い標準ライブラリを探していたら、 RDoc::Markdownがまさにな感じだったのでそれを使うことにしました。

docs.ruby-lang.org

下記のような感じでMarkdown形式の文字列をRDoc::Markdown.parseに渡してあげると、 RDoc::Markup::DocumentというMarkdownのアイテムの関係性を保持した中間のオブジェクトを返却してくれます。

require 'rdoc/markdown'

markdown = <<~MARKDOWN
# title
## subtitle
* item 1
* item 2
MARKDOWN

RDoc::Markdown.parse(markdown)
#=> #<RDoc::Markup::Document:0x00007f92f0b2e398 @parts=[#<struct RDoc::Markup::Heading level=1, text="title">, #<struct RDoc::Markup::Heading level=2, text="subtitle">, #<RDoc::Markup::List:0x00007f92f11dd928 @type=:BULLET, @items=[#<RDoc::Markup::ListItem:0x00007f92f0b65f78 @label=nil, @parts=[#<RDoc::Markup::Paragraph:0x00007f92f11e3828 @parts=["item 1"]>]>, #<RDoc::Markup::ListItem:0x00007f92f11e8fa8 @label=nil, @parts=[#<RDoc::Markup::Paragraph:0x00007f92f0b54b38 @parts=["item 2"]>]>]>], @file=nil, @omit_headings_from_table_of_contents_below=nil>

このオブジェクトをRSpecに変換するコードを今回は実装しています。

この辺のRDoc::Markup::Formatterを継承したクラスを作ったほうがいい感じに出来たのかも・・・? ※今回は使い方を学習するのが結構難しそうで自前で実装してしまった。。。

docs.ruby-lang.org

OptionParserを使ってCLIのインタフェースを定義

CLI部分のインタフェース部分を標準ライブラリのOptionParserを使って実装してます。

docs.ruby-lang.org

OptionParserを使うと下記のような感じで実行時に渡されたオプションに対して何を実行するのかを定義出来ます。

option.onで指定されたオプションと実行する処理を紐付けして、option.parse!(ARGV)で、 実行時引数を元に実行する処理を判定して引数をよしなに渡してくれるようです✨(便利)

require 'optparse'
require 'markdown_to_rspec/rake_tasks'

option = OptionParser.new
option.on('-f', '--file FILE_PATH', 'Set the target Markdown file path.') do |v|
  Rake::Task['markdown_to_rspec:to_rspec_from_file'].execute(file_path: v)
end
option.on('-v', '--version', 'Show gem version.') do
  Rake::Task['markdown_to_rspec:version'].execute
end
option.parse!(ARGV)

上記のような設定だと以下のような動きになります。

  • -f--formatの時に引数で渡された値を元にRake::Task['markdown_to_rspec:to_rspec_from_file']を実行 例) $ markdown_to_rspec -f sample.md
  • -v--versionの時にRake::Task['markdown_to_rspec:version']を実行 例) $ markdown_to_rspec -v

あとは--helpも自動で実装してくれます✨

$ markdown_to_rspec -h
Usage: markdown_to_rspec [options]
    -f, --file FILE_PATH             Set the target Markdown file path.
    -v, --version                    Show gem version.

おわりに

マークダウン形式で書かれた画面仕様書からRSpecのE2Eテスト等を作るときに、 抜け漏れ等を防げて便利な気がするので、せっかく作ったので使っていこうと思います💪

色々調べるとRubyの標準ライブラリ、便利ですね✨

Ruby: オレオレフレームワーク`makanai`を`v0.1.6`にアップデートしました🥳

先日、ほぼピュアなRubyで書いているオレオレフレームワークmakanaiv0.1.6にバージョンアップしました🥳

github.com

アップデートの主な内容は下記の通りです。

✨ enabled to switch template engine Haml and ERB.

github.com

テンプレートエンジンが今まではERB固定だったのですが、Hamlも使えるようになりました⚙️

Makanai::Settings.template_engineの値か、renderの第2引数で使用するテンプレートエンジンが指定可能になりました✨(:haml or :erb)

require 'makanai/main'

# setting template engine(default: :erb).
Makanai::Settings.template_engine = :haml

router.get '/index' do
  @title = 'Makanai title'
  @body = 'Makanai body'
  render :index # render default template engine.
end

router.get '/index' do
  @title = 'Makanai title'
  @body = 'Makanai body'
  render :index, :haml # render specified template engine.
end

⚡ remove sqlite3 from runtime_dependency.

github.com

今までsqlite3runtime_dependencyとなっていましたが、静的サイト等特にDBを使わない場合は必要ないのでruntime_dependencyから外しました✂️

makanai initでGemfileにsqlite3が追記された状態でapplication用のディレクトリ構成が作成されるようになっています📦

それでは👋