EzValidator

特徴

EzValidatorは、Flutter向けに設計された非常にシンプルなフィールドとオブジェクトスキーマ検証ライブラリです。Yupの直感的なAPIに触発され、Flutterアプリケーション内でデータスキーマの定義と実施を簡素化します。

主な特徴

  • Flutterスキーマビルダー: Flutterプロジェクトにシームレスに統合し、データ検証スキーマを構築・管理
  • 多用途な検証: 個別のフィールドや複雑なオブジェクト全体の検証に対応
  • 型安全: ジェネリクスを使用した型安全な検証
  • カスタムバリデーション: addMethodを使用した柔軟なカスタム検証ルール
  • 国際化対応: カスタムロケールによるエラーメッセージのローカライズ
  • 条件付き検証: whendependsOnunionを使用した高度な検証ロジック
  • データ変換: transformを使用した検証前のデータ前処理

インストール

dependencies:
  ez_validator: ^0.3.6

基本的な使い方

スキーマの定義

final EzSchema userSchema = EzSchema.shape({
  "email": EzValidator<String>(label: "メールアドレス")
      .required()
      .email(),
  "password": EzValidator<String>(label: 'パスワード')
      .required()
      .minLength(8),
  'date': EzValidator<DateTime>()
      .required()
      .date()
      .minDate(DateTime(2019))
      .maxDate(DateTime(2025)),
});

データの検証

// エラーのみを取得
final errors = userSchema.catchErrors({
  'email': '[email protected]',
  'password': '12345678',
  'date': DateTime.now(),
});

if (errors.isEmpty) {
  print("検証成功!");
} else {
  print("検証エラー: $errors");
}

// データとエラーを同時に取得
final (data, errors) = userSchema.validateSync({
  'email': '[email protected]',
  'password': '12345678',
  'date': DateTime.now(),
});

print(data);   // 処理されたデータ(デフォルト値を含む)
print(errors); // 検証エラー

Flutter ウィジェットでの使用

TextFormFieldでの例

class LoginForm extends StatelessWidget {
  final _formKey = GlobalKey<FormState>();

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        children: [
          TextFormField(
            validator: EzValidator<String>()
                .required("メールアドレスは必須です")
                .email("有効なメールアドレスを入力してください")
                .build(),
            decoration: const InputDecoration(
              labelText: 'メールアドレス',
              hintText: '[email protected]',
            ),
          ),
          TextFormField(
            obscureText: true,
            validator: EzValidator<String>()
                .required("パスワードは必須です")
                .minLength(8, "パスワードは8文字以上必要です")
                .matches(
                  RegExp(r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]'),
                  "大文字、小文字、数字、特殊文字を含む必要があります",
                )
                .build(),
            decoration: const InputDecoration(
              labelText: 'パスワード',
            ),
          ),
          ElevatedButton(
            onPressed: () {
              if (_formKey.currentState!.validate()) {
                // フォームが有効な場合の処理
              }
            },
            child: const Text('ログイン'),
          ),
        ],
      ),
    );
  }
}

高度な使い方

カスタムバリデーション

// JSON構造の検証
final checkJson = EzValidator<Map<String, dynamic>>()
    .addMethod((v) => v?['foo'] == 'bar' ? null : 'fooはbarである必要があります')
    .addMethod((v) => v?['bar'] == 'Flutter' ? null : 'barはFlutterである必要があります')
    .addMethod((v) => v?['items'] is List && v!['items'].isNotEmpty 
        ? null 
        : 'itemsは空でないリストである必要があります')
    .build();

// カスタムパスワード検証
final passwordValidator = EzValidator<String>()
    .required("パスワードは必須です")
    .minLength(8, "8文字以上必要です")
    .addMethod((password) {
      if (password == null) return null;
      
      // 一般的な弱いパスワードをチェック
      final weakPasswords = ['password', '12345678', 'qwerty'];
      if (weakPasswords.contains(password.toLowerCase())) {
        return 'このパスワードは弱すぎます';
      }
      return null;
    });

条件付き検証(when)

final EzSchema registrationSchema = EzSchema.shape({
  "password": EzValidator<String>()
      .required()
      .minLength(8, "パスワードは8文字以上必要です"),
  "confirmPassword": EzValidator<String>().when(
    "password",
    (confirmValue, [ref]) =>
        confirmValue == ref?["password"] 
            ? null 
            : "パスワードが一致しません",
  ),
  "accountType": EzValidator<String>()
      .required()
      .oneOf(["personal", "business"]),
  "companyName": EzValidator<String>().when(
    "accountType",
    (value, [ref]) {
      if (ref?["accountType"] == "business" && (value == null || value.isEmpty)) {
        return "法人アカウントの場合、会社名は必須です";
      }
      return null;
    },
  ),
});

条件付き検証(dependsOn)

final EzSchema carRentalSchema = EzSchema.shape({
  "age": EzValidator<int>().required().min(18),
  "carType": EzValidator<String>()
      .required()
      .oneOf(["economy", "standard", "luxury"]),
  "insuranceRequired": EzValidator<bool>().dependsOn(
    condition: (ref) => ref!["age"] < 25 || ref["carType"] == "luxury",
    then: EzValidator<bool>()
        .required()
        .addMethod((v) => v == true ? null : "保険への加入が必須です"),
    orElse: EzValidator<bool>(optional: true),
  ),
});

データ変換(transform)

final EzSchema userProfileSchema = EzSchema.shape({
  "name": EzValidator<String>()
      .transform((value) => value.trim())
      .minLength(3, "名前は3文字以上必要です"),
  "email": EzValidator<String>()
      .transform((value) => value.toLowerCase().trim())
      .required()
      .email(),
  "phone": EzValidator<String>()
      .transform((value) => value.replaceAll(RegExp(r'[^\d+]'), ''))
      .matches(RegExp(r'^\+?\d{10,15}$'), "有効な電話番号を入力してください"),
});

ネストした検証

final EzSchema orderSchema = EzSchema.shape({
  "orderId": EzValidator<String>().required().uuid(),
  "customer": EzSchema.shape({
    "name": EzValidator<String>().required(),
    "email": EzValidator<String>().required().email(),
    "phone": EzValidator<String>().required().phone(),
  }),
  "shippingAddress": EzSchema.shape({
    "street": EzValidator<String>().required(),
    "city": EzValidator<String>().required(),
    "prefecture": EzValidator<String>().required(),
    "postalCode": EzValidator<String>()
        .required()
        .matches(RegExp(r'^\d{3}-\d{4}$'), "郵便番号の形式が正しくありません"),
  }),
  "items": EzValidator<List<Map<String, dynamic>>>()
      .required()
      .minLength(1, "商品を1つ以上選択してください")
      .arrayOf(
        EzValidator<Map<String, dynamic>>().schema(
          EzSchema.shape({
            "productId": EzValidator<String>().required(),
            "quantity": EzValidator<int>().required().min(1).max(100),
            "price": EzValidator<double>().required().positive(),
          }),
        ),
      ),
});

Union型の検証

final EzSchema paymentSchema = EzSchema.shape({
  'paymentMethod': EzValidator<String>()
      .required()
      .oneOf(['card', 'bank', 'cash']),
  'paymentDetails': EzValidator().union([
    // クレジットカード用のスキーマ
    EzValidator<Map<String, dynamic>>().schema(
      EzSchema.shape({
        'cardNumber': EzValidator<String>()
            .required()
            .matches(RegExp(r'^\d{16}$')),
        'cvv': EzValidator<String>()
            .required()
            .matches(RegExp(r'^\d{3,4}$')),
      }),
    ),
    // 銀行振込用のスキーマ
    EzValidator<Map<String, dynamic>>().schema(
      EzSchema.shape({
        'accountNumber': EzValidator<String>().required(),
        'branchCode': EzValidator<String>().required(),
      }),
    ),
    // 現金払いの場合はnull
    EzValidator<String?>().addMethod((v) => v == null ? null : '現金払いの場合、詳細は不要です'),
  ]),
});

カスタムロケールの実装

class JapaneseLocale implements EzLocale {
  const JapaneseLocale();

  @override
  String required([String? label]) => '${label ?? 'この項目'}は必須です';

  @override
  String email(String v, [String? label]) =>
      '${label ?? 'メールアドレス'}の形式が正しくありません';

  @override
  String minLength(String v, int n, [String? label]) =>
      '${label ?? 'この項目'}は$n文字以上必要です';

  @override
  String maxLength(String v, int n, [String? label]) =>
      '${label ?? 'この項目'}は$n文字以下にしてください';

  @override
  String min(String v, num n, [String? label]) =>
      '${label ?? 'この値'}は$n以上である必要があります';

  @override
  String max(String v, num n, [String? label]) =>
      '${label ?? 'この値'}は$n以下である必要があります';

  @override
  String positive(String v, [String? label]) =>
      '${label ?? 'この値'}は正の数である必要があります';

  @override
  String negative(String v, [String? label]) =>
      '${label ?? 'この値'}は負の数である必要があります';

  @override
  String date(String v, [String? label]) =>
      '${label ?? 'この項目'}は有効な日付である必要があります';

  @override
  String url(String v, [String? label]) =>
      '${label ?? 'URL'}の形式が正しくありません';

  @override
  String phone(String v, [String? label]) =>
      '${label ?? '電話番号'}の形式が正しくありません';

  @override
  String ip(String v, [String? label]) =>
      '${label ?? 'IPアドレス'}の形式が正しくありません';

  @override
  String ipv6(String v, [String? label]) =>
      '${label ?? 'IPv6アドレス'}の形式が正しくありません';

  @override
  String uuid(String v, [String? label]) =>
      '${label ?? 'UUID'}の形式が正しくありません';

  @override
  String boolean(String v, [String? label]) =>
      '${label ?? 'この値'}はtrue/falseである必要があります';

  @override
  String lowerCase(String v, [String? label]) =>
      '${label ?? 'この項目'}は小文字である必要があります';

  @override
  String upperCase(String v, [String? label]) =>
      '${label ?? 'この項目'}は大文字である必要があります';
  
  // その他のメソッドも同様に実装...
}

// 使用方法
void main() {
  EzValidator.setLocale(const JapaneseLocale());
  // これ以降、すべての検証エラーメッセージが日本語で表示されます
}

実践的な例

ユーザー登録フォーム

class UserRegistrationForm extends StatefulWidget {
  @override
  _UserRegistrationFormState createState() => _UserRegistrationFormState();
}

class _UserRegistrationFormState extends State<UserRegistrationForm> {
  final _formKey = GlobalKey<FormState>();
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();
  final _confirmPasswordController = TextEditingController();
  final _ageController = TextEditingController();
  bool _agreeToTerms = false;

  late final EzSchema registrationSchema;

  @override
  void initState() {
    super.initState();
    
    registrationSchema = EzSchema.shape({
      "email": EzValidator<String>()
          .required("メールアドレスは必須です")
          .email("有効なメールアドレスを入力してください"),
      "password": EzValidator<String>()
          .required("パスワードは必須です")
          .minLength(8, "パスワードは8文字以上必要です")
          .matches(
            RegExp(r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)'),
            "大文字、小文字、数字を含む必要があります",
          ),
      "confirmPassword": EzValidator<String>().when(
        "password",
        (value, [ref]) {
          if (value != ref?["password"]) {
            return "パスワードが一致しません";
          }
          return null;
        },
      ),
      "age": EzValidator<int>()
          .required("年齢は必須です")
          .min(18, "18歳以上である必要があります")
          .max(120, "有効な年齢を入力してください"),
      "agreeToTerms": EzValidator<bool>()
          .required()
          .addMethod((v) => v == true ? null : "利用規約に同意してください"),
    });
  }

  void _handleRegistration() {
    final formData = {
      'email': _emailController.text,
      'password': _passwordController.text,
      'confirmPassword': _confirmPasswordController.text,
      'age': int.tryParse(_ageController.text),
      'agreeToTerms': _agreeToTerms,
    };

    final (data, errors) = registrationSchema.validateSync(formData);

    if (errors.isEmpty) {
      // 登録処理
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('登録成功!')),
      );
    } else {
      // エラー表示
      String errorMessage = errors.entries
          .map((e) => '${e.key}: ${e.value}')
          .join('\n');
      
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(
          content: Text(errorMessage),
          backgroundColor: Colors.red,
        ),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        children: [
          TextFormField(
            controller: _emailController,
            decoration: InputDecoration(labelText: 'メールアドレス'),
            validator: EzValidator<String>()
                .required("メールアドレスは必須です")
                .email("有効なメールアドレスを入力してください")
                .build(),
          ),
          TextFormField(
            controller: _passwordController,
            obscureText: true,
            decoration: InputDecoration(labelText: 'パスワード'),
            validator: EzValidator<String>()
                .required("パスワードは必須です")
                .minLength(8, "パスワードは8文字以上必要です")
                .build(),
          ),
          TextFormField(
            controller: _confirmPasswordController,
            obscureText: true,
            decoration: InputDecoration(labelText: 'パスワード(確認)'),
            validator: (value) {
              if (value != _passwordController.text) {
                return "パスワードが一致しません";
              }
              return null;
            },
          ),
          TextFormField(
            controller: _ageController,
            keyboardType: TextInputType.number,
            decoration: InputDecoration(labelText: '年齢'),
            validator: EzValidator<String>()
                .required("年齢は必須です")
                .addMethod((v) {
                  final age = int.tryParse(v ?? '');
                  if (age == null) return "有効な数値を入力してください";
                  if (age < 18) return "18歳以上である必要があります";
                  if (age > 120) return "有効な年齢を入力してください";
                  return null;
                })
                .build(),
          ),
          CheckboxListTile(
            title: Text('利用規約に同意します'),
            value: _agreeToTerms,
            onChanged: (bool? value) {
              setState(() {
                _agreeToTerms = value ?? false;
              });
            },
          ),
          ElevatedButton(
            onPressed: () {
              if (_formKey.currentState!.validate()) {
                _handleRegistration();
              }
            },
            child: Text('登録'),
          ),
        ],
      ),
    );
  }
}

パフォーマンスのヒント

  1. スキーマの再利用: スキーマは一度定義して再利用する
// 良い例
final userSchema = EzSchema.shape({...});

// 悪い例 - 毎回新しいスキーマを作成
Widget build(BuildContext context) {
  final schema = EzSchema.shape({...}); // 避けるべき
}
  1. 適切な検証タイミング: 必要に応じて検証を実行
// フィールドごとの検証
TextFormField(
  validator: emailValidator.build(),
  autovalidateMode: AutovalidateMode.onUserInteraction,
)

// フォーム全体の検証
if (_formKey.currentState!.validate()) {
  // 送信処理
}
  1. エラーメッセージのキャッシュ: 頻繁に使用するエラーメッセージは定数として定義
class ValidationMessages {
  static const required = '必須項目です';
  static const invalidEmail = '有効なメールアドレスを入力してください';
  static const passwordTooShort = 'パスワードは8文字以上必要です';
}

まとめ

EzValidatorは、Flutterアプリケーションにおける入力検証を簡単かつ柔軟に行うための強力なツールです。シンプルなAPIから始めて、必要に応じて高度な機能を活用することで、堅牢な入力検証システムを構築できます。