Metabase における週初めは日曜

Metabase を試していたところ、以下の点が気になりました。

  • 週単位で集計すると週初めが日曜になる(日曜から土曜までの集計)
(画面例)

f:id:fits:20190525210517p:plain

DB 等、一般的なシステムにおける週初めは月曜になる(ISO 8601)はずなので、Metabase が日曜へ変えているのは確実。

そこで、「SQLを見る」をクリックして SQL の内容を確認してみると、やはり日曜へ変える(週初めの月曜 - 1日)ようになっていました。(接続先 DB は PostgreSQL

クエリビルダーで生成された SQL
SELECT (date_trunc('week', CAST((CAST("public"."stock_move"."date" AS timestamp) + INTERVAL '1 day') AS timestamp)) - INTERVAL '1 day') AS "date", sum("public"."stock_move"."product_qty") AS "sum"
FROM "public"."stock_move"
GROUP BY (date_trunc('week', CAST((CAST("public"."stock_move"."date" AS timestamp) + INTERVAL '1 day') AS timestamp)) - INTERVAL '1 day')
ORDER BY (date_trunc('week', CAST((CAST("public"."stock_move"."date" AS timestamp) + INTERVAL '1 day') AS timestamp)) - INTERVAL '1 day') ASC

特に設定も見当たらないので(タイムゾーンや言語を設定しても無駄だった)、該当箇所の ソース を見てみると、日曜へ変える事しか考慮していない事が判明。

src/matabase/driver/postgres.clj
・・・

(defmethod sql.qp/date [:postgres :week]            [_ _ expr] (hx/- (date-trunc :week (hx/+ (hx/->timestamp expr)
                                                                                             one-day))
                                                                     one-day))

・・・

(現時点では)週初めを月曜へ変えるには Metabase のソースを書き換える事になりそうですが、日曜を前提に作られている点が懸念されます。

また、Allow organizations to determine the start of their week #1779 などを見る限り、Metabase 側の対応に期待するのも厳しそうです。

PostgreSQL 検索時の週初めを月曜へ変更

試しに、Java の Instrumentation 機能を利用し、PostgreSQL 検索時に週初めが月曜となるようにしてみます。

Clojure で実装された該当処理(上記 postgres.clj の処理内容)が Java 上でどのように処理されるのか調べたところ、以下のようになっていました。

  • metabase.driver.postgres__init クラスの static initializer 実行時に metabase.driver.sql.query-processor 名前空間に属する date 変数の rawRoot に [:postgres :week] をキーにして addMethod している

つまり、この処理が終わった後で [:postgres :week] の処理を差し替えれば何とかなりそうです。

実装

ソースは http://github.com/fits/try_samples/tree/master/blog/20190525/

postgres__init クラスの初期化後に処理を実施したいので、ClassFileTransformer を使って任意のクラスのロード時に処理を差し込めるようにしました。(クラスのロード時に transform メソッドが呼ばれる)

ここでは org/postgresql/Driver クラスのロード時に処理するようにしましたが、postgres__init の初期化が済んでいればどのタイミングでも問題ないと思います。

差し替え後の処理 PgWeekFuncClojure のコードで (date-trunc :week expr) を実施するように実装しています。

sample/SampleAgent.java
package sample;

import java.lang.instrument.*;
import java.security.*;
import clojure.lang.*;

public class SampleAgent {
    public static void premain(String agentArgs, Instrumentation inst) {
        inst.addTransformer(new PgWeekTransformer());
    }

    static class PgWeekTransformer implements ClassFileTransformer {
        public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) {
            // org/postgresql/Driver クラスのロード時に実施
            if (className.equals("org/postgresql/Driver")) {
                replacePgWeek();
            }

            return null;
        }

        // [:postgres :week] の処理を置き換えるための処理
        private void replacePgWeek() {
            // metabase.driver.sql.query-processor 名前空間の取得
            Namespace n = Namespace.find(
                Symbol.intern("metabase.driver.sql.query-processor")
            );

            // date 変数の取得
            Var v = n.findInternedVar(Symbol.intern("date"));

            MultiFn root = (MultiFn)v.getRawRoot();

            // キー [:postgres :week] の作成
            IPersistentVector k = Tuple.create(
                RT.keyword(null, "postgres"), 
                RT.keyword(null, "week")
            );

            // [:postgres :week] へ紐づいた処理を差し替え
            root.removeMethod(k);
            root.addMethod(k, new PgWeekFunc());
        }
    }

    // 週の処理関数を定義
    static class PgWeekFunc extends AFunction {
        public static final Var const__1 = RT.var(
            "metabase.driver.postgres", 
            "date-trunc"
        );

        public static final Keyword const__2 = RT.keyword(null, "week");

        public static Object invokeStatic(Object obj1, Object obj2, Object expr) {
            // (date-trunc :week expr) の実施
            return ((IFn)const__1.getRawRoot()).invoke(const__2, expr);
        }

        public Object invoke(Object obj1, Object obj2, Object obj3) {
            return invokeStatic(obj1, obj2, obj3);
        }
    }
}
META-INF/MANIFEST.MF
Manifest-Version: 1.0
Premain-Class: sample.SampleAgent

上記ソースをビルドして JAR ファイル化(例. sample-agent.jar)しておきます。

実行

Metabase の実行時に(上で作成した)sample-agent.jar を -javaagent オプションで適用します。

Metabase 実行(Instrumentation 適用)
> java -javaagent:sample-agent.jar -jar metabase.jar

SQL を確認してみると、INTERVAL '1 day' の減算等が無くなり、処理の差し替えが効いている事を確認できました。

クエリビルダーで生成された SQL(差し替え後)
SELECT date_trunc('week', CAST("public"."stock_move"."date" AS timestamp)) AS "date", sum("public"."stock_move"."product_qty") AS "sum"
FROM "public"."stock_move"
GROUP BY date_trunc('week', CAST("public"."stock_move"."date" AS timestamp))
ORDER BY date_trunc('week', CAST("public"."stock_move"."date" AS timestamp)) ASC

ついでに、サーバーからのレスポンス内容を確認してみると、日付が月曜になり集計結果が変わっている事を確認できました。

レスポンス内容(一部)
"data":{
    "rows":[
        ["2019-04-22T00:00:00.000+09:00",2139.0],
        ["2019-05-06T00:00:00.000+09:00",30.0],
        ["2019-05-13T00:00:00.000+09:00",13.0]
    ],
    "columns":["date","sum"],
    ・・・
}

ただし、Web 画面上は JavaScript が週の範囲を生成している事から、(date: Week の)表示上は日曜から土曜となってしまいます。

(画面例)

f:id:fits:20190525210556p:plain

このように、週初めを月曜へ変えるには以下のような JavaScript の処理に関しても考慮が必要になりそうです。

frontend/src/metabase/lib/formatting.js
・・・

function formatWeek(m: Moment, options: FormattingOptions = {}) {
  // force 'en' locale for now since our weeks currently always start on Sundays
  m = m.locale("en");
  return formatMajorMinor(m.format("wo"), m.format("gggg"), options);
}

・・・