2022年4月28日木曜日
Angular ユニットテスト エラー( NullInjectorError: No provider for Router )の対応。
突然ですが、Angularでユニットテストしてますか?
CLIでコンポーネントを作ると、「xxxxxx.component.spec.ts」が作成されますが、
「そのまま何も変えない(ユニットテスト使わない) or 邪魔だから消す」と、なっているかもしれないですね・・・・
まあ、Angularのユニットテスト、割と難しいので、気持ちは分かります。
という事で、Angularでユニットテストを書いていく上で、良く出るエラーの対処方法を記載していこうと思います。
今回はDI関連のエラーです。
NullInjectorError: R3InjectorError(DynamicTestModule)[Router -> Router]: NullInjectorError: No provider for Router!みたいなエラーが出てしまった場合の対応ですね。
メッセージそのままですが、Router絡みのエラーであることが分かります。
Angularでは、DI可能なクラスであれば、コンポーネントのコンストラクタに定義する事で、DIして使用可能になります。
[hoge.component.ts]
import { Router } from '@angular/router';
// (中略)
constructor(private router: Router) {}
// (中略)
onClickBack(): void {
// 「戻る」ボタンを押したら、「Top画面」に遷移する
this.router.navigateByUrl('/hoge/top');
}
・・・つまりテストクラス側でもDIをする必要があるって事ですね
[hoge.component.spec.ts]
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { HogeComponent } from './hoge.component';
describe('HogeComponent', () => {
let component: HogeComponent;
let fixture: ComponentFixture<HogeComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [HogeComponent],
imports: [
RouterTestingModule,
],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(HogeComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
って感じになります。デフォルト(CLIによって作成された状態)から、
import { RouterTestingModule } from '@angular/router/testing';
と、
imports: [
RouterTestingModule,
],
を追加しています。先ほどのエラー(NullInjectorError)を無くすだけであれば、実は上記でOKです。
ただ、当然テストケースは作らないといけないので、ついでにやってみましょう
[hoge.component.spec.ts]
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
// Routerをimport
import { Router } from '@angular/router';
import { HogeComponent } from './hoge.component';
describe('HogeComponent', () => {
let component: HogeComponent;
let fixture: ComponentFixture<HogeComponent>;
// 全テストケースで利用出来るように、ここで定義
let router: Router;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [HogeComponent],
imports: [
RouterTestingModule,
],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(HogeComponent);
// DI後のインスタンスを取得
router = TestBed.inject(Router);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('ボタンアクションテスト', () => {
it('戻るボタン押下時 ホーム画面へ遷移する事', () => {
// navigateByUrl処理のモックを作成
const navigateByUrlSpy = spyOn(router, 'navigateByUrl');
// テスト対象のメソッドを呼び出す
component.onClickBack();
// navigateByUrlが、'/hoge/top'を引数として呼ばれているか確認
expect(navigateByUrlSpy).toHaveBeenCalledWith('/hoge/top');
});
});
});
ポイントとしては、DIしたクラスをテスト側で使用する為には、
router = TestBed.inject(Router);
が必要、というところです。実際のテストの部分は、プログラム内のコメントを参照して頂ければと思いますが、流れ的に、
① spyOn で、navigateByUrl のモックを作成
② コンポーネント側の onClickBack を呼び出す
③ expect の toHaveBeenCalledWith で、指定の引数で navigateByUrl が呼ばれたかを判定
となっています。
ちょっと長くなってしまったので、その他のDI関連のテストエラーは別途記事にしようと思います。
2022年4月22日金曜日
【C#】モーダル内に非同期でデータを表示させたかった話【ajax】
オフィス狛 開発担当のnagoです。
今回は、最近初めて使用したC#(ASP.NET Core MVC)で、
なるほどなぁと思ったモーダル実装についてまとめたいと思います。
DBはPostgreSQL、ビューにはBootstrapを使用しました。
<やりたかったこと>
親画面からボタンクリック
↓
親画面とは別のcshtmlファイルに記述したモーダルを開きつつ、
DBから持ってきたデータをモーダル内に表示させる
<できたもの>
コンサートなどのチケット購入サイト風サンプルを作成してみました。
・Topページ
↓詳細ボタンを押下したら
こんなかんじで表示。
・モーダル
データはこんなかんじ。
処理全体の流れとしては、
①親画面にモーダルのcshtmlを入れ込むためのdivを置いておく
②ボタンが押されたら、ajaxで非同期でコントローラにデータ取得用パラメータを渡す
③コントローラでデータ取得
④取得したデータを成形してビューモデルにセット
⑤コントローラからPertialViewでjsにcshtmlとビューモデルを返却する
⑥モーダル表示
というかんじで作ってみました。
<ソース>
・TopController.cs
using Microsoft.AspNetCore.Mvc;
using DotNetTest.Models;
namespace DotNetTest.Controllers
{
public class TopController : Controller
{
private readonly DotNetTestContext _context;
public TopController(DotNetTestContext context){
this._context = context;
}
[HttpGet]
public IActionResult Top() {
return View(this._context.performance);
}
}
}
・ModalController.cs
using Microsoft.AspNetCore.Mvc;
using DotNetTest.Models;
using System.Linq;
namespace DotNetTest.Controllers
{
public class ModalController : Controller
{
private readonly DotNetTestContext _context;
public ModalController(DotNetTestContext context){
this._context = context;
}
[HttpPost]
public IActionResult ShowModal(ModalViewModel viewModel) {
try
{
if ( viewModel.id != string.Empty && _context.performance != null) {
if( Int32.TryParse( viewModel.id, out int intId )) {
// レコード取得
var performance = _context.performance.SingleOrDefault(p => p.id == intId);
if (performance != null) {
viewModel.name = performance.name;
viewModel.date = performance.date.ToString("yyyy年MM月dd日 HH:mm");
viewModel.place = performance.place;
viewModel.description = performance.description;
viewModel.errorMessage = string.Empty;
// ビューとビューモデルを返却
return PartialView("Modal", viewModel);
}
}
}
// エラーの場合はエラーメッセージ入りビューモデルを返却
return Json(viewModel);
}
catch (System.Exception)
{
throw;
}
}
}
}
・DotNetTestContext.cs
using Microsoft.EntityFrameworkCore;
using DotNetTest.Models;
namespace DotNetTest.Models
{
public class DotNetTestContext: DbContext
{
public DotNetTestContext(DbContextOptions options) : base(options)
{
}
public DbSet performance { get; set; } = null!;
}
}
・Performance.cs
namespace DotNetTest.Models
{
public class performance
{
public int id { get; set; }
public string name { get; set; } = string.Empty;
public DateTime date { get; set; }
public string place { get; set; } = string.Empty;
public string description { get; set; } = string.Empty;
}
}
・ModalViewModel.cs
using Microsoft.AspNetCore.Mvc;
namespace DotNetTest.Models
{
[BindProperties]
public class ModalViewModel
{
public string id { get; set; } = string.Empty;
public string name { get; set; } = string.Empty;
public string date { get; set; } = string.Empty;
public string place { get; set; } = string.Empty;
public string description { get; set; } = string.Empty;
public string errorMessage { get; set; } = "エラーが発生しました";
}
}
・Top.cshtml
@model IEnumerable<DotNetTest.Models.performance>
@{
ViewData["Title"] = "Top";
}
<!doctype html>
<html lang="ja" >
<head>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" >
</head>
<body>
<table>
<tr>
<td colspan="2">
<p class="error_message" id="error_message"></p>
</td>
</tr>
@foreach(var item in Model)
{
<tr>
<td>
<p>・ @item.name @item.date.ToString("yyyy年M月d日 HH:mm")〜</p>
</td>
<td class="button_td">
<button type="button" class="btn btn-primary" data-toggle="modal" value="@item.id" onclick="openModal(this.value);">
詳細
</button>
</td>
</tr>
}
</table>
<div id="modal_base"></div>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script>
<script src="~/js/site.js" asp-append-version="true"></script>
</body>
</html>
・Modal.cshtml
@using DotNetTest.Models
@model ModalViewModel
<!-- Modal -->
<div class="modal fade" id="modal" tabindex="-1" aria-labelledby="modalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">@Model.name</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">
<p>
公演日時:@Model.date 〜<br>
会場:@Model.place<br>
説明:@Model.description
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary">
予約
</button>
</div>
</div>
</div>
</div>
・site.js
function openModal(id) {
$('#error_message').val('');
// コントローラ名/メソッド名
const url = new URL(window.location.href) + 'Modal/ShowModal';
// パラメータ成形
let form = new FormData();
form.append('id', id);
$.ajax({
url: url,
type: 'POST',
processData: false,
contentType: false,
data: form
}).done(function(data){
const data_stringify = JSON.stringify(data);
const data_json = JSON.parse(data_stringify);
if(data_json.errorMessage != null) {
$('#error_message').append(data_json.errorMessage);
} else {
// 親画面に置いておいたdivに、コントローラから受け取ったモーダルのcshtmlをセットする
$('#modal_base').html(data);
// モーダル表示
$('#modal_base').find('.modal').modal('show');
}
});
}
親画面と子画面(モーダル)のcshtmlを別ファイルで管理したかったのと、
jsファイルも別ファイルで管理したかったということがあり、苦戦したのですが、
これで思い通りの動きに出来た時はすごく嬉しかったです。
もし同じようなことで躓いた方がいらしたら、参考になれば幸いです。
参考URL:
https://docs.microsoft.com/ja-jp/dotnet/api/system.web.mvc.controller.partialview?view=aspnet-mvc-5.2
2022年3月31日木曜日
要素同士の余白に使える!gapプロパティが便利
こんにちは、オフィス狛 デザイン部のSatoです。
Internet Explorerシリーズのサポートが今年6月15日終わりますね。
便利なCSSプロパティが「モダンブラウザでは問題ないけれどIEで非対応だった!」と焦るシチュエーションがなくなり、モダンブラウザで使えるさまざまなCSS要素がIEの存在を気にせず使えるようになると思うと、個人的にはかなりありがたいです!
IEを気にしなくて良くなり、気軽に使えるようになったCSS要素で「要素をタイル状に横並びにする」レイアウトをCSSで実装する際に使える「gapプロパティ」を使ってみた所、とても便利でしたので今回のブログでご紹介します。
gapプロパティは要素と要素の間を指定するプロパティです。
marginに近いですが、要素の有無に関係なく余白を指定できるmarginと違い、gapプロパティは要素と要素の隙間を指定できます。
今まで「840pxの横幅の要素の中に、要素を3つずつ横並びで表示する、要素と要素の間は4px」といったレイアウトを作成する際余白を開けるために、要素の数が決まっている際はjustify-content: space-between;で並べたり…、要素の数に増減がある際はjustify-content: space-between;を使わずmarginで2番目の要素だけに余白をつけたり…。
などと私の場合考えることが多かったです。
レスポンシブサイトでのスマホビュー時のレイアウト変更にも少し手間がかかっていました。
しかし、gapプロパティを使うと余白をデザインの際に横に並べる数が変わった時などに変更する数値や箇所がわかりやすい+要素が右寄せで表示+レスポンシブの際のコード変更箇所が減る…などなど良いこと尽くめです。
しかもgapプロパティは上下右左個別に指定可能なんです。とってもありがたいです。
See the Pen 要素を並べて表示 by sato (@officekoma_sato) on CodePen.
👆デモを作成してみました。
HTML&CSSも見れますので、参考にしてみてください。
今後はグリッドレイアウトなどと一緒に使うことで、色々な可能性が広がりそうですね。
今回ご紹介したgapプロパティはリニューアルしたオフィス狛のWebサイトのこまちゃん紹介ページでも使用しています。
よろしければ、リニューアルしたオフィス狛のWebサイトも見ていってください。
2022年2月24日木曜日
【JavaScript】メールテンプレート内のプレースホルダーを独自に式展開(置換)する方法。
■はじめに
今回は、私が関わったプロジェクトで「ユーザがメールテンプレートを自由に編集できるようにしたい!」という要望を受けて、その実現方法を検討・実装した際のお話です。要件実現にあたって使用したのがJavaScriptの「replace」という関数なのですが、この関数が非常に使える子で...ふるえました(,,゚Д゚)。 ←初心者感丸出しなのと語彙力(笑)
今後も活用する場面がありそうなので、自分の理解向上と備忘録としてコチラにまとめます。
■今回の要件整理
最初に話したとおり、今回の要望はメールテンプレートをユーザが自由に編集・管理したいというものです。また、この要件には「メールの宛先によって一部の内容(名前とか、契約内容とか)はシステムで可変となるようにしたい」という要望も含まれています。なので、ざっくりこんな感じの要件です。
[要件箇条書き]
- ①メールテンプレートは外部に保存可能とし、ユーザが自由に編集できるようにして欲しい
- ②ユーザが可変指定した箇所は、システムで自動的に置き換えて欲しい
可変にしたいと言うのは、メールテンプレート内にプレースホルダーを埋め込みたいというイメージです。 そのため、イメージとしては、以下のようにユーザがメールテンプレートを定義したらプレースホルダー(
${})部分は、該当する情報に置き換えられて欲しいという感じです。(まんま、JavaScriptのテンプレートリテラル内で式展開させる感じと同じです。)
■メールテンプレート
${last_name} {first_name} 様
この度は、${company_name}の${plan_name}にご登録いただき、ありがとうございました。
↓
■メール送信内容
オフ狛 ゆっきー 様
この度は、株式会社オフ狛のスペシャルプランにご登録いただき、ありがとうございました。
■検討結果と実装内容
テンプレートリテラレルを使えば解決じゃん?と思うのですが、外部から読み込んだ文字列にプレースホルダーが書いてあったとしても式展開されず、単なる文字列になっちゃうんですよね....(´・ω・)まぁ、そうですよね、、、ということで、結果、独自にプレースホルダー(
${})を置換する関数を作ることにしました。それがコチラ↓/**
* テンプレート置換処理
* @param {string} template // 外部から読み込んだメールテンプレート文字列
* @param {object} jsonObj // メールテンプレート内のプレースホルダー置換対象情報
*/
const templateRepalcer = (template, jsonObj)
=> template.replace(/\${([^}]+)}/g, (_, prop) => jsonObj[prop]);
以上です!
■実装関数の使用例
つづいて、実装した関数を使用して簡単な使い方例を以下に紹介します。// メールテンプレートのテンプレートリテラレルを顧客情報に置き換える関数を定義
const templateRepalcer = (template, jsonObj)
=> template.replace(/\${([^}]+)}/g, (_, prop) => jsonObj[prop]);
// メールテンプレート(!!実際は外部(DBなど)から読み込む!!)
const mailTempalte = "${last_name} ${first_name} 様\nこの度は、株式会社オフ狛の${plan_name}にご登録いただきありがとうございました。"
// 顧客情報(!!実際は外部(DBなど)から読み込む!!)
const userInfo = {
"user_id": "ID00001",
"last_name": "オフ狛",
"first_name": "ゆっきー",
"plan_name": "スペシャルプラン",
"mail_address": "yuckieee@officekoma.co.jp"
}
// メールテンプレートのプレースホルダーを顧客情報に入れ替え
const mailContents = templateRepalcer(mailTempalte, userInfo);
// 出来上がった変数を本文に編集すれば出来上がり!(この例ではコンソール出力のみ)
console.log(mailContents);
上記実行の結果、コンソールに出力される内容は以下のとおりです。
オフ狛 ゆっきー 様 この度は、株式会社オフ狛のスペシャルプランにご登録いただきありがとうございました。
以降、実装関数について詳細を解説していこうと思います。
■関数の詳細説明
この関数を説明する前にreplace関数についての知識が必要ですが、こちらの詳細は公式(?)ページを参照された方が分かりやすいですし、ここでの説明を割愛します。[replace関数の説明サイト]
・MDN Web Docs:String.prototype.replace()
・JavaScript Primer:文字列の置換/削除
作成した関数は2つあり、replace関数を呼ぶ大元の
templateReplacerという関数と、replace関数の第二引数で使用する無名関数です。
templateReplacer自体は、テンプレートと差し替えデータを引数に、replace関数を呼び出しているだけですので説明は省略します。以下より、replace関数の第2引数で指定している無名関数に焦点をあてて説明したいと思います。
まず、replace関数全体を眺めてると、以下のとおり、第2引数は、第1引数の結果を引き継いで処理を行う作りとなっていることが分かります。
template.replace(/\${([^}]+)}/g, (_, prop) => jsonObj[prop]);
[置換の母体となる文字列].replace(第1引数:[置換対象を正規表現で指定], 第2引数:[第一引数の結果を元に置換処理]);
無名関数を説明する上で、第1引数も重要となってくるため、第1引数と第2引数の両方を順に説明します。
第1引数: 置換対象の文字列又は、正規表現を指定
replace関数の第1引数には、置換対象にしたい文字列を固定で指定するか、正規表現を使って指定する事ができます。今回は、汎用性を高めるために正規表現を使用しています。
なお、
/\${([^}]+)}/の箇所は、書き方は色々あると思うので、正規表現をググってみてください。.?*とかでも行けると思います。※
()表記については、第2引数内で説明します。正規表現による抽出とは直接関わりません。また、
/gを指定することで置換の母体となる文字列全体での抽出が可能になります。この指定を行わない場合は、対象が複数あった場合でも、最初に正規表現にひっかかった1件だけが置換される結果となるので注意が必要です。
第2引数: 置換する文字列又は、置換処理を指定
第2引数には、置換後の文字列を固定で指定するか、処理自体を指定することが出来ます。今回紹介した関数では、第2引数に無名関数を定義して、置換処理を行う形をとっています。なお、第1引数での結果をもとに、以下の引数を無名関数に引き渡すことが可能です。
※ブラウザによっては、5つ引数があるようですが、ここでは説明対象外とします。
(match, p1, p2..., offset, string )
それぞれの具体的な値について説明します。
※実装例で上げたメールテンプレート中の
${last_name}を例に上げて説明します| 引数 | 説明 |
|---|---|
| match | 第1引数の正規表現にマッチした文字列が設定されます。 今回の例で言うところの ${last_name}が該当箇所です。なお、今回使用しないため、関数では_と表記しています。 |
| p1,p2... | 第1引数でキャプチャした文字列が引数として渡されます。 キャプチャは、第1引数の正規表現内でキャプチャしたい箇所を ()で囲むことで指定が可能です。複数指定した場合は、指定した分引数が増えていきます。今回は1対1で置換したかったので1つだけ指定しています。 今回の例では last_nameという箇所が抜き出されることになります。 |
| offset | 第1引数の正規表現でヒットした文字列の最初の位置を返します。${last_name}であれば、開始位置の0がセットされる感じです。今回は使用していません。 |
| string | 検索対象全体がセットされます。今回でいうと"${last_name} ${first_name} 様\nこの度は、株式会社狛のです。今回は使用していません。 |
上記の引数を元に置換対象を指定していますが、やっていることはJSONのキーを指定して、キーに紐づく値を取得しているだけです。
(_, prop) => jsonObj[prop]
今回の例では、以下のようなJSONが与えられていますので、第一引数でキャプチャしたキー(last_name)を元に、JSONのキーに紐づく値(オフ狛)を取得し、それを置換対象としています。
{
"user_id": "ID00001",
"last_name": "オフ狛",
"first_name": "ゆっきー",
"plan_name": "狛スタンダードプラン",
"mail_address": "yuckieee@officekoma.co.jp"
}
■まとめ
私は、今までreplace関数を単純に置換してくれるだけの関数ででしょ?って思っていました。ですが、こんなに汎用性の高い関数であるということを知って、目からウロコ...本当にreplace関数さん申し訳ありません...!!!ってなりました^^;
特に引数として関数を指定するというスタイルは、自分の作る関数にも活かして行けると面白いなと感じます。
カラム内の区切り文字でデータを分割して取得する方法(SQLServer)。
ある開発で、テーブルのカラムにカンマ区切りで値が入っているデータがあるのですが、ここからカンマ区切りで1つのレコードとして分割してデータ抽出したいと要望がありました。
取得イメージ的には、例えば以下のようなfruits_boxテーブルがあったとします。
+----+-------+-----------------------+ | id | name | fruits | +----+-------+-----------------------+ | 1 | boxA | apple,grape | | 2 | boxB | apple,orange,banana | | 3 | boxC | banana | +----+-------+-----------------------+こちらから
+----+-------+-----------------------+ | id | name | fruits | +----+-------+-----------------------+ | 1 | boxA | apple | | 1 | boxA | grape | | 2 | boxB | apple | | 2 | boxB | orange | | 2 | boxB | banana | | 3 | boxC | banana | +----+-------+-----------------------+のように取得する感じです。
カラム内にカンマ区切りでデータを入れることは結構あると思います。ただ、そのデータをレコードとして分割して出力することがなかったのですが、今回調べてみて意外と簡単な方法があったのでちょっと書いてみたいと思います。 RDBMSによってそれぞれ書き方は異なるようですが、開発でSQLServerを使っていたので、今回はそのSQLServerで上記のような構成でデータを取得する方法となります。
■STRING_SPLIT関数
まずSQLserverで、区切り文字で分割して結果を取得する方法としては、STRING_SPLIT関数を使う方法があります。STRING_SPLIT関数は、
STRING_SPLIT(文字列, 区切り文字)の方法で利用することができます。
使い方としてはこんな感じです。
> SELECT * FROM STRING_SPLIT('apple,orange,banana', ',');
+-----------+
| value |
+-----------+
| apple |
| orange |
| banana |
+-----------+
これはこれで使えそうなんですが、そもそもこれを使うには1レコード対象にしか使えません。
なので、今回のような複数レコードが存在する場合にはちょっと無理そうです。
■CROSS APPLY句
ということで、もう1つの方法として、「CROSS APPLY」という結合演算子と合体技で取得する方法です。APPLY句については今回細かく記載は省きますが、テーブルから取得したデータをテーブル値関数の結果として組み合わせて使いたい場合にこのAPPLYという演算子は有効です。
これを使ってSQLを書くと以下のように書くことが出来ます。
> SELECT * > FROM fruits_box AS FB > CROSS APPLY STRING_SPLIT(fruits, ','); +----+-------+-----------------------+ | id | name | fruits | +----+-------+-----------------------+ | 1 | boxA | apple | | 1 | boxA | grape | | 2 | boxB | apple | | 2 | boxB | orange | | 2 | boxB | banana | | 3 | boxC | banana | +----+-------+-----------------------+という感じで、ほしいデータの結果を得ることが出来ました。
結構簡単な記述でいけますね!
1点注意点としては、上記テーブルのfruitsにNULLがあった場合、その結果は返ってこないので、もしNULLも結果として返したい場合は、「OUTER APPLY」を使うことでNULLレコードも結果として得ることができるようになります。
もし上記のような、カラム内の区切り文字で、それぞれレコードとしてデータ取得が必要な場合にはご参考にしてみてください。
SQL Serverで複数カラム(NULL許可のカラムを含む)のユニーク制約を設定する方法。
オフィス狛 技術部のJoeです。
データベースで複数カラムを組み合わせたユニーク制約の設定が可能ですが、データベースによって、NULL値を重複として扱うかどうかが違っています。
例えばMySQLは、NULL値を重複として扱いませんが、SQL Serverは、NULL値を重複した値として扱います。
今回は、SQL ServerでNULL値を含む複数カラムの組み合わせで、ユニーク制約を設定する方法をご紹介したいと思います。
※下記の内容はSQL Server 2017で確認しています
まず、下記のようなテーブルを作成、データを追加します。
【テーブル作成】
CREATE TABLE fish ( id bigint IDENTITY(1,1) NOT NULL, name nvarchar(10) NOT NULL, code varchar(2) NULL, PRIMARY KEY CLUSTERED(id) );
+--------+--------+------+ | id(PK) | name | code | +--------+--------+------+ | 1 | かつお | 11 | | 2 | かつお | NULL | | 3 | かつお | NULL | | 4 | まぐろ | 11 | | 5 | まぐろ | 22 | +--------+--------+------+
このテーブルに「name」と「code」の組み合わせの複数のカラムに対して、ユニーク制約の設定しようとすると、エラーが発生します。
【ユニーク制約の設定SQL】
ALTER TABLE fish ADD CONSTRAINT uq_fish UNIQUE ( name, code );
【エラー内容】
The CREATE UNIQUE INDEX statement terminated because a duplicate key was found for the object name 'dbo.fish' and the index name 'uq_fish'. The duplicate key value is (かつお,).
エラー内容のとおり、「かつお, NULL」の組み合わせで、既に重複しているデータがあるので、ユニーク制約を設定することが出来ませんでした。
(SQL Serverは、NULL値を重複した値として扱うため)
もし、NULL値を重複した値として扱いたくない場合、下記の方法で設定が可能です。
【設定方法1】スカラー関数とチェック制約
スカラー関数とチェック制約を組み合わせて、NULL値を含む複数カラムでのユニーク制約を設定可能です。
-- スカラー関数作成
CREATE FUNCTION fishCheck (
@id bigint,
@name nvarchar(10),
@code varchar(20)
)
RETURNS BIT
WITH RETURNS NULL ON NULL INPUT -- ※1
AS
BEGIN
RETURN
CASE WHEN EXISTS
(SELECT 1
FROM fish
WHERE id <> @id -- ※2
AND name = @name
AND code = @code)
THEN 1 ELSE 0 END
END;
-- チェック制約の設定
ALTER TABLE fish ADD CONSTRAINT ck_fish
CHECK (dbo.fishCheck(id, name, code) <> 1); -- ※3
※1 引数のいずれかが NULL の場合に関数の本体を呼び出すことなく NULL を返しますそのため、code(NULL許可のカラム)がNULLであれば、重複のチェックは行いません
※2 重複レコードを探すSQLでは、チェックする(挿入、更新しようとする)データは除きます
※3 スカラー関数の結果が「1(重複あり)」でなければ、チェックOKとします
と、上記のように設定することができますが、2つの定義が必要ですし、少し解り難いかなという印象です。
もう一つの下記方法ですと、1つの定義で設定が可能です。
【設定方法2】ユニークインデックス
ユニークインデックスでは、WHERE句で条件を指定できます。「code(NULL許可のカラム)がNULL以外」の条件を指定することで、NULLを除外したユニーク制約の設定が可能です。
CREATE UNIQUE INDEX uq_idx_fish ON fish(name, code) WHERE code IS NOT NULL;
もしSQL Serverで、NULL許可のカラムにユニーク制約を設定する場合、上記を試してみてください。
ただ、MSDNに「一意制約の列を選択する場合は、NOT NULL と定義された列を選択します。」との記載があるので、
ユニーク制約に設定するカラムには、NULL値が入らない前提での設計が必要ですね。
一意インデックスの作成
2022年2月22日火曜日
【Angular】Reactive formを使用して、要素が動的なフォームを作成する方法。
オフィス狛 技術部のmmm(むー)です。
先日Angularにて、Reactive Formを使用し、要素が動的に増えるフォームを作成したかったのですが、公式のページを読んでもなかなかイメージがわかず、苦労したので備忘録として残します。
今回、記事のためにAngularのバージョンは13.2.0を使用して確認しました。
業務で使用した際、Angular8系で動くことも確認しています。
対応方法
作成したかったフォームの構造は下記のようなイメージです。「ID、名前、メモ」で1セットのデータが動的に増減します。 早速ですが、コンポーネントのコードを記載します。 // app.component.ts
import { Component, VERSION } from '@angular/core';
import { FormBuilder, FormGroup, Validators, FormArray } from '@angular/forms';
@Component({
selector: 'my-app',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
})
export class AppComponent {
constructor(private formBuilder: FormBuilder) {}
// フォーム(枠) = formタグに該当します
formGroup: FormGroup = this.formBuilder.group({
features: this.formBuilder.array([]),
});
// 実際はAPIからの戻り値を使用すると思いますが、今回は確認用に以下の値を定義します
list = [
{ id: 1, name: 'Haru', memo: '春' },
{ id: 2, name: 'Natsu', memo: '夏' },
];
ngOnInit() {
// 空のフォーム(中身) = formタグの中に入れる項目をまとめたものになります
// 今回の例ですと、「ID、名前、メモ」で1つの単位となります。
let testForm = this.formBuilder.group({});
// 初期値とバリデーションを設定します。
this.list.forEach((obj) => {
testForm = this.formBuilder.group({
name: [obj.name, Validators.required],
memo: [obj.memo],
});
// フォーム(中身)をフォーム(枠)に追加します
this.features.push(testForm);
});
}
// featuresはAbstractControlを返すので、型をFormArrayにします
get features(): FormArray {
return this.formGroup.get('features') as FormArray;
}
}
・FormArrayを使用し、各FormControlの値をまとめることができます。
・FormControlは1つの入力項目(例えば、inputタグ単位)となります。
・更に、FormGroupを使用し、各FormArrayの値をまとめることができます。
・FormArrayが不要な場合は、FormGroupとFormControlだけで問題ありません。
HTMLは下記となります。(バリデーション等は記載していません)
// app.component.html
<form [formGroup]="formGroup">
<ng-container formArrayName="nestForm">
<!-- グループ名(formGroupName)はインデックスになります -->
<ng-container *ngFor="let val of nestForm.controls; let i = index">
<ng-container [formGroupName]="i">
<p>ID:{{ list[i].id }}</p>
<p>
名前:<input
type="text"
class="form-control"
formControlName="name"
/>
</p>
<p>
メモ:<input
type="text"
class="form-control"
formControlName="memo"
/>
</p>
<hr />
</ng-container>
</ng-container>
</ng-container>
<button type="submit" [disabled]="formGroup.invalid ">更新</button>
</form>
このコードにバリデーションをチェックし、エラーメッセージを表示する処理を追加したい場合、以下のように作成することができます。
FormControlの名前を固定できるので(下記の例では、「name」)、バリデーションチェック用の処理を重複して書く必要がありません。
// app.component.ts
// 名前が入力されているかチェックする
errorNameRequired(i: number): boolean {
return (this.formArray.get('features')).controls[i].get('name').hasError('required');
}
// app.component.html
<p>
名前:<input
type="text"
class="form-control"
formControlName="name"
/>
<!-- エラーメッセージを表示するためのコードを追加↓ -->
<ng-container *ngIf="errorNameRequired(i)">名前を入力してください。</ng-container>
</p>
ちなみにフォームの値に直接アクセスしたい場合は、以下のように確認することができます。
console.log(this.nestForm.value[0].name) // Haru
以上となります。参考になりましたら幸いです。
参考:
https://angular.io/api/forms/FormGroup (公式)
https://angular.io/api/forms/FormArray (公式)

