0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

プリザンターの拡張計算式の実装ロジックを調べてみる

0
Last updated at Posted at 2026-06-22

はじめに

プリザンターの計算式には「通常」と「拡張」の 2 種類があります。通常の計算式は四則演算のみですが、拡張計算式は $IF$DATEDIF などの関数が使えてかなり高機能です。

本記事ではこの 拡張計算式がどのようなロジックで実装されているか をソースコードから追いかけて調べてみます。

本記事のソースコード参照は Implem.Pleasanter リポジトリのコミット 203cac8 時点のものです。

通常計算式と拡張計算式の違い

まず、そもそもの違いを整理しましょう。

項目 通常計算式 拡張計算式
計算方法 Default Extended
対象項目 数値項目のみ(decimal 型) 数値・分類・説明・チェック・日付項目
記法 数値A + 数値B * 2 $IF(チェックA, 数値A, 数値B)
実行エンジン C# の Formula クラス(木構造を走査) V8 JavaScript エンジン(ClearScript)
使える演算 四則演算(+ - * /)とカッコ 四則演算 + 50 種類以上の関数

この違いは FormulaSet.CalculationMethods という列挙型で管理されています。

Libraries/Settings/FormulaSet.cs
public enum CalculationMethods
{
    Default,
    Extended
}

拡張計算式で対象にできる項目の範囲が広いのは FormulaColumn メソッドの実装から確認できます。

Libraries/Settings/SiteSettings.cs
public Column FormulaColumn(string name, string calculationMethod = null)
{
    return (string.IsNullOrEmpty(calculationMethod)
        || calculationMethod == FormulaSet.CalculationMethods.Default.ToString()
        // 通常計算式: decimal 型の項目のみ
        ? Columns
            .Where(o => o.ColumnName == name || o.LabelText == name)
            .Where(o => o.TypeName == "decimal")
            .Where(o => !o.NotUpdate)
            .Where(o => !o.Joined)
        // 拡張計算式: 添付ファイル・ID/Ver・結合項目・SiteId・Comments 以外すべて
        : Columns
            .Where(o => o.ColumnName == name || o.LabelText == name)
            .Where(o => o.ControlType != "Attachments")
            .Where(o => !o.NotUpdate)
            .Where(o => !o.Id_Ver)
            .Where(o => !o.Joined)
            .Where(o => !o.OtherColumn())
            .Where(o => o.Name != "SiteId"
                && o.Name != "Comments"))
        .FirstOrDefault();
}

処理フローの全体像

拡張計算式の処理は、大きく 登録時実行時 に分かれます。全体のフローを図にしてみましょう。

登録時の流れ

実行時の流れ

登録時の処理

管理画面で計算式を設定すると FormulaBuilder.SetFormula が呼ばれます。ここで通常計算式と拡張計算式で処理が分岐します。

Libraries/Settings/FormulaBuilder.cs
if (string.IsNullOrEmpty(calculationMethod)
    || calculationMethod == FormulaSet.CalculationMethods.Default.ToString())
{
    // 通常計算式: 数式文字列をパースして Formula ツリーに変換
    formulaSet.FormulaScript = null;
    formulaSet.FormulaScriptOutOfCondition = null;
    var formulaParts = Parts(formula);
    // ...パースして formulaSet.Formula に格納
}
else
{
    // 拡張計算式: FormulaScript にそのまま保存
    formulaSet.Formula = null;
    formulaSet.OutOfCondition = null;
    formulaSet.FormulaScript = formula;
    formulaSet.FormulaScriptOutOfCondition = formulaSet.Condition != null
        ? outOfCondition
        : null;
    // 表示名 ↔ カラム名の対応表を生成
    formulaSet = UpdateColumnDisplayText(ss: ss, formulaSet: formulaSet);
}

FormulaMapping の役割

拡張計算式では FormulaMapping というフィールドに「カラム名 → 表示名」の対応が JSON で保存されます。

例えば $IF(チェックA, 1, 0) という計算式を登録すると以下のようなマッピングが生成されます。

{"CheckA": "チェックA"}

これは 管理画面で項目の表示名を変更した場合に、計算式内のラベルも追従させる ためのしくみです。UpdateColumnDisplayText メソッドがこの追従処理を担っています。

Libraries/Settings/FormulaBuilder.cs
public static FormulaSet UpdateColumnDisplayText(SiteSettings ss, FormulaSet formulaSet)
{
    var columnList = ss.FormulaColumnList();
    var formulaDictionary = new Dictionary<string, string>();
    var oldMapping = Jsons.Deserialize<Dictionary<string, string>>(formulaSet.FormulaMapping);
    formulaSet.FormulaScript = UpdateFormulaScript(formulaSet.FormulaScript);
    formulaSet.FormulaScriptOutOfCondition = UpdateFormulaScript(formulaSet.FormulaScriptOutOfCondition);
    formulaSet.FormulaMapping = formulaDictionary.ToJson();
    return formulaSet;
    string UpdateFormulaScript(string formulaScript)
    {
        if (formulaScript == null) return null;
        foreach (var column in columnList)
        {
            // 旧マッピングに対応があれば、旧表示名→新表示名に置換
            if (oldMapping != null && oldMapping.ContainsKey(column.ColumnName))
            {
                formulaScript = Regex.Replace(
                    input: formulaScript,
                    pattern: @"(?<!\$)" + $@"\b{Regex.Escape(oldMapping.Get(column.ColumnName))}\b"
                        + $"(?=(?:[^""]*""[^""]*"")*[^""]*$)",
                    replacement: column.LabelText);
            }
            // 現在の表示名が計算式内にあれば、新マッピングに追加
            var isMatched = Regex.IsMatch(
                input: formulaScript,
                pattern: Regex.Escape(column.LabelText)
                    + $"(?=(?:[^""]*""[^""]*"")*[^""]*$)");
            if (isMatched)
            {
                if (!formulaDictionary.ContainsKey(column.ColumnName))
                {
                    formulaDictionary.Add(column.ColumnName, column.LabelText);
                }
            }
        }
        return formulaScript;
    }
}

正規表現の (?=(?:[^"]*"[^"]*")*[^"]*$)ダブルクォートで囲まれた文字列の中は置換しない という条件です。計算式の中で文字列リテラルとして使われている部分を誤って置換しないようにしています。

実行時の処理

レコードの作成・更新時に SetByFormula が呼ばれます。CalculationMethodExtended の場合、ExecFormulaExtended に処理が進みます。

ステップ 1: デフォルト値の設定

まず SetExtendedColumnDefaultValue で、計算式内で参照されている項目に値が未設定の場合にデフォルト値をセットします。

Models/Shared/_BaseModel.cs
public void SetExtendedColumnDefaultValue(
    SiteSettings ss, string formulaScript, string calculationMethod)
{
    if (formulaScript.IsNullOrEmpty()) return;
    // [ColumnName] 形式で参照されている項目を検出
    var columns = Regex.Matches(formulaScript, @"\[([^]]*)\]");
    foreach (var column in columns)
    {
        var columnParam = column.ToString()[1..^1];
        if (ss.FormulaColumn(columnParam, calculationMethod) != null)
        {
            switch (Def.ExtendedColumnTypes.Get(columnParam))
            {
                case "Num":
                    if (GetNum(columnParam).Value == null)
                        SetNum(columnParam, new Num(0));
                    break;
                case "Check":
                    SetCheck(columnParam, GetCheck(columnParam));
                    break;
                case "Class":
                    SetClass(columnParam, GetClass(columnParam));
                    break;
                case "Description":
                    SetDescription(columnParam, GetDescription(columnParam));
                    break;
            }
        }
    }
    // ラベル名で参照されている項目も同様に処理
    // ...
}

ステップ 2: ラベル名 → model.ColumnName への変換

ParseFormulaScript で計算式内のラベル名(表示名)を JavaScript の model.ColumnName 形式に変換します。

Libraries/Settings/FormulaBuilder.cs
public static string ParseFormulaScript(
    SiteSettings ss, string formulaScript, string calculationMethod)
{
    // まず [ColumnName] 形式を model.ColumnName に変換
    var columns = Regex.Matches(formulaScript, @"\[([^]]*)\]");
    var columnList = ss.FormulaColumnList();
    foreach (var column in columns)
    {
        var columnParam = column.ToString()[1..^1];
        if (columnList.Any(o => o.ColumnName == columnParam))
        {
            formulaScript = formulaScript.Replace(
                column.ToString(), $"model.{columnParam}");
        }
    }
    // 次にラベル名(表示名)を model.ColumnName に変換
    foreach (var column in columnList)
    {
        formulaScript = Regex.Replace(
            input: formulaScript,
            pattern: @"(?<!\$)" + Regex.Escape(column.LabelText)
                + $"(?=(?:[^""]*""[^""]*"")*[^""]*$)",
            replacement: $"model.{column.ColumnName}");
    }
    // true/false の大文字小文字を統一
    return formulaScript
        .Replace("true", "true", StringComparison.InvariantCultureIgnoreCase)
        .Replace("\"true\"", "true", StringComparison.InvariantCultureIgnoreCase)
        .Replace("false", "false", StringComparison.InvariantCultureIgnoreCase)
        .Replace("\"false\"", "false", StringComparison.InvariantCultureIgnoreCase);
}

つまり、管理画面で入力した $IF(チェックA, 1, 0) は内部的に $IF(model.CheckA, 1, 0) に変換されて実行されます。

FormulaColumnListLabelText の長い順にソートされています。これにより「数値A合計」と「数値A」のように、短い名前が長い名前の一部に含まれる場合でも正しく置換されます。

ステップ 3: V8 エンジンでの JavaScript 実行

変換された計算式は FormulaServerScriptUtilities.Execute で実行されます。ここが拡張計算式の心臓部です。

Libraries/ServerScripts/FormulaServerScriptUtilities.cs
public static object Execute(
    Context context,
    SiteSettings ss,
    BaseItemModel itemModel,
    string formulaScript)
{
    // モデルの値を ExpandoObject に詰め替え
    var data = ServerScriptUtilities.Values(
        context: context, ss: ss, model: itemModel,
        isFormulaServerScript: true);
    var Model = new ExpandoObject();
    data?.ForEach(datam =>
        ((IDictionary<string, object>)Model)[datam.Name] = datam.Value);

    // 関数名の大文字小文字を統一
    formulaScript = ParseIgnoreCase(formulaScript);

    // V8 エンジンを起動して実行
    using (var engine = new ScriptEngine(debug: false))
    {
        engine.AddHostObject("model", Model);
        engine.AddHostObject("context", context);
        engine.AddHostType(typeof(FormulaServerScriptUtilities));

        // 全関数を JavaScript として登録
        var functionScripts = GetDateScript()
            + GetDateDifScript()
            + GetIfScript()
            + GetAndScript()
            // ... 50種類以上の関数スクリプト
            + GetDateTimeScript();

        object value;
        try
        {
            value = engine.Evaluate(functionScripts + formulaScript);
        }
        catch (Exception ex)
        {
            if (ex is ScriptEngineException se)
            {
                throw new FormulaErrorException(se.Message, se.ErrorDetails);
            }
            throw new FormulaErrorException(ex.Message);
        }
        return value == Undefined.Value ? string.Empty : value;
    }
}

ポイントは以下のとおりです。

  1. V8 JavaScript エンジンMicrosoft ClearScript 経由)で実行されている
  2. レコードの全項目が model オブジェクトとして JavaScript に渡される
  3. $IF$DATEDIF などの関数は すべて JavaScript の関数として定義 されている
  4. 関数名は大文字小文字を問わない(ParseIgnoreCase で統一される)
  5. context オブジェクトも渡されるので、ログイン情報なども参照可能

ステップ 4: 結果の反映

JavaScript エンジンから返された結果は、SetByFormData を通じてモデルに反映されます。

Models/Results/ResultModel.cs
var value = ExecFormulaExtended(
    context: context,
    ss: ss,
    columnName: columnName,
    formulaSet: formulaSet,
    isOutOfCondition: isOutOfCondition,
    outputFormulaLogs: ss.OutputFormulaLogs);
var formData = new Dictionary<string, string>
{
    { $"Results_{columnName}", value }
};
SetByFormData(
    context: context,
    ss: ss,
    formData: formData);

通常計算式は SetNum で数値項目に直接代入しますが、拡張計算式は SetByFormData(フォーム送信時と同じ汎用的なセッターメソッド)を使います。これにより数値以外の項目(分類・説明・チェック・日付)にも計算結果を格納できるわけです。

関数の実装のしくみ

拡張計算式で使える関数は、すべて FormulaServerScriptUtilities クラス内に JavaScript の文字列 として定義されています。

たとえば $IF 関数の実装は以下のようになっています。

function $IF(expression, valueIfTrue, valueIfFalse = false)
{
    if (arguments.length > 3 || arguments.length < 2) {
        return 'Invalid Parameter';
    }
    expression = (expression === undefined || expression === '') ? false : expression;
    valueIfTrue = (valueIfTrue === undefined) ? 0 : valueIfTrue;
    valueIfFalse = (valueIfFalse === undefined) ? 0 : valueIfFalse;
    // "" を空文字列として扱う
    if (typeof valueIfTrue === 'string' && valueIfTrue.length === 2
        && valueIfTrue.substring(0,1).charCodeAt() === 34
        && valueIfTrue.substring(1,2).charCodeAt() == 34)
    {
        valueIfTrue = '';
    }
    if (typeof valueIfFalse === 'string' && valueIfFalse.length === 2
        && valueIfFalse.substring(0,1).charCodeAt() === 34
        && valueIfFalse.substring(1,2).charCodeAt() == 34)
    {
        valueIfFalse = '';
    }
    if (typeof expression === 'boolean')
    {
        return expression ? valueIfTrue : valueIfFalse;
    }
    if (!isNaN(expression))
    {
        expression = (expression != 0);
        return expression ? valueIfTrue : valueIfFalse;
    }
    expression = ($VALUE(expression) != 0);
    return expression ? valueIfTrue : valueIfFalse;
}

Excel の IF 関数とよく似た動作ですが、JavaScript の型に合わせた処理が行われているのがわかります。

使える関数一覧

FormulaServerScriptUtilities に定義されている関数を分類すると以下のようになります。

論理関数

関数 説明 書式例
$IF 条件分岐 $IF(条件, 真の値, 偽の値)
$IFS 複数条件分岐 $IFS(条件1, 値1, 条件2, 値2, ...)
$AND すべて真か $AND(条件1, 条件2, ...)
$OR いずれか真か $OR(条件1, 条件2, ...)
$NOT 否定 $NOT(条件)
$IFERROR エラー時の代替値 $IFERROR(値, エラー時の値)

数値関数

関数 説明 書式例
$ABS 絶対値 $ABS(-5)5
$ROUND 四捨五入 $ROUND(3.456, 2)3.46
$ROUNDUP 切り上げ $ROUNDUP(3.421, 2)3.43
$ROUNDDOWN 切り捨て $ROUNDDOWN(3.456, 2)3.45
$TRUNC 整数部分の切り捨て $TRUNC(3.9)3
$MOD 余り $MOD(10, 3)1
$POWER べき乗 $POWER(2, 10)1024
$SQRT 平方根 $SQRT(16)4
$RAND 乱数(0〜1) $RAND()
$ODD 最も近い奇数に切り上げ $ODD(4)5
$AVERAGE 平均 $AVERAGE(1, 2, 3)2
$MIN 最小値 $MIN(1, 2, 3)1
$MAX 最大値 $MAX(1, 2, 3)3
$VALUE 数値変換 $VALUE("123")123

文字列関数

関数 説明 書式例
$CONCAT 文字列結合 $CONCAT("A", "B")"AB"
$LEFT 左から n 文字 $LEFT("ABC", 2)"AB"
$RIGHT 右から n 文字 $RIGHT("ABC", 2)"BC"
$MID 部分文字列 $MID("ABCDE", 2, 3)"BCD"
$LEN 文字数 $LEN("ABC")3
$FIND 検索(大文字小文字区別) $FIND("B", "ABC")2
$SEARCH 検索(大文字小文字区別なし) $SEARCH("b", "ABC")2
$SUBSTITUTE 文字列置換 $SUBSTITUTE("ABA", "A", "X")"XBX"
$REPLACE 位置指定置換 $REPLACE("ABCDE", 2, 3, "X")"AXE"
$TRIM 前後の空白除去 $TRIM(" A ")"A"
$UPPER 大文字化 $UPPER("abc")"ABC"
$LOWER 小文字化 $LOWER("ABC")"abc"
$ASC 全角→半角 $ASC("ABC")"ABC"
$JIS 半角→全角 $JIS("ABC")"ABC"
$TEXT 書式指定文字列化 $TEXT(1234, "#,##0")"1,234"

日付関数

関数 説明 書式例
$DATE 日付生成 $DATE(2025, 6, 15)"2025/06/15"
$DATETIME 日時生成 $DATETIME(2025, 6, 15, 10, 30, 0)
$TODAY 今日の日付 $TODAY()
$NOW 現在日時 $NOW()
$YEAR 年を取得 $YEAR(日付A)
$MONTH 月を取得 $MONTH(日付A)
$DAY 日を取得 $DAY(日付A)
$HOUR 時を取得 $HOUR(日付A)
$MINUTE 分を取得 $MINUTE(日付A)
$SECOND 秒を取得 $SECOND(日付A)
$WEEKDAY 曜日番号 $WEEKDAY(日付A)
$DATEDIF 日付差分 $DATEDIF(日付A, 日付B, "D")
$DAYS 日数差 $DAYS(日付A, 日付B)
$EOMONTH 月末日 $EOMONTH(日付A, 1)

判定関数

関数 説明 書式例
$ISBLANK 空白判定 $ISBLANK(分類A)
$ISNUMBER 数値判定 $ISNUMBER(分類A)
$ISTEXT 文字列判定 $ISTEXT(分類A)
$ISEVEN 偶数判定 $ISEVEN(数値A)
$ISODD 奇数判定 $ISODD(数値A)
$ISERROR エラー判定 $ISERROR(値)

エラーハンドリング

計算結果がエラー値だった場合の処理も実装されています。Excel と同様のエラーコードが返されます。

Models/Results/ResultModel.cs
switch (value)
{
    case "#N/A":
    case "#VALUE!":
    case "#REF!":
    case "#DIV/0!":
    case "#NUM!":
    case "#NAME?":
    case "#NULL!":
    case "Invalid Parameter":
        if (formulaSet.IsDisplayError == true)
        {
            throw new FormulaErrorException($"Formula error {value}");
        }
        new SysLogModel(
            context: context,
            method: nameof(SetByFormula),
            message: $"Formula error {value}",
            sysLogType: SysLogModel.SysLogTypes.Exception);
        break;
}

  • IsDisplayErrortrue の場合はユーザーにエラーを表示(例外をスロー)
  • false の場合はシステムログに記録するのみ

$ISERROR$IFERROR を組み合わせることで、計算式内でエラーをハンドリングすることも可能です。

// $ISERROR: Excel と同じエラーコードを判定
function $ISERROR(value) {
    return value == '#N/A'
        || value == '#VALUE!'
        || value == '#REF!'
        || value == '#DIV/0!'
        || value == '#NUM!'
        || value == '#NAME?'
        || value == '#NULL!'
        || value == 'Invalid Parameter'
        || ((typeof value === 'number') && !Number.isFinite(value));
}

// $IFERROR: エラーなら代替値を返す
function $IFERROR(value, value_if_error) {
    return $ISERROR(value) === true ? value_if_error : value;
}

まとめ

プリザンターの拡張計算式の実装ロジックを調べてみました。ポイントをまとめます。

  • 拡張計算式は V8 JavaScript エンジン(ClearScript 経由)で実行されている
  • $IF$DATEDIF などの関数はすべて JavaScript 関数として実装 されている
  • 計算式内のラベル名は model.ColumnName に自動変換 されてから実行される
  • 管理画面で表示名を変更した場合は FormulaMapping を使って計算式内のラベルが自動追従する
  • 通常計算式は 数値項目のみ が対象だが、拡張計算式は 分類・チェック・説明・日付項目 も対象にできる
  • 拡張計算式の結果は SetByFormData を通じてモデルに反映される(フォーム送信と同じ汎用メソッド)
  • エラーハンドリングは Excel と同じエラーコード 体系(#N/A, #VALUE! 等)に準拠している

計算式の公式マニュアルも合わせて参照してください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?