DB メンテナンスと SQL での情報取得
データベースを直接扱う運用作業のうち、次の 4 つをまとめます。共通の作業は SQL Server・PostgreSQL・MySQL の例を掲載し、孤立ユーザーの SID の修復は SQL Server 専用の手順として説明します。
| やりたいこと | 方法 |
|---|---|
| 添付ファイルだけ削除したときに残るデータを消す | 拡張 SQL で Binaries_deleted から削除 |
リストア後に Implem.CodeDefiner.dll _rds がログインエラーになる | 孤立ユーザーの SID を使ってログインを作り直す |
| サイトごとの通知設定を一覧したい | SiteSettings の JSON を DBMS ごとの JSON 関数で展開 |
| Wiki をリンク項目のマスタにしたとき、SQL で項目を扱いたい | Wiki 本文を行・列に分解し、位置に応じて列に戻す |
Binaries_deleted に残る添付ファイルを削除する
INFO
BinaryStorage.json の Provider が Rds の場合にのみ有効な方法です。Local で運用している場合は対応できません。
なぜ残るのか
添付ファイルは Binaries テーブルに 1 ファイル 1 レコードで格納され、実体は Bin 列にバイナリで入っています(SQL Server では image 型。Binaries_Bin.json)。API でやり取りするときは BASE64 ですが、DB には BASE64 の文字列ではなくバイナリのまま保存されます。添付ファイルを削除すると、そのレコードは Binaries_deleted テーブルに移動します。
既定では、移動後のレコードは何にも使われません。添付ファイルの詳細設定「履歴に存在するファイルは削除しない」を ON にすれば履歴からアクセスできますが、既定は OFF なので、DB に残っているのに触る手段がないデータ になり、DB 肥大化の原因になります。
レコードを削除してゴミ箱からも削除すれば紐づくデータは全削除されますが、「添付ファイルだけ削除してレコードは残す」「削除してもゴミ箱からは消さない」運用は多いはずです(ゴミ箱はサイトの管理権限がないと操作できません)。なお 1.5.8.1 には、BackgroundService.json の DeleteTrashBox を true にすると、DeleteTrashBoxRetentionPeriod(既定 90 日)を過ぎたゴミ箱のデータを DeleteTrashBoxTime の時刻に削除する機能があります(既定は無効。BackgroundService.json)。この処理は Items・Issues・Results・Sites・Wikis などの _deleted テーブルに加えて Binaries_deleted も、更新日時が保持期間より古い行を物理削除します(DeleteTrashBoxTimer.cs)。一定期間後に消えればよいなら、下の拡張 SQL の代わりにこの設定を使えます。
拡張 SQL で削除する
レコード更新後に、そのレコードの Binaries_deleted のデータを削除する拡張 SQL を、公式マニュアル に従ってファイルとして配置します。
{
"Description": "レコード更新後に削除済み添付ファイルをレコードごと消す",
"OnUpdated": true,
"CommandText": "-- Write an arbitrary SQL statement."
}SQL ファイル名は DeletedBinariesOnUpdatedDelete.json.sql です。
DELETE FROM
[Binaries_deleted]
WHERE
[ReferenceId] = {{Id}}DELETE FROM
"Binaries_deleted"
WHERE
"ReferenceId" = {{Id}}DELETE FROM
`Binaries_deleted`
WHERE
`ReferenceId` = {{Id}}SQL の中の二重波括弧で囲んだ Id は、実行時にレコード ID に置き換わります(ExtendedSql.cs)。この例は全サイトで実行されます。サイト ID で対象を限定する方法は前述のマニュアルを参照してください。「履歴に存在するファイルは削除しない」を ON にしているサイトを除外したい場合は、Sites テーブルの SiteSettings と Results_history / Issues_history テーブルを組み合わせて条件を組めば対応できます(RDBMS 側で JSON を扱う必要があります)。
TIP
SQL Server の Express 版は DB サイズに 10GB の上限があるため、特に効果があります。ただし添付ファイルを多く扱う場合は、どうしても Rds モードで運用したい場合を除き、Local モードでの運用がおすすめです。
リストア後に _rds が実行できないとき
サーバー移行などで DB のバックアップを別環境にリストアした後、Implem.CodeDefiner.dll _rds を実行すると 'Implem.Pleasanter_Owner' はログインできませんでした。 と表示される場合の対処です。原因は Implem.Pleasanter_Owner と Implem.Pleasanter_User が 孤立ユーザー になっていることです。
INFO
SQL Server 限定の方法です。MySQL や PostgreSQL では使えません。2024/12/01 時点で、公式マニュアルの方法には今後非推奨となる方法が含まれていたため、こちらの方法を紹介しています。
1. 孤立ユーザーの SID を調べる
SSMS などで次を実行します。Implem.Pleasanter_Owner と Implem.Pleasanter_User の 2 行が表示されるので、それぞれの SID を控えます。
USE [master];
SELECT dp.type_desc, dp.sid, dp.name AS user_name
FROM sys.database_principals AS dp
LEFT JOIN sys.server_principals AS sp
ON dp.sid = sp.sid
WHERE sp.sid IS NULL
AND dp.authentication_type_desc = 'INSTANCE';2. 同じ SID でログインを作成する
控えた SID を指定してログインを新規作成し、リストアした DB のユーザーと紐づけます。パスワードは Rds.json に記載しているものを使います。
USE [master];
CREATE LOGIN [Implem.Pleasanter_Owner]
WITH PASSWORD = <Rds.jsonに記載しているImplem.Pleasanter_Ownerのパスワード>,
SID = <取得したImplem.Pleasanter_OwnerのSID>;
CREATE LOGIN [Implem.Pleasanter_User]
WITH PASSWORD = <Rds.jsonに記載しているImplem.Pleasanter_Userのパスワード>,
SID = <取得したImplem.Pleasanter_UserのSID>;その後、再度 Implem.CodeDefiner.dll _rds を実行します。
通知設定を SQL で取り出す
JSON の配列展開には SQL Server の OPENJSON、PostgreSQL の jsonb_array_elements、MySQL 8.0 の JSON_TABLE を使います。SQL Server の OPENJSON は互換性レベル 130 以上、後述の順序付き STRING_SPLIT(第 3 引数が 1 の例)は SQL Server 2022 以降が必要です。
サイトの設定は [Sites] テーブルの [SiteSettings] カラムに JSON で格納されており、通知設定は $.Notifications に配列で入っています。権限や設定の棚卸しに使えます。
SELECT
*
FROM
[Sites]
CROSS APPLY
OPENJSON([SiteSettings], '$.Notifications')SELECT s.*, n.value
FROM "Sites" s
CROSS JOIN LATERAL jsonb_array_elements(s."SiteSettings"::jsonb->'Notifications') n(value);SELECT s.*, JSON_EXTRACT(s.`SiteSettings`, CONCAT('$.Notifications[', n.ord - 1, ']')) AS value
FROM `Sites` s
CROSS JOIN JSON_TABLE(s.`SiteSettings`, '$.Notifications[*]' COLUMNS (
ord FOR ORDINALITY
)) n;通知設定の JSON の中身
定義は Notification.cs にあります。NULL の項目(使われていない項目)は JSON に格納されません。
| プロパティ | 内容 |
|---|---|
Id | ID |
Type | 通知の種類(下表) |
Prefix / Subject | タイトル前のプレフィックス / タイトル |
Address / CcAddress / BccAddress | 送信先(Chatwork などの場合は URL)/ CC / BCC |
Token | トークン(Chatwork などの場合) |
MethodType | HttpClient のメソッド(1: Get、2: Post、3: Put、4: Delete) |
Encoding / MediaType / Headers / Body | HttpClient のエンコード / メディアタイプ / ヘッダ / ボディ |
UseCustomFormat / Format | カスタムデザインの使用有無 / フォーマット |
MonitorChangesColumns | 変更を検出するカラム(配列) |
BeforeCondition / AfterCondition | 変更前 / 変更後に使うビュー ID(未設定は 0) |
Expression | 変更前・変更後の結合(1: Or、2: And) |
AfterCreate / AfterUpdate / AfterDelete / AfterCopy / AfterBulkUpdate / AfterBulkDelete / AfterImport | 実行条件(作成後・更新後・削除後・コピー後・一括更新後・一括削除後・インポート後) |
Disabled | 無効 |
Type の値 | 種類 |
|---|---|
| 1 | |
| 2 | Slack |
| 3 | ChatWork |
| 4 | Line |
| 5 | LineGroup |
| 6 | Teams |
| 7 | RocketChat |
| 8 | InCircle |
| 9 | HttpClient |
| 10 | LineWorks |
Type の値は 1.5.8.1 のソースの Notification.Types で確認しています(Notification.cs)。
項目ごとに展開するクエリ
SELECT
[SiteId],
[Title],
JSON_VALUE(value, '$.Id') AS [Id],
JSON_VALUE(value, '$.Type') AS [Type],
JSON_VALUE(value, '$.Prefix') AS [Prefix],
JSON_VALUE(value, '$.Subject') AS [Subject],
JSON_VALUE(value, '$.Address') AS [Address],
JSON_VALUE(value, '$.CcAddress') AS [CcAddress],
JSON_VALUE(value, '$.BccAddress') AS [BccAddress],
JSON_VALUE(value, '$.Token') AS [Token],
JSON_VALUE(value, '$.MethodType') AS [MethodType],
JSON_VALUE(value, '$.Encoding') AS [Encoding],
JSON_VALUE(value, '$.MediaType') AS [MediaType],
JSON_VALUE(value, '$.Headers') AS [Headers],
JSON_VALUE(value, '$.UseCustomFormat') AS [UseCustomFormat],
JSON_VALUE(value, '$.Format') AS [Format],
JSON_VALUE(value, '$.Body') AS [Body],
JSON_QUERY(value, '$.MonitorChangesColumns') AS [MonitorChangesColumns],
JSON_VALUE(value, '$.BeforeCondition') AS [BeforeCondition],
JSON_VALUE(value, '$.AfterCondition') AS [AfterCondition],
JSON_VALUE(value, '$.Expression') AS [Expression],
JSON_VALUE(value, '$.AfterCreate') AS [AfterCreate],
JSON_VALUE(value, '$.AfterUpdate') AS [AfterUpdate],
JSON_VALUE(value, '$.AfterDelete') AS [AfterDelete],
JSON_VALUE(value, '$.AfterCopy') AS [AfterCopy],
JSON_VALUE(value, '$.AfterBulkUpdate') AS [AfterBulkUpdate],
JSON_VALUE(value, '$.AfterBulkDelete') AS [AfterBulkDelete],
JSON_VALUE(value, '$.AfterImport') AS [AfterImport],
JSON_VALUE(value, '$.Disabled') AS [Disabled]
FROM
[Sites]
CROSS APPLY
OPENJSON([SiteSettings], '$.Notifications')SELECT
s."SiteId",
s."Title",
n.value->>'Id' AS "Id",
n.value->>'Type' AS "Type",
n.value->>'Prefix' AS "Prefix",
n.value->>'Subject' AS "Subject",
n.value->>'Address' AS "Address",
n.value->>'CcAddress' AS "CcAddress",
n.value->>'BccAddress' AS "BccAddress",
n.value->>'Token' AS "Token",
n.value->>'MethodType' AS "MethodType",
n.value->>'Encoding' AS "Encoding",
n.value->>'MediaType' AS "MediaType",
n.value->>'Headers' AS "Headers",
n.value->>'UseCustomFormat' AS "UseCustomFormat",
n.value->>'Format' AS "Format",
n.value->>'Body' AS "Body",
n.value->'MonitorChangesColumns' AS "MonitorChangesColumns",
n.value->>'BeforeCondition' AS "BeforeCondition",
n.value->>'AfterCondition' AS "AfterCondition",
n.value->>'Expression' AS "Expression",
n.value->>'AfterCreate' AS "AfterCreate",
n.value->>'AfterUpdate' AS "AfterUpdate",
n.value->>'AfterDelete' AS "AfterDelete",
n.value->>'AfterCopy' AS "AfterCopy",
n.value->>'AfterBulkUpdate' AS "AfterBulkUpdate",
n.value->>'AfterBulkDelete' AS "AfterBulkDelete",
n.value->>'AfterImport' AS "AfterImport",
n.value->>'Disabled' AS "Disabled"
FROM "Sites" s
CROSS JOIN LATERAL jsonb_array_elements(s."SiteSettings"::jsonb->'Notifications') n(value);SELECT
s.`SiteId`,
s.`Title`,
JSON_UNQUOTE(JSON_EXTRACT(s.`SiteSettings`, CONCAT('$.Notifications[', n.ord - 1, '].Id'))) AS `Id`,
JSON_UNQUOTE(JSON_EXTRACT(s.`SiteSettings`, CONCAT('$.Notifications[', n.ord - 1, '].Type'))) AS `Type`,
JSON_UNQUOTE(JSON_EXTRACT(s.`SiteSettings`, CONCAT('$.Notifications[', n.ord - 1, '].Prefix'))) AS `Prefix`,
JSON_UNQUOTE(JSON_EXTRACT(s.`SiteSettings`, CONCAT('$.Notifications[', n.ord - 1, '].Subject'))) AS `Subject`,
JSON_UNQUOTE(JSON_EXTRACT(s.`SiteSettings`, CONCAT('$.Notifications[', n.ord - 1, '].Address'))) AS `Address`,
JSON_UNQUOTE(JSON_EXTRACT(s.`SiteSettings`, CONCAT('$.Notifications[', n.ord - 1, '].CcAddress'))) AS `CcAddress`,
JSON_UNQUOTE(JSON_EXTRACT(s.`SiteSettings`, CONCAT('$.Notifications[', n.ord - 1, '].BccAddress'))) AS `BccAddress`,
JSON_UNQUOTE(JSON_EXTRACT(s.`SiteSettings`, CONCAT('$.Notifications[', n.ord - 1, '].Token'))) AS `Token`,
JSON_UNQUOTE(JSON_EXTRACT(s.`SiteSettings`, CONCAT('$.Notifications[', n.ord - 1, '].MethodType'))) AS `MethodType`,
JSON_UNQUOTE(JSON_EXTRACT(s.`SiteSettings`, CONCAT('$.Notifications[', n.ord - 1, '].Encoding'))) AS `Encoding`,
JSON_UNQUOTE(JSON_EXTRACT(s.`SiteSettings`, CONCAT('$.Notifications[', n.ord - 1, '].MediaType'))) AS `MediaType`,
JSON_UNQUOTE(JSON_EXTRACT(s.`SiteSettings`, CONCAT('$.Notifications[', n.ord - 1, '].Headers'))) AS `Headers`,
JSON_UNQUOTE(JSON_EXTRACT(s.`SiteSettings`, CONCAT('$.Notifications[', n.ord - 1, '].UseCustomFormat'))) AS `UseCustomFormat`,
JSON_UNQUOTE(JSON_EXTRACT(s.`SiteSettings`, CONCAT('$.Notifications[', n.ord - 1, '].Format'))) AS `Format`,
JSON_UNQUOTE(JSON_EXTRACT(s.`SiteSettings`, CONCAT('$.Notifications[', n.ord - 1, '].Body'))) AS `Body`,
JSON_EXTRACT(s.`SiteSettings`, CONCAT('$.Notifications[', n.ord - 1, '].MonitorChangesColumns')) AS `MonitorChangesColumns`,
JSON_UNQUOTE(JSON_EXTRACT(s.`SiteSettings`, CONCAT('$.Notifications[', n.ord - 1, '].BeforeCondition'))) AS `BeforeCondition`,
JSON_UNQUOTE(JSON_EXTRACT(s.`SiteSettings`, CONCAT('$.Notifications[', n.ord - 1, '].AfterCondition'))) AS `AfterCondition`,
JSON_UNQUOTE(JSON_EXTRACT(s.`SiteSettings`, CONCAT('$.Notifications[', n.ord - 1, '].Expression'))) AS `Expression`,
JSON_UNQUOTE(JSON_EXTRACT(s.`SiteSettings`, CONCAT('$.Notifications[', n.ord - 1, '].AfterCreate'))) AS `AfterCreate`,
JSON_UNQUOTE(JSON_EXTRACT(s.`SiteSettings`, CONCAT('$.Notifications[', n.ord - 1, '].AfterUpdate'))) AS `AfterUpdate`,
JSON_UNQUOTE(JSON_EXTRACT(s.`SiteSettings`, CONCAT('$.Notifications[', n.ord - 1, '].AfterDelete'))) AS `AfterDelete`,
JSON_UNQUOTE(JSON_EXTRACT(s.`SiteSettings`, CONCAT('$.Notifications[', n.ord - 1, '].AfterCopy'))) AS `AfterCopy`,
JSON_UNQUOTE(JSON_EXTRACT(s.`SiteSettings`, CONCAT('$.Notifications[', n.ord - 1, '].AfterBulkUpdate'))) AS `AfterBulkUpdate`,
JSON_UNQUOTE(JSON_EXTRACT(s.`SiteSettings`, CONCAT('$.Notifications[', n.ord - 1, '].AfterBulkDelete'))) AS `AfterBulkDelete`,
JSON_UNQUOTE(JSON_EXTRACT(s.`SiteSettings`, CONCAT('$.Notifications[', n.ord - 1, '].AfterImport'))) AS `AfterImport`,
JSON_UNQUOTE(JSON_EXTRACT(s.`SiteSettings`, CONCAT('$.Notifications[', n.ord - 1, '].Disabled'))) AS `Disabled`
FROM `Sites` s
CROSS JOIN JSON_TABLE(s.`SiteSettings`, '$.Notifications[*]' COLUMNS (
ord FOR ORDINALITY
)) n;これを WITH 句に入れて応用できます。たとえば Chatwork 宛ての通知の、通知先ルーム ID を取り出すクエリは次のとおりです(Address の URL を / で分割した 6 番目)。
WITH [Notifications] AS (
SELECT
[SiteId],
[Title],
JSON_VALUE(value, '$.Id') AS [Id],
JSON_VALUE(value, '$.Type') AS [Type],
JSON_VALUE(value, '$.Address') AS [Address]
-- 他の列は上のクエリと同じ
FROM
[Sites]
CROSS APPLY
OPENJSON([SiteSettings], '$.Notifications')
)
SELECT
[SiteId],
[Title],
[Id] AS [NotificationId],
value AS [NotificationRoomId]
FROM
[Notifications]
CROSS APPLY
STRING_SPLIT ([Address], '/', 1)
WHERE
[Type] IN (3)
AND ordinal IN (6)SELECT s."SiteId", s."Title",
n.value->>'Id' AS "NotificationId",
split_part(n.value->>'Address', '/', 6) AS "NotificationRoomId"
FROM "Sites" s
CROSS JOIN LATERAL jsonb_array_elements(s."SiteSettings"::jsonb->'Notifications') n(value)
WHERE n.value->>'Type' = '3'
AND array_length(string_to_array(n.value->>'Address', '/'), 1) >= 6;SELECT s.`SiteId`, s.`Title`, n.`Id` AS `NotificationId`,
SUBSTRING_INDEX(SUBSTRING_INDEX(n.`Address`, '/', 6), '/', -1) AS `NotificationRoomId`
FROM `Sites` s
CROSS JOIN JSON_TABLE(s.`SiteSettings`, '$.Notifications[*]' COLUMNS (
`Id` INT PATH '$.Id',
`Type` INT PATH '$.Type',
`Address` TEXT PATH '$.Address'
)) n
WHERE n.`Type` = 3
AND CHAR_LENGTH(n.`Address`) - CHAR_LENGTH(REPLACE(n.`Address`, '/', '')) >= 5;Wiki のリンク項目マスタを SQL で取り出す
リンク項目 のマスタには Wiki も使えます。キーに ReferenceId 以外を使える、項目が少なければ表示のパフォーマンスがよい、といった利点がある一方、SQL で直接扱いにくいのが難点です。次のクエリで Wiki の本文を行・列に分解できます。列の名前は Choice.cs に合わせています。
WITH [Masters] AS (
SELECT
[Row].[RowOrdinal],
ordinal AS [ColOrdinal],
VALUE AS [ColValue]
FROM
(
SELECT
VALUE AS [RowValue],
ordinal AS [RowOrdinal]
FROM
[dbo].[Wikis]
CROSS APPLY STRING_SPLIT([Body], CHAR (10), 1)
WHERE
[SiteId] = @SiteId
) AS [Row]
CROSS APPLY STRING_SPLIT([RowValue], ',', 1)
)
SELECT
MAX(
CASE [Masters].[ColOrdinal]
WHEN 1 THEN [Masters].[ColValue]
END
) AS [Value],
MAX(
CASE [Masters].[ColOrdinal]
WHEN 2 THEN [Masters].[ColValue]
END
) AS [Text],
MAX(
CASE [Masters].[ColOrdinal]
WHEN 3 THEN [Masters].[ColValue]
END
) AS [TextMini],
MAX(
CASE [Masters].[ColOrdinal]
WHEN 4 THEN [Masters].[ColValue]
END
) AS [CssClass],
MAX(
CASE [Masters].[ColOrdinal]
WHEN 5 THEN [Masters].[ColValue]
END
) AS [Style]
FROM
[Masters]
GROUP BY
[Masters].[RowOrdinal]WITH "Masters" AS (
SELECT r.ord AS "RowOrdinal", c.ord AS "ColOrdinal", c.value AS "ColValue"
FROM "Wikis" w
CROSS JOIN LATERAL unnest(string_to_array(w."Body", chr(10)))
WITH ORDINALITY r(value, ord)
CROSS JOIN LATERAL unnest(string_to_array(r.value, ','))
WITH ORDINALITY c(value, ord)
WHERE w."SiteId" = @SiteId
)
SELECT
MAX(CASE "ColOrdinal" WHEN 1 THEN "ColValue" END) AS "Value",
MAX(CASE "ColOrdinal" WHEN 2 THEN "ColValue" END) AS "Text",
MAX(CASE "ColOrdinal" WHEN 3 THEN "ColValue" END) AS "TextMini",
MAX(CASE "ColOrdinal" WHEN 4 THEN "ColValue" END) AS "CssClass",
MAX(CASE "ColOrdinal" WHEN 5 THEN "ColValue" END) AS "Style"
FROM "Masters"
GROUP BY "RowOrdinal"
ORDER BY "RowOrdinal";WITH `Masters` AS (
SELECT r.ord AS `RowOrdinal`, c.ord AS `ColOrdinal`, c.value AS `ColValue`
FROM `Wikis` w
CROSS JOIN JSON_TABLE(
CONCAT('[', REPLACE(JSON_QUOTE(w.`Body`), CONCAT(CHAR(92), 'n'), '","'), ']'),
'$[*]' COLUMNS (ord FOR ORDINALITY, value LONGTEXT PATH '$')
) r
CROSS JOIN JSON_TABLE(
CONCAT('[', REPLACE(JSON_QUOTE(r.value), ',', '","'), ']'),
'$[*]' COLUMNS (ord FOR ORDINALITY, value LONGTEXT PATH '$')
) c
WHERE w.`SiteId` = @SiteId
)
SELECT
MAX(CASE `ColOrdinal` WHEN 1 THEN `ColValue` END) AS `Value`,
MAX(CASE `ColOrdinal` WHEN 2 THEN `ColValue` END) AS `Text`,
MAX(CASE `ColOrdinal` WHEN 3 THEN `ColValue` END) AS `TextMini`,
MAX(CASE `ColOrdinal` WHEN 4 THEN `ColValue` END) AS `CssClass`,
MAX(CASE `ColOrdinal` WHEN 5 THEN `ColValue` END) AS `Style`
FROM `Masters`
GROUP BY `RowOrdinal`
ORDER BY `RowOrdinal`;@SiteId は変数定義するか、サイト ID に直接書き換えて使います。PostgreSQL は unnest と WITH ORDINALITY、MySQL は JSON_TABLE の FOR ORDINALITY で行・列の位置を保持します。この例は改行とカンマを単純に分割するため、値にエスケープしたカンマやバックスラッシュを含まないマスタを対象とします。
たとえば次の内容の Wiki は、Value・Text・TextMini・CssClass の列を持つ 5 行として取得できます。
100,保留,保留,status-new
130,伝票作成,伝票作成,status-preparation
140,伝票確認,伝票確認,status-preparation
150,発注依頼,発注依頼,status-preparation
900,部材発注済,部材発注済,status-rejected関連ページ
- 拡張 SQL の活用
- 内部で動く SQL 文
- マルチテナント運用
- データベースのテーブル構成(ベース・_deleted・_history) —
_deleted・_historyの作られ方と、残ったリンク・権限の行を消すDeleteUnusedRecord - SQL Server の行サイズの見積もりと現状診断 — 列定義・保存データ量・行外領域を読み取りだけで確かめる PowerShell スクリプト