Skip to content

拡張計算式の実装ロジック ​

第5版作成 最終更新 (日本時間)
確認バージョン1.5.8.1

プリザンターの計算式には「通常」と「拡張」の 2 種類があります。このページでは、$IF や $DATEDIF などの関数が使える拡張計算式が内部でどう実装されているかをまとめます。

結論: 拡張計算式は、計算式内のラベル名を model.ColumnName に変換したうえで、V8 JavaScript エンジン(ClearScript 経由) で評価されます。$IF などの関数はすべて JavaScript 関数として定義されており、結果はフォーム送信と同じ SetByFormData でモデルに反映されます。

INFO

ソースコードの参照先は Implem.Pleasanter のコミット 203cac8 時点のものです。

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

項目通常計算式拡張計算式
計算方法DefaultExtended
対象項目数値項目のみ(decimal 型)数値・分類・説明・チェック・日付項目
記法数値A + 数値B * 2$IF(チェックA, 数値A, 数値B)
実行エンジンC# の Formula クラス(木構造を走査)V8 JavaScript エンジン(ClearScript)
使える演算四則演算(+ - * /)とカッコ四則演算 + 50 種類以上の関数
結果の反映SetNum で数値項目に直接代入SetByFormData(フォーム送信と同じ汎用セッター)

計算方法は FormulaSet.CalculationMethods 列挙型で管理されています。

csharp
public enum CalculationMethods
{
    Default,
    Extended
}

FormulaSet.cs#L19-L23

対象にできる項目 ​

計算式で使える項目は SiteSettings.FormulaColumn で決まります。

SiteSettings.cs#L2499-L2517

計算方法条件(いずれも項目名 ColumnName または表示名 LabelText で一致)
通常TypeName == "decimal"、更新不可(NotUpdate)でない、結合項目(Joined)でない
拡張添付ファイル(ControlType == "Attachments")でない、NotUpdate でない、ID/Ver(Id_Ver)でない、結合項目でない、OtherColumn() でない、SiteId と Comments でない

処理の全体像 ​

処理は登録時と実行時に分かれます。

登録時 ​

図を読み込み中…

実行時 ​

図を読み込み中…

登録時の処理 ​

SetFormula の分岐 ​

管理画面で計算式を設定すると FormulaBuilder.SetFormula が呼ばれます。

csharp
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);
}

FormulaBuilder.cs#L98-L138

拡張計算式は文字列のまま FormulaScript(条件外の式は FormulaScriptOutOfCondition)に保存されます。

FormulaMapping:表示名変更への追従 ​

拡張計算式では、FormulaMapping に「カラム名 → 表示名」の対応が JSON で保存されます。たとえば $IF(チェックA, 1, 0) を登録すると次のマッピングになります。

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

これは、管理画面で項目の表示名を変更したときに、計算式内のラベルも追従させるための仕組みです。UpdateColumnDisplayText が、旧マッピングの表示名を新しい表示名に置換し、現在の表示名が計算式内にあれば新しいマッピングに追加します。

FormulaBuilder.cs#L306-L342

csharp
// 旧マッピングに対応があれば、旧表示名→新表示名に置換
if (oldMapping != null && oldMapping.ContainsKey(column.ColumnName))
{
    formulaScript = Regex.Replace(
        input: formulaScript,
        pattern: @"(?<!\$)" + $@"\b{Regex.Escape(oldMapping.Get(column.ColumnName))}\b"
            + $"(?=(?:[^""]*""[^""]*"")*[^""]*$)",
        replacement: column.LabelText);
}

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

実行時の処理 ​

レコードの作成・更新時に SetByFormula が呼ばれ、CalculationMethod が Extended なら ExecFormulaExtended に進みます。

1. デフォルト値の設定 ​

SetExtendedColumnDefaultValue が、計算式内で参照されている項目([ColumnName] 形式とラベル名の両方)に値が未設定の場合、デフォルト値をセットします。

_BaseModel.cs#L1121-L1179

項目の種類処理
Num(数値)値が null なら new Num(0) をセット
CheckSetCheck(columnParam, GetCheck(columnParam))
ClassSetClass(columnParam, GetClass(columnParam))
DescriptionSetDescription(columnParam, GetDescription(columnParam))

2. ラベル名を model.ColumnName に変換 ​

ParseFormulaScript が、計算式内の項目参照を JavaScript の model.ColumnName 形式に変換します。

FormulaBuilder.cs#L249-L273

  1. [ColumnName] 形式(正規表現 \[([^]]*)\])で、FormulaColumnList() に存在する項目名を model.ColumnName に置換します。
  2. ラベル名(表示名)を model.ColumnName に置換します。ダブルクォート内と、直前が $ の箇所は置換しません。
  3. true / false("true" / "false" を含む)を大文字小文字を問わず true / false に統一します。

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

INFO

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

3. V8 エンジンでの JavaScript 実行 ​

変換後の計算式は FormulaServerScriptUtilities.Execute で実行されます。

FormulaServerScriptUtilities.cs#L14-L97

csharp
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;
    }
}
  • V8 JavaScript エンジン(Microsoft ClearScript 経由)で実行されます。
  • レコードの全項目が model オブジェクトとして JavaScript に渡されます。
  • $IF や $DATEDIF などの関数は、評価のたびに関数定義の JavaScript を計算式の前に連結して評価されます。engine.Evaluate(functionScripts + formulaScript) のとおり、関数定義の文字列(1.5.8.1 では Get〜Script() 57 個の連結)と計算式を 1 つのスクリプトとして評価するので、計算式側で同名の関数や変数を宣言すると定義を上書きしてしまう点に注意してください。確認したソースでも同じ実装です(FormulaServerScriptUtilities.cs#L15-L106)。
  • 関数名は大文字小文字を問いません(ParseIgnoreCase で統一)。
  • context オブジェクトも渡されるため、ログイン情報なども参照できます。
  • 評価結果が undefined の場合は空文字になります。スクリプトエラーは FormulaErrorException になります。

4. 結果の反映 ​

結果は SetByFormData でモデルに反映されます。フォーム送信時と同じ汎用セッターを使うため、数値以外の項目(分類・説明・チェック・日付)にも結果を格納できます。

csharp
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);

ResultModel.cs#L3574-L3588

関数の実装 ​

拡張計算式の関数は、すべて FormulaServerScriptUtilities クラス内に JavaScript の文字列として定義されています。たとえば $IF は次のとおりです。

js
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;
}

FormulaServerScriptUtilities.cs#L861-L894

Excel の IF と似た動作ですが、JavaScript の型に合わせた処理になっています。

  • 引数が 2〜3 個でなければ 'Invalid Parameter' を返します。
  • 条件が undefined または空文字なら false とみなします。
  • 真偽値ならそのまま、数値として解釈できれば 0 以外を真、それ以外は $VALUE() で数値化して判定します。
  • ""(ダブルクォート 2 文字)は空文字として扱います。

使える関数一覧 ​

論理関数 ​

関数説明書式例
$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(値)

ログインユーザー関数 ​

1.5.8.1 のソースには、ログインユーザーを返す関数もあります。関数定義の文字列を作るときに context.UserId とユーザー名を埋め込むので、計算式を実行したユーザー(更新した人)の値になります(FormulaServerScriptUtilities.cs#L1929-L1948)。

関数説明書式例
$LOGINUSERIDログインユーザーのユーザー ID$LOGINUSERID()
$LOGINUSERNAMEログインユーザーの名前$LOGINUSERNAME()

Excel にあって拡張計算式に無い関数 ​

1.5.8.1 の関数は上の 57 個だけです(FormulaServerScriptUtilities.cs)。Excel でよく使う SUM・COUNT・INT・SWITCH・EDATE・TEXTJOIN や、VLOOKUP などの検索・参照関数はありません。拡張計算式は計算しているレコード 1 件の項目だけを参照するので、ほかのレコードを集計・検索する関数(SUMIF・VLOOKUP など)は設計上そのままでは作れません。集計が必要なときはサーバースクリプトの items.Sum・items.Count などを使います。

Excel の主な関数との比較と、足すとしたらどれからかは 拡張計算式に無い Excel 関数 にまとめています。

エラーハンドリング ​

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

csharp
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;
}

ResultModel.cs#L3624-L3643

IsDisplayError動作
true例外をスローし、ユーザーにエラーを表示
falseシステムログに Formula error ... を記録するのみ

計算式内では $ISERROR と $IFERROR でエラーを扱えます。$ISERROR は上記のエラー値に加え、有限でない数値(Number.isFinite が偽)もエラーとみなします。

js
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));
}

function $IFERROR(value, value_if_error) {
    return $ISERROR(value) === true ? value_if_error : value;
}

FormulaServerScriptUtilities.cs#L1843-L1875

実践的なポイント ​

  • 項目は表示名でも [ColumnName] でも書ける: どちらも実行前に model.ColumnName に変換されます。表示名を後から変えても、FormulaMapping により計算式内のラベルが追従します。
  • 文字列リテラル内は置換されない: ダブルクォートで囲んだ部分に項目名と同じ文字列があっても、項目参照には変換されません。
  • 関数名の大文字小文字は問わない: ParseIgnoreCase で統一されます。
  • 数値以外の項目にも結果を入れられる: 結果は SetByFormData で反映されるため、分類・説明・チェック・日付項目も計算先にできます。
  • エラーが静かに記録されるだけのことがある: IsDisplayError が false の場合、エラー値はシステムログに記録されるだけです。画面に出したい場合は IsDisplayError が true になるよう設定します。計算式内で扱うなら $IFERROR で代替値を返します。

関連ページ ​

変更履歴

第5版記事の確認版を繰り返す表現を整理する
第4版サーバースクリプトの仕組み・項目の変更可否・拡張サーバースクリプトの解説と、関連する改修・設計メモを追加
第3版「内部実装を読む」を 1.5.8.1 のソースで検証して修正
第2版記事のファイル名に並び順の番号を付け、元記事リンクを frontmatter の sources に移行
第1版「内部実装を読む」に CodeDefiner・多言語対応・ライセンス判定・拡張計算式を追加