拡張計算式の実装ロジック
プリザンターの計算式には「通常」と「拡張」の 2 種類があります。このページでは、$IF や $DATEDIF などの関数が使える拡張計算式が内部でどう実装されているかをまとめます。
結論: 拡張計算式は、計算式内のラベル名を model.ColumnName に変換したうえで、V8 JavaScript エンジン(ClearScript 経由) で評価されます。$IF などの関数はすべて JavaScript 関数として定義されており、結果はフォーム送信と同じ SetByFormData でモデルに反映されます。
INFO
ソースコードの参照先は Implem.Pleasanter のコミット 203cac8 時点のものです。
通常計算式と拡張計算式の違い
| 項目 | 通常計算式 | 拡張計算式 |
|---|---|---|
| 計算方法 | Default | Extended |
| 対象項目 | 数値項目のみ(decimal 型) | 数値・分類・説明・チェック・日付項目 |
| 記法 | 数値A + 数値B * 2 | $IF(チェックA, 数値A, 数値B) |
| 実行エンジン | C# の Formula クラス(木構造を走査) | V8 JavaScript エンジン(ClearScript) |
| 使える演算 | 四則演算(+ - * /)とカッコ | 四則演算 + 50 種類以上の関数 |
| 結果の反映 | SetNum で数値項目に直接代入 | SetByFormData(フォーム送信と同じ汎用セッター) |
計算方法は FormulaSet.CalculationMethods 列挙型で管理されています。
public enum CalculationMethods
{
Default,
Extended
}対象にできる項目
計算式で使える項目は SiteSettings.FormulaColumn で決まります。
| 計算方法 | 条件(いずれも項目名 ColumnName または表示名 LabelText で一致) |
|---|---|
| 通常 | TypeName == "decimal"、更新不可(NotUpdate)でない、結合項目(Joined)でない |
| 拡張 | 添付ファイル(ControlType == "Attachments")でない、NotUpdate でない、ID/Ver(Id_Ver)でない、結合項目でない、OtherColumn() でない、SiteId と Comments でない |
処理の全体像
処理は登録時と実行時に分かれます。
登録時
図を読み込み中…
実行時
図を読み込み中…
登録時の処理
SetFormula の分岐
管理画面で計算式を設定すると 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);
}拡張計算式は文字列のまま FormulaScript(条件外の式は FormulaScriptOutOfCondition)に保存されます。
FormulaMapping:表示名変更への追従
拡張計算式では、FormulaMapping に「カラム名 → 表示名」の対応が JSON で保存されます。たとえば $IF(チェックA, 1, 0) を登録すると次のマッピングになります。
{"CheckA": "チェックA"}これは、管理画面で項目の表示名を変更したときに、計算式内のラベルも追従させるための仕組みです。UpdateColumnDisplayText が、旧マッピングの表示名を新しい表示名に置換し、現在の表示名が計算式内にあれば新しいマッピングに追加します。
// 旧マッピングに対応があれば、旧表示名→新表示名に置換
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] 形式とラベル名の両方)に値が未設定の場合、デフォルト値をセットします。
| 項目の種類 | 処理 |
|---|---|
Num(数値) | 値が null なら new Num(0) をセット |
Check | SetCheck(columnParam, GetCheck(columnParam)) |
Class | SetClass(columnParam, GetClass(columnParam)) |
Description | SetDescription(columnParam, GetDescription(columnParam)) |
2. ラベル名を model.ColumnName に変換
ParseFormulaScript が、計算式内の項目参照を JavaScript の model.ColumnName 形式に変換します。
[ColumnName]形式(正規表現\[([^]]*)\])で、FormulaColumnList()に存在する項目名をmodel.ColumnNameに置換します。- ラベル名(表示名)を
model.ColumnNameに置換します。ダブルクォート内と、直前が$の箇所は置換しません。 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
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 でモデルに反映されます。フォーム送信時と同じ汎用セッターを使うため、数値以外の項目(分類・説明・チェック・日付)にも結果を格納できます。
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);関数の実装
拡張計算式の関数は、すべて 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;
}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 と同様のエラー値だった場合の処理が実装されています。
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 | システムログに Formula error ... を記録するのみ |
計算式内では $ISERROR と $IFERROR でエラーを扱えます。$ISERROR は上記のエラー値に加え、有限でない数値(Number.isFinite が偽)もエラーとみなします。
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で代替値を返します。