はじめに
プリザンターの計算式には「通常」と「拡張」の 2 種類があります。通常の計算式は四則演算のみですが、拡張計算式は $IF や $DATEDIF などの関数が使えてかなり高機能です。
本記事ではこの 拡張計算式がどのようなロジックで実装されているか をソースコードから追いかけて調べてみます。
本記事のソースコード参照は Implem.Pleasanter リポジトリのコミット 203cac8 時点のものです。
通常計算式と拡張計算式の違い
まず、そもそもの違いを整理しましょう。
| 項目 | 通常計算式 | 拡張計算式 |
|---|---|---|
| 計算方法 | Default |
Extended |
| 対象項目 | 数値項目のみ(decimal 型) |
数値・分類・説明・チェック・日付項目 |
| 記法 | 数値A + 数値B * 2 |
$IF(チェックA, 数値A, 数値B) |
| 実行エンジン | C# の Formula クラス(木構造を走査) |
V8 JavaScript エンジン(ClearScript) |
| 使える演算 | 四則演算(+ - * /)とカッコ |
四則演算 + 50 種類以上の関数 |
この違いは FormulaSet.CalculationMethods という列挙型で管理されています。
public enum CalculationMethods
{
Default,
Extended
}
拡張計算式で対象にできる項目の範囲が広いのは FormulaColumn メソッドの実装から確認できます。
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 が呼ばれます。ここで通常計算式と拡張計算式で処理が分岐します。
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 メソッドがこの追従処理を担っています。
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 が呼ばれます。CalculationMethod が Extended の場合、ExecFormulaExtended に処理が進みます。
ステップ 1: デフォルト値の設定
まず SetExtendedColumnDefaultValue で、計算式内で参照されている項目に値が未設定の場合にデフォルト値をセットします。
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 形式に変換します。
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) に変換されて実行されます。
FormulaColumnList は LabelText の長い順にソートされています。これにより「数値A合計」と「数値A」のように、短い名前が長い名前の一部に含まれる場合でも正しく置換されます。
ステップ 3: V8 エンジンでの JavaScript 実行
変換された計算式は FormulaServerScriptUtilities.Execute で実行されます。ここが拡張計算式の心臓部です。
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 の関数として定義 されている - 関数名は大文字小文字を問わない(
ParseIgnoreCaseで統一される) -
contextオブジェクトも渡されるので、ログイン情報なども参照可能
ステップ 4: 結果の反映
JavaScript エンジンから返された結果は、SetByFormData を通じてモデルに反映されます。
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 と同様のエラーコードが返されます。
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;
}
-
IsDisplayErrorがtrueの場合はユーザーにエラーを表示(例外をスロー) -
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!等)に準拠している
計算式の公式マニュアルも合わせて参照してください。