Skip to content

拡張 SQL の活用 ​

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

拡張 SQL を使うと、プリザンター本体を改修せずに、通常の操作(レコードの作成・更新・削除・一覧表示など)のタイミングで任意の SQL を追加実行できます。「レコード作成時に別テーブルにも書き込む」「一覧取得時に特定の条件で絞り込む」「画面表示時にマスタデータを隠し項目としてページに埋め込む」といったカスタマイズができます。このページでは次の内容を扱います。

  • 設定ファイル(JSON)に指定できる全パラメータ(識別・適用対象・実行タイミング・挙動)とプレースホルダー
  • OnSelectingWhere で一覧の WHERE 句に条件を加える(ユーザの拡張項目でのフィルタ、特権ユーザの非表示、複数値での IN 句フィルタ)
  • ユーザ削除について、使える拡張機能と使えない拡張機能
  • OnUpdated と拡張スクリプトを組み合わせた添付ファイルのリネーム

サーバースクリプトと併用する場合の値の上書き、名前指定による選択、保存後の再取得、明示呼び出しとの重複は、拡張 SQL とサーバースクリプトを併用するを参照してください。

通常の画面・API処理に追加した拡張SQLは同期実行され、完了を待ってから応答します。 保存後の OnUpdated も応答を返す前の処理なので、SQLが重い場合や呼び出し回数が多い場合は操作の待ち時間が増えます。再取得や行ごとのSSからの呼び出しも含めて、同期実行と応答時間を確認してください。

DBMS ごとの SQL の書き方 ​

SQL Server・PostgreSQL・MySQL で構文が異なる例は、DBMS ごとのタブに分けています。同名の SQL ファイルに入れるのは、使用中の DBMS の内容だけです。

項目SQL ServerPostgreSQLMySQL
識別子の引用符[Sites]"Sites"`Sites`
無効フラグなどの偽値0false0
本体が更新日時に使う式GETDATE()CURRENT_TIMESTAMPCURRENT_TIMESTAMP(3)
組み込みのユーザー ID@_U@ipU@ipU
組み込みのテナント ID@_T@ipT@ipT

型と日時の式は本体の DBMS 別実装(PostgreSqlDataTypes.cs、MySqlDataTypes.cs、SqlServerSqls.cs、PostgreSqlSqls.cs、MySqlSqls.cs)で確認しています。

組み込みパラメータの接頭辞は Parameter.json の SqlParameterPrefix が空の既定の場合です(Parameter.cs)。利用者が定義する @SiteId・@Keyword などを一律に @ip... に変える必要はありません。これらは拡張 SQL の実行時にバインドされるパラメータです。DB のコンソールへ直接貼り付ける場合は、値に置き換えるか、その実行ツールの変数・パラメータの書き方に合わせます。特に PostgreSQL の SQL コンソールは @SiteId をそのまま解釈しません。

プリザンター本体は MySQL の SQL 実行前に ansi_quotes,pipes_as_concat を設定します(MySqlCommandText.cs)。このため、本体の SQL の引用や共通の条件式では MySQL にも二重引用符を使えます。MySQL のコンソールで同じ SQL を実行するときは同じモードを設定するか、識別子をバッククォートに直してください。PostgreSQL は引用符を省くと識別子を小文字として扱うため、テーブル・列・返却列の別名の二重引用符を残します。

JSON 配列の例で使う MySQL の JSON_TABLE は 8.0 以降を対象とします(JSON_TABLE)。

拡張 SQL の配置場所 ​

拡張 SQL は App_Data/Parameters/ExtendedSqls/ に置きます。1 つの JSON ファイルが 1 つの拡張 SQL 定義に対応します。SQL は JSON 内の CommandText に直接書く方法と、同じ名前の .json.sql ファイルに分けて書く方法があります。

text
App_Data/Parameters/ExtendedSqls/
├── HidePrivilegedUsers.json          ← CommandText に SQL を直接書く
├── SyncAttachmentFileName.json       ← 設定
└── SyncAttachmentFileName.json.sql   ← SQL 本体

拡張 SQL を置いた後は、プリザンターを再起動するか、パラメータリロード(/admins/reloadparameters)を実行すると反映されます。

ファイルではなく Extensions テーブル(データベース)に登録して管理することもできます。詳しくは Extensions テーブルで拡張機能を DB 管理 を参照してください。

設定パラメータ ​

設定ファイルに書けるパラメータの全体は次のとおりです(Implem.ParameterAccessor/Parts/ExtendedSql.cs と ExtendedBase.cs の実装から導出したもの。1.5.2.0 時点。確認したソースでも同じ項目です。ExtendedSql.cs、ExtendedBase.cs)。

json
{
    "Name": null,
    "SpecifyByName": false,
    "Description": "Sample",
    "Disabled": false,
    "DeptIdList": null,
    "GroupIdList": null,
    "UserIdList": null,
    "SiteIdList": null,
    "IdList": null,
    "Controllers": null,
    "Actions": null,
    "ColumnList": null,
    "Api": false,
    "DbUser": null,
    "Html": false,
    "OnCreating": false,
    "OnCreated": false,
    "OnUpdating": false,
    "OnUpdated": false,
    "OnUpdatingByGrid": false,
    "OnUpdatedByGrid": false,
    "OnDeleting": false,
    "OnDeleted": false,
    "OnBulkUpdating": false,
    "OnBulkUpdated": false,
    "OnBulkDeleting": false,
    "OnBulkDeleted": false,
    "OnImporting": false,
    "OnImported": false,
    "OnSelectingColumn": false,
    "OnSelectingColumnParams": null,
    "OnSelectingWhere": false,
    "OnSelectingWherePermissionsDepts": false,
    "OnSelectingWherePermissionsGroups": false,
    "OnSelectingWherePermissionsUsers": false,
    "OnSelectingWhereParams": null,
    "OnSelectingOrderBy": false,
    "OnSelectingOrderByParams": null,
    "OnUseSecondaryAuthentication": false,
    "CommandText": "-- Write an arbitrary SQL statement."
}

パラメータは「識別・説明」「適用対象の絞り込み」「実行タイミング」「挙動」の 4 つに分けられます。

カテゴリ主なパラメータ
識別・説明Name, SpecifyByName, Description, Disabled
適用対象の絞り込みDeptIdList, GroupIdList, UserIdList, SiteIdList, IdList, Controllers, Actions, ColumnList
実行タイミング(レコード操作)OnCreating, OnCreated, OnUpdating, OnUpdated, OnUpdatingByGrid, OnUpdatedByGrid, OnDeleting, OnDeleted
実行タイミング(一括操作)OnBulkUpdating, OnBulkUpdated, OnBulkDeleting, OnBulkDeleted
実行タイミング(インポート)OnImporting, OnImported
実行タイミング(一覧取得)OnSelectingWhere, OnSelectingOrderBy, OnSelectingColumn と、それぞれの 〜Params
実行タイミング(権限設定)OnSelectingWherePermissionsDepts / Groups / Users
実行タイミング(認証)OnUseSecondaryAuthentication
挙動Html, Api, DbUser
SQL 本文CommandText

識別・説明 ​

パラメータ型説明
Namestring拡張 SQL の名前。SpecifyByName: true のとき、名前が一致したものだけが適用される
SpecifyByNamebooltrue にすると名前指定による呼び出しでのみ適用される(既定: false)
Descriptionstring説明文(コメント用。動作には影響しない)
Disabledbooltrue にするとこの拡張 SQL が無効になる(既定: false)

SpecifyByName: false(既定)の場合は、タイミング条件(OnCreating など)が一致するすべての拡張 SQL が実行されます。SpecifyByName: true にすると、サーバースクリプトから view.OnSelectingWhere = "名前" のように名前を指定して呼び出したときだけ適用されます。「特定の画面・条件でだけ使う SQL」が他のタイミングで誤って実行されないようにできます。

適用対象の絞り込み ​

これらを設定すると、条件を満たすリクエストにだけ拡張 SQL が適用されます。いずれも null(未設定)の場合はすべてのリクエストに適用されます。

パラメータ型説明
DeptIdListint[]適用する組織 ID のリスト
GroupIdListint[]適用するグループ ID のリスト(いずれかに所属していれば適用)
UserIdListint[]適用するユーザ ID のリスト
SiteIdListlong[]適用するサイト ID のリスト
IdListlong[]適用するレコード ID のリスト
Controllersstring[]適用するコントローラ名のリスト(例: ["items"])
Actionsstring[]適用するアクション名のリスト(例: ["create", "update"])
ColumnListstring[]適用する列名のリスト。OnSelectingColumn で特定の列にだけ SQL を適用したいときに使う

Controllers はリクエストの URL から決まるコントローラ名(小文字)です。レコードのテーブルへのアクセスは通常 "items" コントローラです。Actions はアクション名(小文字)で、レコード作成は "create"、編集画面の表示は "edit"、一覧は "index" などです。

各リストの値に -(マイナス)を付けると「その ID・名前以外」という否定条件になります。

json
"Actions": ["-index"]

この例では、index(一覧表示)以外のすべてのアクションで適用されます。1 つのリストの中で肯定値と否定値を混在させると、否定値に当たらないものはすべて適用されるため、肯定値は意味を持たなくなります。否定のみ、または肯定のみにそろえてください(判定は ExtensionUtilities.cs)。

実行タイミング ​

true にしたタイミングで CommandText が実行されます。複数を true にした場合は、それぞれのタイミングで実行されます。

レコード操作系 ​

パラメータ実行タイミング
OnCreatingレコード作成の直前(INSERT の前)
OnCreatedレコード作成の直後(INSERT の後)
OnUpdatingレコード更新の直前(UPDATE の前)
OnUpdatedレコード更新の直後(UPDATE の後)
OnUpdatingByGrid一覧画面でのインライン編集による更新の直前
OnUpdatedByGrid一覧画面でのインライン編集による更新の直後
OnDeletingレコード削除の直前(DELETE の前)
OnDeletedレコード削除の直後(DELETE の後)

OnCreating は INSERT の前に実行されるため、 は 0 に置き換わります(Rds.cs)。作成後の ID を使いたい場合は OnCreated を使います。確認したソースでは、レコード操作系で に値が入るのは OnUpdating だけで、ほかのタイミングでは空文字列になります(Rds.cs)。一括操作系・インポート系では は 0 です。

一括操作系・インポート系 ​

パラメータ実行タイミング
OnBulkUpdating一括更新の直前
OnBulkUpdated一括更新の直後
OnBulkDeleting一括削除の直前
OnBulkDeleted一括削除の直後
OnImportingインポート処理の直前
OnImportedインポート処理の直後

一覧取得系 ​

パラメータ説明
OnSelectingWhereSELECT の WHERE 句に追加する条件式(先頭に AND は書かない)。使い方は次の節を参照
OnSelectingWhereParams指定した列のフィルタが有効なときだけ適用する(文字列配列)
OnSelectingOrderBySELECT の ORDER BY 句に追加する SQL
OnSelectingOrderByParams指定した列のソートが有効なときだけ適用する(文字列配列)
OnSelectingColumnSELECT の列定義を差し替える(サブクエリとして使える)
OnSelectingColumnParams指定したビュー拡張キーが有効なときだけ適用する(文字列配列)

OnSelectingColumn は ColumnList と組み合わせると、特定の列に対してだけサブクエリを差し替えられます。たとえば "ColumnList": ["ClassA"] とすれば、ClassA 列の SELECT だけが対象になります。

権限設定画面・認証 ​

パラメータ説明
OnSelectingWherePermissionsDepts権限設定画面の組織一覧を取得するときに WHERE 句を追加する
OnSelectingWherePermissionsGroups権限設定画面のグループ一覧を取得するときに WHERE 句を追加する
OnSelectingWherePermissionsUsers権限設定画面のユーザ一覧を取得するときに WHERE 句を追加する
OnUseSecondaryAuthentication二段階認証の確認処理のときに実行する

OnSelectingWherePermissions 系を使うと、権限設定画面に表示されるユーザ・グループ・組織の一覧を「特定の組織だけ表示する」といった形で絞り込めます。

挙動 ​

Html:SQL の結果を画面に埋め込む ​

"Html": true にすると、CommandText の SQL を実行し、結果を JSON にシリアライズして <input type="hidden"> としてページの HTML に埋め込みます。コントロール ID は Name の値になります。

json
{
    "Name": "MasterData",
    "Html": true,
    "CommandText": "SELECT [Code], [Name] FROM [MasterTable] WHERE [TenantId] = @_T"
}
json
{
    "Name": "MasterData",
    "Html": true,
    "CommandText": "SELECT \"Code\", \"Name\" FROM \"MasterTable\" WHERE \"TenantId\" = @ipT"
}
json
{
    "Name": "MasterData",
    "Html": true,
    "CommandText": "SELECT `Code`, `Name` FROM `MasterTable` WHERE `TenantId` = @ipT"
}

この設定では、ページの HTML に次のような隠し項目が追加されます。

html
<input type="hidden" id="MasterData" value="[{&quot;Code&quot;:...}]">

クライアント側のスクリプト(拡張スクリプトなど)から $("#MasterData").val() で取得し、JSON.parse() で使えます。CommandText に SELECT 文を複数書いて結果セットが複数返る場合は、名前_Table・名前_Table1 のように 名前_テーブル名 形式の ID になります(UNION は結果セットが 1 つなので 名前 のままです。HtmlSql.cs)。

ログインユーザのテナント ID・組織 ID・ユーザ ID は、どの拡張 SQL にも T・D・U のパラメータとして自動で渡されます(SqlIo.cs)。パラメータ名の接頭辞は DBMS で変わり、Parameter.json の SqlParameterPrefix が空(既定)なら SQL Server は @_、PostgreSQL・MySQL は @ip です(Parameter.cs)。

値SQL ServerPostgreSQL・MySQL
テナント ID@_T@ipT
組織 ID@_D@ipD
ユーザ ID@_U@ipU

PostgreSQL で @_T と書くと、column "_t" does not exist のエラーになります。このページの SQL 例は SQL Server の書き方なので、PostgreSQL・MySQL では @ipT などに読み替えてください。テナント ID とサイト ID は別の値なので、テナントの条件にはサイト ID ではなくテナント ID のパラメータを使います。

Api:API から SQL を実行する ​

"Api": true にすると、この拡張 SQL を API から呼び出せます。エンドポイントは POST /api/extended/sql です(ExtendedController.cs。使用例は 項目ロックの解除)。/api/extensions/sql というパスは存在しません(/api/extensions は Extensions テーブルの API です)。外部から呼ぶ場合は、ほかの API と同じくリクエストボディに ApiKey を入れます。

json
{
    "Name": "MySqlQuery",
    "Params": {
        "param1": "値1"
    }
}
json
{
    "StatusCode": 200,
    "Response": {
        "Data": {
            "Table": [
                { "Col1": "...", "Col2": "..." }
            ]
        }
    }
}

Params に指定したキーと値は SQL パラメータ(@param1)としてバインドされます。外部システムからプリザンターの DB に対してカスタムクエリを安全に実行できます。Name が一致し Api: true の拡張 SQL が見つからないときは 400 になります(ExtensionUtilities.cs)。

DbUser:使う DB 接続ユーザを指定する ​

"DbUser": "Owner" を指定すると、Rds.OwnerConnectionString に設定したオーナー権限の接続文字列で SQL を実行します。Api: true と組み合わせて使い、通常の接続ユーザでは権限が足りない操作(テーブルの DDL 操作など)を行うときに使います。null(既定)の場合は通常の接続文字列を使います。

DbUser が効くのは API から呼ぶ拡張 SQL(/api/extended/sql)だけです。確認したソースで DbUser を見ているのは API 用の実行処理だけで(ExtensionUtilities.cs#L103-L112)、OnCreated などのイベントで動く拡張 SQL は DbUser を書いても通常の接続ユーザで実行されます。各接続ユーザの権限と、Linked Server などで外部 DB を読む方法は 拡張 SQL の実行ユーザと外部 DB 接続 を参照してください。

CommandText とプレースホルダー ​

CommandText には実行する SQL を書きます。次のプレースホルダーが使えます。

プレースホルダー置換される内容
現在のサイト ID(整数)
現在のコンテキスト ID(レコード ID など)
タイムスタンプ(yyyy/M/d H:m:s.fff 形式)

これらはすべて文字列置換です(ExtendedSql.cs)。確認したソースでは、Html: true と Api: true の実行経路で置き換わるのは と だけで、 は置き換わりません(HtmlSql.cs、ExtensionUtilities.cs)。整数値の にインジェクションの心配はありませんが、文字列を動的に渡したい場合は @パラメータ名 形式の SQL パラメータバインドを使います(view.OnSelectingWhere とフィルター、拡張フィールド の SqlParam を参照)。

OnSelectingWhere で一覧の条件を追加する ​

OnSelectingWhere を true にした拡張 SQL は、一覧などを取得する内部 SQL の WHERE 句に条件として自動で追加されます。追加した条件は、ほかの条件と and でつながれます(Rds.cs、SqlWhereCollection.cs)。そのため CommandText には条件式だけを書き、先頭に AND を付けません。付けると and AND ...(ほかに条件が無ければ where AND ...)となり SQL エラーになり、一覧には「この項目は並べ替えることができません」と出ます(原因と見分け方)。公式マニュアルのサンプルも参考にしてください。

text
SELECT ... FROM [Users]
WHERE [TenantId] = @_T
  AND(OnSelectingWhere で追加した条件)  ← ここに追加される

OnSelectingWhere は View.Where() メソッドから呼ばれるため、Users コントローラを含むすべてのコントローラで動作します。

TIP

サーバースクリプトの view.OnSelectingWhere を使うと、適用する拡張 SQL を動的に切り替えることもできます。

例1: ユーザの拡張項目でレコードを絞り込む ​

ユーザに使える属性は基本的に「組織」「グループ」だけですが、拡張項目で属性を足せます。ここではユーザの項目 A(ClassA)を選択肢項目「常駐場所」として拡張し、ログインユーザと同じ常駐場所のユーザが作成したレコードだけを一覧に表示します。

拡張項目の設定:

json
{
    "Users_ClassA": {
        "LabelText": "常駐場所",
        "GridEnabled": "1",
        "EditorEnabled": "1",
        "UseSearch": true,
        "ChoicesText": "01,A事業所\n02,B事業所\n03,C事業所",
        "LabelText_en": "Permanent location",
        "LabelText_zh": "常驻地",
        "LabelText_de": "Fester Standort",
        "LabelText_ko": "상주 장소",
        "LabelText_es": "Ubicación permanente",
        "LabelText_vn": "Nơi cư trú"
    }
}

拡張 SQL の設定(サイト ID 2 だけを対象にしています。環境に合わせて書き換えてください):

json
{
    "SiteIdList": [2],
    "OnSelectingWhere": true
}

SQL ファイル名は LocationFilter.json.sql です。

sql
(
    @_U IN (1) --システム管理者のユーザID
    OR [Creator] IN (
        SELECT
            [UserId]
        FROM
            [Users]
        WHERE
            [ClassA] IN (
                SELECT
                    [ClassA]
                FROM
                    [Users]
                WHERE
                    [UserId] IN (@_U)
            )
    )
)
sql
(
    @ipU IN (1) -- システム管理者のユーザID
    OR "Creator" IN (
        SELECT
            "UserId"
        FROM
            "Users"
        WHERE
            "ClassA" IN (
                SELECT
                    "ClassA"
                FROM
                    "Users"
                WHERE
                    "UserId" IN (@ipU)
            )
    )
)
sql
(
    @ipU IN (1) -- システム管理者のユーザID
    OR `Creator` IN (
        SELECT
            `UserId`
        FROM
            `Users`
        WHERE
            `ClassA` IN (
                SELECT
                    `ClassA`
                FROM
                    `Users`
                WHERE
                    `UserId` IN (@ipU)
            )
    )
)

ログインユーザ(SQL Server は @_U、PostgreSQL・MySQL は @ipU。SqlParameterPrefix が空の既定値の場合)の常駐場所を取得し、同じ常駐場所のユーザを求め、作成者(Creator)がその中に含まれるかで判定しています。作成者の物理列名は _Bases_Creator.json で確認しています。システム管理者はユーザ ID を決め打ちして全レコードを表示させていますが、サイトの権限を読み取って動的に判定することもできます(権限を取得する方法)。

例2: 特権ユーザをユーザ一覧から隠す ​

Controllers を ["users"] にすると、ユーザ管理画面の一覧にだけ条件を追加できます。特権ユーザを一覧に出さないことで、テナント管理者が特権ユーザを選択・削除できないようにします(背景はユーザ削除で使える拡張機能を参照)。

json
{
    "Name": "HidePrivilegedUsers",
    "Description": "特権ユーザをユーザ一覧から非表示にする",
    "Disabled": false,
    "Controllers": ["users"],
    "OnSelectingWhere": true,
    "CommandText": "\"Users\".\"LoginId\" NOT IN ('admin')"
}
設定項目値説明
Controllers["users"]ユーザ管理画面でのみ適用
OnSelectingWheretrueSELECT の WHERE 句に条件を追加
CommandText条件式追加する WHERE 条件(先頭に AND は付けない)

INFO

Controllers の値は小文字で書きます。プリザンターは内部でコントローラ名を小文字にして比較するため、"Users" ではなく "users" と書く必要があります。

CommandText の先頭に AND を付けると、上記のとおり本体が自動で and を付けるため構文エラーになります。先頭の AND は書きません。

複数の特権ユーザを除外するときは、NOT IN にカンマ区切りで並べます。

json
"CommandText": "\"Users\".\"LoginId\" NOT IN ('admin', 'superuser')"

PostgreSQL でもこのハードコーディング方式がそのまま使えます(識別子はダブルクォートで囲みます)。

特権ユーザのリストの持ち方 ​

特権ユーザは App_Data/Parameters/Security.json の PrivilegedUsers に LoginId のリストとして定義されていますが、サーバースクリプトからは Parameters.Security.PrivilegedUsers を参照できません。拡張 SQL 側でリストを持つ方法は次の 3 通りです。

方式メリットデメリット
NOT IN 句にハードコーディング最もシンプルSecurity.json と二重管理になる
管理テーブルを参照DB で一元管理できるテーブルの作成・メンテナンスが必要
OPENROWSET で Security.json を直接読み取りSecurity.json と完全に同期できるSQL Server 限定。同一サーバ構成が前提

WARNING

ハードコーディング方式では、Security.json の PrivilegedUsers を変えたら拡張 SQL 内のリストも合わせて更新する必要があります。

管理テーブル方式: 特権ユーザの LoginId を持つテーブルを作り、サブクエリで参照します。特権ユーザの追加・削除はテーブルの更新だけで反映され、拡張 SQL ファイルを変える必要はありません(Security.json との二重管理は残ります)。

sql
CREATE TABLE PrivilegedUserMaster (
    LoginId NVARCHAR(256) NOT NULL PRIMARY KEY
);

-- 特権ユーザを登録
INSERT INTO PrivilegedUserMaster (LoginId) VALUES ('admin');
sql
CREATE TABLE "PrivilegedUserMaster" (
    "LoginId" VARCHAR(256) NOT NULL PRIMARY KEY
);

-- 特権ユーザを登録
INSERT INTO "PrivilegedUserMaster" ("LoginId") VALUES ('admin');
sql
CREATE TABLE PrivilegedUserMaster (
    LoginId VARCHAR(256) NOT NULL PRIMARY KEY
);

-- 特権ユーザを登録
INSERT INTO PrivilegedUserMaster (LoginId) VALUES ('admin');
json
{
    "Name": "HidePrivilegedUsers",
    "Description": "特権ユーザをユーザ一覧から非表示にする(管理テーブル参照)",
    "Disabled": false,
    "Controllers": ["users"],
    "OnSelectingWhere": true,
    "CommandText": "\"Users\".\"LoginId\" NOT IN (SELECT \"LoginId\" FROM \"PrivilegedUserMaster\")"
}

OPENROWSET 方式(SQL Server 限定): OPENROWSET(BULK ...) で Security.json をテキストとして読み込み、OPENJSON で PrivilegedUsers 配列を行に展開します。Security.json との二重管理が不要になります。

事前に Ad Hoc Distributed Queries を有効にし、SQL Server のサービスアカウントに Security.json の読み取り権限を与えておきます。

sql
-- SQL Server の設定(管理者権限で実行)
EXEC sp_configure 'show advanced options', 1;
RECONFIGURE;
EXEC sp_configure 'Ad Hoc Distributed Queries', 1;
RECONFIGURE;
json
{
    "Name": "HidePrivilegedUsers",
    "Description": "特権ユーザをユーザ一覧から非表示にする(Security.json参照)",
    "Disabled": false,
    "Controllers": ["users"],
    "OnSelectingWhere": true,
    "CommandText": "[Users].[LoginId] NOT IN (SELECT j.[value] FROM OPENROWSET(BULK 'C:\\inetpub\\pleasanter\\App_Data\\Parameters\\Security.json', SINGLE_CLOB) AS f CROSS APPLY OPENJSON(f.BulkColumn, '$.PrivilegedUsers') AS j)"
}

ファイルパスは環境に合わせて変更してください。

WARNING

OPENROWSET はセキュリティ上のリスクがあるため、本番環境での使用は慎重に検討してください。SQL Server のサービスアカウントの権限は最小限に保ちます。また SQL Server とプリザンターが別サーバにある場合は、UNC パス(\\server\share\...)で指定するか、ハードコーディング方式・管理テーブル方式を使ってください。

制約事項 ​

  • API 経由の削除は防げません。 OnSelectingWhere は一覧の SELECT にしか効かないため、ユーザ ID を直接指定した DELETE /api/users/{id} は止められません。必要ならプリザンター本体の修正や、リバースプロキシ・ファイアウォールなどインフラ側での対策を検討します。
  • テナント管理者は特権ユーザの確認・編集もできなくなります。 特権ユーザの管理は、特権ユーザ自身が行うか、データベースを直接操作します。

特権ユーザ以外のユーザは、これまでどおり一覧に表示され削除もできます。

例3: 複数の値で IN 句フィルタをかける(配列を渡す) ​

拡張フィールドの SqlParam を使うと、サーバースクリプトから拡張 SQL に @パラメータ名 として値を渡せます(詳しくは view.OnSelectingWhere とフィルター)。ただし、配列をそのまま SQL パラメータとして渡すことはできません。view.Filters.MyParam に設定できるのはスカラー値(文字列・数値)だけです。

javascript
// こういうことはできない
view.Filters.MyParam = ["001", "002", "003"];

SqlParam: true の拡張フィールドの値は、ADO.NET の SQL パラメータとしてバインドされます。ADO.NET のパラメータはスカラー値(文字列・数値・日付など)を前提としており、配列や複数値のリストをそのままバインドする仕組みがないためです。

回避策:区切り文字列で渡して SQL 側で縦持ちにする ​

複数の値をカンマ区切りの文字列として渡し、SQL 側で分割して縦持ち(1 値 1 行)に変換して、IN 句のサブクエリに使います。

図を読み込み中…

RDBMS分割に使う関数説明
SQL Server(2016 以降)STRING_SPLIT(@MyParam, ',')カンマ区切りの文字列を受け取り、value 列を持つ行セットを返す
PostgreSQLunnest(string_to_array(@MyParam, ','))string_to_array で区切り文字ごとに配列にし、unnest で 1 行ずつの集合に展開する
MySQLFIND_IN_SET(列, @MyParam) > 0カンマ区切りのリストに値が含まれるかを直接判定する

実装例:複数の分類値で絞り込む ​

分類 A(ClassA)の複数の値で一覧を絞り込む例です。

まず SqlParam: true の拡張フィールドを定義します。

json
{
    "Name": "ClassAValues",
    "FieldType": "Filter",
    "SqlParam": true
}

拡張 SQL を名前指定で定義します。

json
{
    "Name": "ClassAMultiFilter",
    "SpecifyByName": true,
    "OnSelectingWhere": true
}

SQL ファイル名は App_Data/Parameters/ExtendedSqls/ClassAMultiFilter.json.sql です。

sql
"Issues"."ClassA" IN (
    SELECT [value]
    FROM STRING_SPLIT(@ClassAValues, ',')
)
sql
"Issues"."ClassA" IN (
    SELECT unnest(string_to_array(@ClassAValues, ','))
)
sql
FIND_IN_SET(`Issues`.`ClassA`, @ClassAValues) > 0

サーバースクリプト(ビュー処理時)で、絞り込みたい値を join(",") でカンマ区切りの文字列にして渡します。値のリストが空([])だと join(",") は空文字列になり、SQL Server の STRING_SPLIT は空の結果を返して一覧が 0 件になるため(STRING_SPLIT)、値がないときは view.OnSelectingWhere を設定しないようにします。

javascript
var values = ["001", "002", "003"];
if (values.length > 0) {
    view.OnSelectingWhere = "ClassAMultiFilter";
    view.Filters.ClassAValues = values.join(",");
}

注意点 ​

  • 値にカンマが含まれる場合は、SQL Server・PostgreSQL ではカンマ以外の区切り文字(タブ \t やパイプ | など)を使い、サーバースクリプト側と SQL 側の区切り文字を合わせます。MySQL の FIND_IN_SET は区切り文字がカンマ固定なので、この例はカンマを含まない値に限ります(FIND_IN_SET)。

    javascript
    view.Filters.ClassAValues = values.join("|");
    sql
    "Issues"."ClassA" IN (
        SELECT [value]
        FROM STRING_SPLIT(@ClassAValues, '|')
    )
  • SQL インジェクションは発生しません。 @ClassAValues は ADO.NET の SQL パラメータとしてバインドされ、STRING_SPLIT はパラメータの値を区切るだけで SQL として実行しないためです。

ユーザ削除で使える拡張機能 ​

プリザンター標準では、ユーザ削除時に「削除対象が特権ユーザかどうか」を検証しません。ユーザ削除は UserUtilities.Delete で次の順に処理されます。

  1. UserModel の生成(対象ユーザの情報を取得)
  2. UserValidators.OnDeleting による検証
  3. 検証 OK なら userModel.Delete を実行
  4. 削除結果に応じてレスポンスを返す

UserValidators.OnDeleting の検証内容は次のとおりで、対象が特権ユーザかどうかは見ていません。テナント管理者に削除権限があれば、特権ユーザも削除できてしまいます。

検証項目内容
API 検証API 経由の場合、API アクセス権を確認
ShowProfiles 検証ShowProfiles が無効かつ特権ユーザでない場合は拒否
削除権限context.CanDelete(ss) で削除権限を確認
ReadOnly対象ユーザが読み取り専用でないことを確認

また、削除時に動く拡張機能の多くは Users コントローラでは動きません。

拡張機能Users での動作理由
BeforeDelete 拡張サーバースクリプト動作しないUserModel.Delete が SetByBeforeDeleteServerScript を呼ばず、直接 SQL で削除するため
OnDeleting 拡張 SQL(OnDeletingExtendedSqls)動作しないコード生成テンプレートで "ItemOnly": "1" となっており、レコード系モデル(Results、Issues、Wikis、Dashboards)だけが対象
OnSelectingWhere 拡張 SQL動作するView.Where() から呼ばれるため

BeforeDelete 拡張サーバースクリプトに対応しているモデルは次のとおりです。

モデルBeforeDelete 対応
ResultModel(レコード)対応
IssueModel(レコード)対応
WikiModel(Wiki)非対応(OnDeletingExtendedSqls には対応)
DashboardModel(ダッシュボード)非対応(OnDeletingExtendedSqls には対応)
UserModel(ユーザ)非対応
GroupModel(グループ)非対応
DeptModel(組織)非対応

このため、拡張機能だけでユーザ削除を制限するには、例2のように OnSelectingWhere で一覧から除外する方法しかありません。API 経由の削除まで含めて完全に制限するには、UserValidators.OnDeleting に特権ユーザのチェックを加えるなど、ソースコードレベルの対応が必要です(改修の設計は 特権ユーザの削除・編集の制限(改修案))。

OnUpdated で添付ファイルのリネームを実現する ​

添付ファイル項目は、アップロード時のファイル名がそのまま保持され、画面からはリネームできません。拡張スクリプトと拡張 SQL だけで「リネーム」ボタンを追加する方法を示します。テーブルごとの設定が不要なので、全テーブルにまとめて適用できます(1.5.4.0 が対象)。

書き換える必要がある 2 か所 ​

添付ファイル項目(AttachmentsA〜AttachmentsZ、Enterprise Edition の項目拡張では Attachments001〜 も)は、レコードの列に JSON 配列で保存されています。

json
[
  {
    "Guid": "31DE9B93C26342D186646E723D7EB8E1",
    "Name": "report_202703.xlsx",
    "Size": 11904,
    "HashCode": "xjugT/ALXg+G5dcjgs6CTPZ7DAeFDTXnZ8kavI8AQZY="
  }
]

Name が画面に表示されるファイル名、Guid が添付ファイル本体の ID です(/binaries/{Guid}/download で取得できます)。

一方、ダウンロード時のファイル名は、FileContentResults.cs の Bytes メソッドが Binaries.FileName を fileDownloadName に渡しているため、モデル JSON の Name ではなく Binaries.FileName が使われます。

csharp
return new ResponseFile(
    fileContent: new MemoryStream(bin, false),
    fileDownloadName: dataRow.String("FileName"),
    contentType: contentType);

そのため、リネームでは次の両方を書き換えます。

  1. モデル JSON の Name(編集画面・一覧画面の表示名)
  2. Binaries.FileName と Binaries.Title(ダウンロード時のファイル名)

処理の流れ ​

図を読み込み中…

モデル JSON の更新は $p.apiUpdate だけで完結し、Binaries の同期は本体の標準フローで動く OnUpdated に任せます。拡張機能から SQL を直接呼ぶ必要はありません。

拡張 SQL(Binaries の同期) ​

json
{
    "Description": "Syncs Binaries.FileName/Title with the attachment Name in the model JSON.",
    "Controllers": ["items"],
    "OnUpdated": true,
    "CommandText": "-- loaded from .json.sql"
}

Controllers: ["items"] でテーブル系画面の更新だけを対象にし、OnUpdated で更新直後に実行します。

SQL ファイル名は ExtendedSqls/SyncAttachmentFileName.json.sql です。

sql
WITH AttachmentColumns AS (
    SELECT v.col
    FROM [Results] r
    CROSS APPLY (VALUES (r.[AttachmentsA]), (r.[AttachmentsB]), (r.[AttachmentsC]), (r.[AttachmentsD]), (r.[AttachmentsE]), (r.[AttachmentsF]), (r.[AttachmentsG]), (r.[AttachmentsH]), (r.[AttachmentsI]), (r.[AttachmentsJ]), (r.[AttachmentsK]), (r.[AttachmentsL]), (r.[AttachmentsM]), (r.[AttachmentsN]), (r.[AttachmentsO]), (r.[AttachmentsP]), (r.[AttachmentsQ]), (r.[AttachmentsR]), (r.[AttachmentsS]), (r.[AttachmentsT]), (r.[AttachmentsU]), (r.[AttachmentsV]), (r.[AttachmentsW]), (r.[AttachmentsX]), (r.[AttachmentsY]), (r.[AttachmentsZ])) v(col)
    WHERE r.[SiteId] = {{SiteId}} AND r.[ResultId] = {{Id}}
    UNION ALL
    SELECT v.col
    FROM [Issues] i
    CROSS APPLY (VALUES (i.[AttachmentsA]), (i.[AttachmentsB]), (i.[AttachmentsC]), (i.[AttachmentsD]), (i.[AttachmentsE]), (i.[AttachmentsF]), (i.[AttachmentsG]), (i.[AttachmentsH]), (i.[AttachmentsI]), (i.[AttachmentsJ]), (i.[AttachmentsK]), (i.[AttachmentsL]), (i.[AttachmentsM]), (i.[AttachmentsN]), (i.[AttachmentsO]), (i.[AttachmentsP]), (i.[AttachmentsQ]), (i.[AttachmentsR]), (i.[AttachmentsS]), (i.[AttachmentsT]), (i.[AttachmentsU]), (i.[AttachmentsV]), (i.[AttachmentsW]), (i.[AttachmentsX]), (i.[AttachmentsY]), (i.[AttachmentsZ])) v(col)
    WHERE i.[SiteId] = {{SiteId}} AND i.[IssueId] = {{Id}}
), AttachmentNames AS (
    SELECT UPPER(j.[Guid]) AS [Guid], j.[Name]
    FROM AttachmentColumns a
    CROSS APPLY OPENJSON(COALESCE(NULLIF(a.col, ''), '[]'))
    WITH ([Guid] nvarchar(64) '$.Guid', [Name] nvarchar(1024) '$.Name') j
)
UPDATE b
SET b.[FileName] = n.[Name], b.[Title] = n.[Name]
FROM [Binaries] b
INNER JOIN AttachmentNames n ON UPPER(b.[Guid]) = n.[Guid]
WHERE b.[FileName] <> n.[Name] OR b.[Title] <> n.[Name];
sql
update "Binaries"
set "FileName" = sub."Name",
    "Title" = sub."Name"
from (
    select
        upper(jsonb_array_elements(coalesce(nullif(t.col, '')::jsonb, '[]'::jsonb))->>'Guid') as "Guid",
        jsonb_array_elements(coalesce(nullif(t.col, '')::jsonb, '[]'::jsonb))->>'Name' as "Name"
    from (
        select unnest(array[
            "AttachmentsA","AttachmentsB","AttachmentsC","AttachmentsD","AttachmentsE",
            "AttachmentsF","AttachmentsG","AttachmentsH","AttachmentsI","AttachmentsJ",
            "AttachmentsK","AttachmentsL","AttachmentsM","AttachmentsN","AttachmentsO",
            "AttachmentsP","AttachmentsQ","AttachmentsR","AttachmentsS","AttachmentsT",
            "AttachmentsU","AttachmentsV","AttachmentsW","AttachmentsX","AttachmentsY",
            "AttachmentsZ"
        ]) as col
        from "Results"
        where "SiteId" = {{SiteId}} and "ResultId" = {{Id}}
        union all
        select unnest(array[
            "AttachmentsA","AttachmentsB","AttachmentsC","AttachmentsD","AttachmentsE",
            "AttachmentsF","AttachmentsG","AttachmentsH","AttachmentsI","AttachmentsJ",
            "AttachmentsK","AttachmentsL","AttachmentsM","AttachmentsN","AttachmentsO",
            "AttachmentsP","AttachmentsQ","AttachmentsR","AttachmentsS","AttachmentsT",
            "AttachmentsU","AttachmentsV","AttachmentsW","AttachmentsX","AttachmentsY",
            "AttachmentsZ"
        ]) as col
        from "Issues"
        where "SiteId" = {{SiteId}} and "IssueId" = {{Id}}
    ) as t
) as sub
where upper("Binaries"."Guid") = sub."Guid"
  and ("Binaries"."FileName" <> sub."Name" or "Binaries"."Title" <> sub."Name");
sql
WITH AttachmentColumns AS (
    SELECT JSON_ARRAY(r.`AttachmentsA`, r.`AttachmentsB`, r.`AttachmentsC`, r.`AttachmentsD`, r.`AttachmentsE`, r.`AttachmentsF`, r.`AttachmentsG`, r.`AttachmentsH`, r.`AttachmentsI`, r.`AttachmentsJ`, r.`AttachmentsK`, r.`AttachmentsL`, r.`AttachmentsM`, r.`AttachmentsN`, r.`AttachmentsO`, r.`AttachmentsP`, r.`AttachmentsQ`, r.`AttachmentsR`, r.`AttachmentsS`, r.`AttachmentsT`, r.`AttachmentsU`, r.`AttachmentsV`, r.`AttachmentsW`, r.`AttachmentsX`, r.`AttachmentsY`, r.`AttachmentsZ`) AS cols
    FROM `Results` r
    WHERE r.`SiteId` = {{SiteId}} AND r.`ResultId` = {{Id}}
    UNION ALL
    SELECT JSON_ARRAY(i.`AttachmentsA`, i.`AttachmentsB`, i.`AttachmentsC`, i.`AttachmentsD`, i.`AttachmentsE`, i.`AttachmentsF`, i.`AttachmentsG`, i.`AttachmentsH`, i.`AttachmentsI`, i.`AttachmentsJ`, i.`AttachmentsK`, i.`AttachmentsL`, i.`AttachmentsM`, i.`AttachmentsN`, i.`AttachmentsO`, i.`AttachmentsP`, i.`AttachmentsQ`, i.`AttachmentsR`, i.`AttachmentsS`, i.`AttachmentsT`, i.`AttachmentsU`, i.`AttachmentsV`, i.`AttachmentsW`, i.`AttachmentsX`, i.`AttachmentsY`, i.`AttachmentsZ`) AS cols
    FROM `Issues` i
    WHERE i.`SiteId` = {{SiteId}} AND i.`IssueId` = {{Id}}
), AttachmentNames AS (
    SELECT UPPER(j.`Guid`) AS `Guid`, j.`Name`
    FROM AttachmentColumns a
    CROSS JOIN JSON_TABLE(a.cols, '$[*]' COLUMNS (col LONGTEXT PATH '$')) c
    CROSS JOIN JSON_TABLE(COALESCE(NULLIF(c.col, ''), '[]'), '$[*]' COLUMNS (
        `Guid` VARCHAR(64) PATH '$.Guid',
        `Name` VARCHAR(1024) PATH '$.Name'
    )) j
)
UPDATE `Binaries` b
INNER JOIN AttachmentNames n ON UPPER(b.`Guid`) = n.`Guid`
SET b.`FileName` = n.`Name`, b.`Title` = n.`Name`
WHERE b.`FileName` <> n.`Name` OR b.`Title` <> n.`Name`;
  • 更新中のレコードを SiteId・Id のプレースホルダで絞り込み、JSON 配列を SQL Server は OPENJSON、PostgreSQL は jsonb_array_elements、MySQL は JSON_TABLE で展開して Guid → Name の対応表にしています。
  • union all で Results・Issues の両方を見ているので、記録テーブル・期限付きテーブルのどちらでも動きます。
  • この拡張 SQL はレコード更新のたびに実行されるため、"FileName" <> sub."Name" のように差分があるときだけ UPDATE して無駄な更新を避けています。

INFO

SQL Server の例は OPENJSON が使える互換性レベル 130 以上、MySQL の例は CTE と JSON_TABLE が使える 8.0 以降を対象とします。列には空文字列・NULL または有効な JSON 配列が入っている前提です。

拡張スクリプト(リネームボタン) ​

App_Data/Parameters/ExtendedScripts/ に拡張スクリプトとして置きます。

js
(function () {
  /**
   * 添付ファイル項目の表示テキスト「ファイル名 (サイズ)」からファイル名のみを抽出する。
   */
  function ra_splitNameAndSize(text) {
    var match = String(text).match(/^([\s\S]*?)([\s\u3000]*[((][^()()]+[))])\s*$/);
    return match
      ? { name: match[1], suffix: match[2] }
      : { name: text, suffix: '' };
  }

  /**
   * 添付ファイル項目の要素から、サイトID・レコードID・項目名・ファイルGUIDを取得する。
   */
  function ra_resolveContext($item) {
    var $container = $item.closest('.control-attachments-items');
    var containerId = $container.attr('id') || '';
    var columnName = containerId.replace(/\.items$/, '');
    var $hidden = $('[id$="_' + columnName + '"]').filter('input[type=hidden]').first();
    if (!$hidden.length) return null;
    var prefix = $hidden.attr('id').replace('_' + columnName, '');
    var recordId = $('#' + prefix + '_' + (prefix === 'Issues' ? 'IssueId' : 'ResultId')).val()
      || $p.getControl(prefix === 'Issues' ? 'IssueId' : 'ResultId');
    return {
      siteId: $p.siteId(),
      recordId: recordId,
      columnName: columnName,
      guid: ($item.attr('id') || '').toUpperCase(),
      hidden: $hidden
    };
  }

  /**
   * リネーム処理本体。
   */
  function ra_rename(item) {
    var $item = $(item);
    var $link = $item.find('a.file-name').filter(function () {
      return $(this).text().length > 0;
    }).last();
    var parts = ra_splitNameAndSize($link.text());
    var currentName = parts.name;

    var newName = window.prompt('新しいファイル名を入力してください', currentName);
    if (newName === null) return;
    newName = newName.replace(/[\\/:*?"<>|]/g, '_').trim();
    if (!newName || newName === currentName) return;

    var ctx = ra_resolveContext($item);
    if (!ctx || !ctx.recordId) {
      alert('レコードIDが取得できませんでした(新規作成中はリネームできません)');
      return;
    }

    // 現在のリストを取得して該当ファイルだけ Name を更新する
    var list;
    try { list = JSON.parse(ctx.hidden.val() || '[]'); } catch (e) { list = []; }
    var found = false;
    list.forEach(function (f) {
      if (String(f.Guid || '').toUpperCase() === ctx.guid) {
        f.Name = newName;
        found = true;
      }
    });
    if (!found) {
      alert('対象ファイルが見つかりませんでした。画面を再読込してから試してください。');
      return;
    }

    var hash = {};
    hash[ctx.columnName] = list;
    $p.apiUpdate({
      id: ctx.recordId,
      data: { AttachmentsHash: hash, ApiVersion: 1.1 },
      done: function () {
        // 編集中のフォームにも反映し、画面表示を更新する
        ctx.hidden.val(JSON.stringify(list));
        $link.text(newName + parts.suffix);
        $p.clearMessage();
        $p.setMessage('#Message', JSON.stringify({
          Css: 'alert-success',
          Text: 'ファイル名を「' + newName + '」に変更しました'
        }));
      },
      fail: function () {
        $p.clearMessage();
        $p.setMessage('#Message', JSON.stringify({
          Css: 'alert-error',
          Text: 'ファイル名の変更に失敗しました'
        }));
      }
    });
  }

  /**
   * 添付ファイル項目にリネームボタンを追加する。
   */
  function ra_decorate() {
    $('.control-attachments-items .control-attachments-item.already-attachments').each(function () {
      var $item = $(this);
      if ($item.find('.rename-file').length) return;
      var $btn = $('<div class="ui-icon ui-icon-pencil rename-file" title="ファイル名を変更"></div>')
        .css({ cursor: 'pointer', display: 'inline-block' })
        .on('click', function (e) {
          e.preventDefault();
          e.stopPropagation();
          ra_rename($item[0]);
        });
      var $delete = $item.find('.delete-file').first();
      if ($delete.length) {
        $delete.before($btn);
      } else {
        $item.append($btn);
      }
    });
  }

  // 編集画面表示時に装飾する
  $p.events.on_editor_load = function () { ra_decorate(); };

  // 追加アップロード後などにも再装飾する
  $(document).ajaxComplete(function () { setTimeout(ra_decorate, 0); });
})();

スクリプトのポイント:

  • 関数名に ra_(Rename Attachment)を付け、他の拡張スクリプトとの名前衝突を避けています。
  • ra_resolveContext で、クリックされた要素からサイト ID・レコード ID・項目名(AttachmentsA など)・ファイル GUID・フォームの hidden 要素をまとめて取得します。
  • 入力されたファイル名の \ / : * ? " < > | は _ に置換します(本体の Attachment.OnDeserialized の Files.ValidateFileName と同じ方針)。
  • Name だけを書き換えた JSON を AttachmentsHash として $p.apiUpdate に渡します。Added / Deleted を付けていないので、本体はファイルを書き込まず、モデル JSON だけが更新されます。
  • 更新成功後は、編集中フォームの hidden 値と画面上のリンクテキストも同期させ、画面と DB の食い違いを防ぎます。

WARNING

新規作成画面(未保存のレコード)にはレコード ID がないため、リネームできるのは保存済みの添付ファイルだけです。

動作確認 ​

  1. 編集画面を開くと、アップロード済みファイルの行の削除ボタンの左に、鉛筆アイコン(ui-icon-pencil)のリネームボタンが表示されます。
  2. クリックすると現在のファイル名が入った入力ダイアログが出ます。新しい名前で OK すると、画面のファイル名が即座に切り替わります。
  3. ダウンロードすると、保存ダイアログに新しいファイル名が表示されます。
  4. PostgreSQL なら次のクエリで Binaries 側も変わっていることを確認できます。
sql
select b.[Guid], b.[FileName], b.[Title]
from [Binaries] b
where b.[Guid] = '31de9b93c26342d186646e723d7eb8e1';
sql
select b."Guid", b."FileName", b."Title"
from "Binaries" b
where b."Guid" = '31de9b93c26342d186646e723d7eb8e1';
sql
select b.`Guid`, b.`FileName`, b.`Title`
from `Binaries` b
where b.`Guid` = '31de9b93c26342d186646e723d7eb8e1';

関連ページ ​

変更履歴

第12版操作別の実行順と拡張SQLの併用・同期実行の注意点を追加
第11版記事の確認版を繰り返す表現を整理する
第10版拡張 SQL のフィルタと添付名同期を3種類のDBMSで説明
第9版ログイン情報のパラメータ名が SQL Server(@_T など)と PostgreSQL・MySQL(@ipT など)で違うことを明記
第8版拡張 SQL で一覧が「この項目は並べ替えることができません」になる原因と、先頭の AND を取り除く改修メモを追加
第7版管理機能の権限・グループの入れ子・トップ画面とテナント・api/users の実行権限の解説と、権限グループ・特権ユーザ保護の改修・設計メモを追加
第6版本文から元記事や以前の版への言及を除き、正しい動作だけを書く形に整理
第5版「拡張機能」「API」を 1.5.8.1 のソースで検証して修正
第4版元記事への言及を整理し、必要なコードをページに収録。検索機能に一覧の検索と絞り込みを追加
第3版記事のファイル名に並び順の番号を付け、元記事リンクを frontmatter の sources に移行
第2版拡張 HTML・拡張スタイル・拡張スクリプト・拡張フィールドの仕組みを追加し、拡張 SQL の設定パラメータを拡充
第1版「拡張機能」「API」セクションの記事を追加