2024年3月1日金曜日

対話グリッドで数独を実装する

Oracle APEXの対話グリッドで数独を実装してみました。問題を入れると解答を見つけるというものではなく対話グリッドで9x9のマス目を作成して、そのマス目に入っている数値が数独のルールに従っているかどうか、検証を行なうアプリケーションです。

作成したアプリケーションは以下のように動作します。


以下より、アプリケーションの作成手順を紹介します。

最初に以下のパッケージSUDOKUを作成します。数独の検証ロジックを実装しています。また、数独の9x9のマトリックスの数値はパッケージ変数G_MATRIX: (コロン)で区切った文字列として保存します。G_ERRORSには重複している値を保存します。

create or replace package sudoku
as
G_MATRIX apex_t_number; /* 9x9のセルを保持する。 */
G_ERRORS apex_t_number; /* エラーになったセルだけを保持する。 */
/**
* G_MATRIXを初期化する。
*/
procedure initialize(
p_matrix in varchar2
);
/**
* 検証として呼び出す。
*/
function initialize(
p_matrix in varchar2
)
return boolean;
/**
* 9x9のマトリックスすべてを検証する。
*/
procedure validate_all;
/**
*  検証として呼び出す。
*/
function validate_all
return boolean;
/**
* セルの結果を確認する。
*/
function is_valid(
p_column_position in integer
,p_row_position in integer
)
return boolean;
/**
* 9x9の配列をアイテムに保存する。
*/
procedure save(
p_item in out varchar2
);
end sudoku;
/
create or replace package body sudoku
as
CO_SEPARATOR constant varchar2(1) := ':';
G_IS_INITIALIZED boolean; /* G_MATRIXが有効 */
G_IS_VALIDATED boolean; /* G_ERRORSが有効 */
G_IS_SAVED boolean; /* すでに保存済み */
/**
* 数独の9個の数字に重複がないことを確認する。
* p_valは検証対象となる9個の数値。p_locはその数値のsudoku.G_MATRIX内の位置を保持する。
*/
procedure validate_i(
p_loc in apex_t_number
,p_val in apex_t_number
)
as
l_valid boolean := true;
begin
for c in (
select column_value from table(p_val) where column_value is not null
group by column_value having count(column_value) > 1
)
loop
l_valid := false;
for i in 1..9
loop
if p_val(i) = to_number(c.column_value) then
G_ERRORS(p_loc(i)) := p_val(i);
end if;
end loop;
end loop;
end validate_i;
/**
* 行の検証。nは行番号。
*/
procedure validate_h(
n in integer
)
as
l_val apex_t_number;
l_loc apex_t_number;
l_idx integer;
begin
for i in 1..9
loop
l_idx := ((n - 1)*9) + i;
apex_string.push(l_loc, l_idx);
apex_string.push(l_val, G_MATRIX(l_idx));
end loop;
validate_i(l_loc, l_val);
end validate_h;
/**
* 列の検証。nは列番号。
*/
procedure validate_v(
n in integer
)
as
l_val apex_t_number;
l_loc apex_t_number;
l_idx integer;
begin
for i in 0..8
loop
l_idx := n + (i * 9);
apex_string.push(l_loc, l_idx);
apex_string.push(l_val, G_MATRIX(l_idx));
end loop;
validate_i(l_loc, l_val);
end validate_v;
/**
* 3x3のマトリックスの検証。
*/
procedure validate_s(
n in integer
)
as
l_val apex_t_number;
l_loc apex_t_number;
l_idx integer;
begin
if n < 4 then
l_idx := 1 + ((n - 1) * 3);
elsif n < 7 then
l_idx := 28 + ((n - 4) * 3);
else
l_idx := 55 + ((n - 7) * 3);
end if;
for i in 1..3
loop
for j in 0..2
loop
apex_string.push(l_loc, (l_idx + j));
apex_string.push(l_val, G_MATRIX(l_idx + j));
end loop;
l_idx := l_idx + 9;
end loop;
validate_i(l_loc, l_val);
end validate_s;
/**
* G_MATRIXを初期化する。
*/
procedure initialize(
p_matrix in varchar2
)
as
begin
if not G_IS_INITIALIZED then
G_MATRIX := apex_string.split_numbers(p_matrix, CO_SEPARATOR);
G_IS_INITIALIZED := true;
end if;
end initialize;
/**
* 検証として呼び出す。
*/
function initialize(
p_matrix in varchar2
)
return boolean
as
begin
initialize(p_matrix);
return true;
end initialize;
/**
* 9x9のマトリックス全体の検証。
*/
procedure validate_all
as
begin
if not G_IS_VALIDATED then
for i in 1..9
loop
validate_h(i);
validate_v(i);
validate_s(i);
end loop;
G_IS_VALIDATED := true;
end if;
end validate_all;
/**
* 検証として呼び出す。
*/
function validate_all
return boolean
as
begin
validate_all();
return true;
end validate_all;
/**
* セルの結果を確認する。
*/
function is_valid(
p_column_position in integer
,p_row_position in integer
)
return boolean
as
l_loc integer;
begin
if not G_IS_VALIDATED then
validate_all();
end if;
l_loc := ((p_row_position - 1) * 9) + p_column_position;
return (G_ERRORS(l_loc) is null);
end is_valid;
/**
* 9x9の配列をアイテムに保存する。
*/
procedure save(
p_item in out varchar2
)
as
begin
if not G_IS_SAVED then
p_item := apex_string.join(G_MATRIX, CO_SEPARATOR);
G_IS_SAVED := true;
end if;
end save;
/**
* パッケージ変数 G_MATRIX, G_ERRORSの初期化。
*/
begin
for i in 1..81
loop
apex_string.push(G_MATRIX, null);
apex_string.push(G_ERRORS, null);
end loop;
G_IS_INITIALIZED := false;
G_IS_VALIDATED := false;
G_IS_SAVED := false;
end sudoku;
/
view raw sudoku.sql hosted with ❤ by GitHub
アプリケーション作成ウィザードを起動します。

アプリケーションの名前数独とします。デフォルトで追加されているホーム・ページを削除し、代わりに対話グリッドのページを追加します。


対話グリッドの名前数独、ソースとしてSQL問合せを選択し以下のSELECT文を記述します。編集を許可を選択します。

select id, c1, c2, c3, c4, c5, c6, c7, c8, c9, 'X' fu from
(
select * from
(select ceil(rownum / 9) as id, mod(rownum,9) as column_name, column_value from table(apex_string.split_numbers(:P1_MATRIX,':')))
pivot
(max(column_value) for column_name in (1 as C1,2 as C2,3 as C3,4 as C4,5 as C5,6 as C6,7 as C7,8 as C8,0 as C9))
order by id
)


アプリケーションの作成をクリックして、これから開発するアプリケーションを作成します。


ページ・デザイナ対話グリッドのページを開きます。

アプリケーション作成ウィザードが対話グリッドの列IDを主キーとして認識していません。列IDを選択し、識別タイプ非表示ソース主キーオンに変更します。


FUを選択し識別タイプ非表示にし、設定保護された値オフにします。この列は変更のない対話グリッドの行をサーバーに送信するために、ページの送信前に動的アクションから値を変更するために使用します。利用者はこの列を見る必要がないため非表示にし、動的アクションからの更新を許可するために保護された値オフにしています。


対話グリッドのプロパティの属性を開きます。

編集実行可能な操作から行の追加行の削除外します。可能な操作は行の更新に限ります。

ツールバーより検索列の選択検索フィールド、それと保存ボタンを外します。対話グリッドの保存からは、呼び出される検証プロセス編集可能リージョンとして(保存ボタンのある対話グリッドである)数独が紐づけられているものに限られます。また、呼び出される検証プロセスは、変更された行毎に複数回呼び出されます。今回は対話グリッドには紐づかず、送信時に一度だけ呼び出される検証プロセスの実行が必要なため、別にボタンを作成してページの送信を行います。


対話グリッドをJavaScriptから扱うため、詳細静的IDとしてSUDOKUを設定します。


9x9の配列に入力した数値を保存するページ・アイテムを作成します。

識別名前P1_MATRIXタイプテキスト・フィールドです。ラベル配列とします。


ページ・アイテムP1_MATRIXを初期化するプロセスを作成します。

レンダリング前ヘッダーの前プロセスを作成します。識別名前P1_MATRIXの初期化タイプコードの実行とします。ソースPL/SQLコードとして以下を記述します。

if :P1_MATRIX is null then
:P1_MATRIX := apex_string.join(sudoku.G_MATRIX, ':');
else
sudoku.G_MATRIX := apex_string.split_numbers(:P1_MATRIX,':');
end if;


検証を実行するボタンを作成します。

作成したボタンはページ・アイテムP1_MATRIXの下に配置します。識別ボタン名VALIDATEラベル検証とします。外観ホットオンテンプレート・オプションWidthStretchSpacing BottomLargeを選択します。

動作アクションはデフォルトのページの送信とします。


左ペインでプロセス・ビューを開き、検証を作成します。

識別名前G_MATRIXの初期化とします。検証編集可能リージョンは未指定(- 選択 -のまま)にし、ページ送信時に1回だけ実行するようにします。タイプを選び、PL/SQL式として以下を記述します。

sudoku.initialize(:P1_MATRIX)

パッケージ変数G_MATRIXにページ・アイテムP1_MATRIXの値を設定しています。

ファンクションsudoku.initializeは検証として実行されますが、必ずtrueを返します。そのため検証に失敗することはありません。しかし、エラー・メッセージは必須であるため適当な値(今回は . ピリオド)を設定しておきます。

サーバー側の条件ボタン押下時VALIDATEを設定します。


検証常に実行というフラグがあります。今回はこれをオフにしています。


この設定はボタンの動作検証の実行に対応しています。常に実行オンのときは、ボタンの検証の実行オフであっても、検証が実行されます。


続いて実行される検証を作成します。

識別名前G_MATRIXを更新とします。対話グリッドで変更された行を受信し、パッケージ変数G_MATRIXを更新します。結果として数独の配列の値が最新の状態になります。この検証も常に成功します。

検証編集可能リージョン数独とします。この検証は対話グリッドで変更された行の数だけ繰り返し実行されます。タイプとしてファンクション本体(ブールを返す)を選択し、PL/SQLファンクション本体として以下を記述します。

declare
l_idx integer;
begin
l_idx := (:ID - 1) * 9;
apex_debug.info('SUDOKU: Update row_id = %s', :ID);
sudoku.G_MATRIX(l_idx + 1) := :C1;
sudoku.G_MATRIX(l_idx + 2) := :C2;
sudoku.G_MATRIX(l_idx + 3) := :C3;
sudoku.G_MATRIX(l_idx + 4) := :C4;
sudoku.G_MATRIX(l_idx + 5) := :C5;
sudoku.G_MATRIX(l_idx + 6) := :C6;
sudoku.G_MATRIX(l_idx + 7) := :C7;
sudoku.G_MATRIX(l_idx + 8) := :C8;
sudoku.G_MATRIX(l_idx + 9) := :C9;
return true;
end;
エラー・メッセージおよびサーバー側の条件は、先ほどの検証と同様に設定します。


続いて実行される検証を作成します。

識別名前数独の検証とします。検証編集可能リージョンは未指定(- 選択 -のまま)にし、ページ送信時に1回だけ実行するようにします。タイプを選び、PL/SQL式として以下を記述します。

sudoku.validate_all()

数独の配列を検証します。検証結果はパッケージ変数G_ERRORSに保存し、このファンクション自体はつねにtrueを返します。

エラー・メッセージおよびサーバー側の条件は、先ほどの検証と同様に設定します。


検証結果を画面に反映させる検証を作成します。

識別名前C1の検証とします。検証編集可能リージョンとして数独を設定します。この検証は対話グリッドが送信する行ごとに実行されます。タイプを選択し、PL/SQL式として以下を記述します。

sudoku.is_valid(1, :ID)

第1引数列の位置第2引数行の位置です。その位置にある値が数独のルールに違反しているときにfalseが返されます。

この検証結果は画面に表示します。エラー・メッセージ重複しています。とします。表示位置フィールド上でインライン表示関連付けられた列C1を設定します。エラー・メッセージは、その対話グリッド上のフィールドに表示されます。

サーバー側の条件ボタン押下時VALIDATEを設定します。


検証サーバー側の条件実行スコープの設定があります。作成済および変更済の行またはすべての送信済の行の2通りを選ぶことができますが、リージョンが対話グリッドの場合、対話グリッド自体が作成済および変更済の行しか送信しないため、この設定に意味はありません。このような設定になっている理由については、下記のFR-2608に説明があります。

エンハンスメント・リクエストがAPEX IdeasのFR-2608 Interactive Grid: Execution Scope = "All Submitted Rows" should submit all rows, not just new/modified rows として上がっており、ステータスが将来実装予定(ROADMAP)になっています。


作成した検証を重複させ、検証C2の検証を作成します。名前PL/SQL式関連付けられた列1が割り当たっている部分を2に変更します。


同様の作業を列C9までを対象として繰り返します。

最終的に列の検証が9つ、全部で12の検証が作成されます。


プロセス数独 - 対話グリッド・データの保存を選択し、対話グリッドのデータをページ・アイテムP1_MATRIXに保存するように変更します。

識別タイプコードの実行に変更し、編集可能リージョンを未選択(- 選択 -に戻す)にします。ソースPL/SQLコードとして以下を記述します。

sudoku.save(:P1_MATRIX);

成功メッセージ検証できました。とします。サーバー側の条件ボタン押下時VALIDATEを設定します。


ここで一旦、アプリケーションの動作を確認してみます。

同じ列で数値が重複している場合は、両方のフィールドともにエラーが通知されます。


しかし行が異なる場合は、エラーが通知されない場合があります。


数独の配列G_MATRIXは初期のデータをページ・アイテムP1_MATRIXから取り出し、対話グリッドから送信された値で更新しているため、9x9の配列のデータをすべて保持しています。しかし、検証C1の検証からC9の検証までは、対話グリッドが送信する行、つまり変更された行だけを対象に検証が呼び出されます。結果として、エラー自体は検出されていても(パッケージ変数G_ERRORSには保存されている)、変更がされていない行ではエラーが通知されません。

検証を実行するためには、変更の有無にかかわらずすべての行をサーバーに送信する必要があります。

Oracle Corporationに所属しており、対話グリッドを開発したJohn Snydersさんが、彼のブログで対話グリッドの検証について記事を書かれています。

Interactive Grid Validation

この記事の中で紹介されているAPEXアプリケーションIG Validateの、EMP Validate 6(ページ番号)にページ送信前に変更のされていない行を見つけて非表示の列を更新する(ことにより送信対象にする)JavaScriptの実装が含まれています。

この実装を導入します。

左ペインで動的アクション・ビューを開き、動的アクションを作成します。

識別名前IGの全行を更新するとします。タイミングイベントカスタムを選択し、カスタム・イベントapexpagesubmitを設定します。選択タイプJavaScript式を選択し、JavaScript式としてapex.gPageContext$を設定します。


TRUEアクションとしてJavaScriptコードの実行を選択し、設定コードとして以下を記述します。

let model = apex.region("SUDOKU").call( "getViews" ).grid.model;
model.forEach( (rec, index, id) => {
let meta = model.getRecordMetadata( id );
if ( !meta.agg ) { // ignore aggregate records
if ( !meta.deleted && !meta.updated && !meta.inserted ) {
model.setValue( rec, "FU", "" );
}
}
} );


以上でアプリケーションは完成です。今度はすべての行が送信されているため、数値が重複しているフィールドがすべてエラーになります。


対話グリッドの検証については、Matthew Mulvaneyさんによる以下のブログ記事も参考になります。

Interactive Grid duplicate values – Learn How to prevent a common problem using a Zero-JavaScript approach

今回作成したAPEXアプリケーションのエクスポートを以下に置きました。
https://github.com/ujnak/apexapps/blob/master/exports/sudoku-validate.zip

対話グリッドのみで実装した版のエクスポートは以下です。
https://github.com/ujnak/apexapps/blob/master/exports/sudoku-validate2.zip

Oracle APEXのアプリケーション作成の参考になれば幸いです。