【Flutter】ObjectBoxの設定、CRUD、リアクティブデータ更新まで徹底解説

Table of Contents

対象者

  • モバイルアプリ開発に携わるエンジニア
  • 高速で効率的なデータベースを求める開発者
  • ObjectBoxの導入方法と使用方法を学びたい方

はじめに

あなたはモバイルアプリ開発に携わるエンジニアで、高速かつ効率的なデータベースを探していますか?ObjectBoxは、その高速なデータ処理能力とリソース効率性で注目されており、特に大量データを扱うプロジェクトで優れた性能を発揮します。このブログ記事では、ObjectBoxの導入手順から基本的な使い方、高度な機能までを徹底解説します。
この記事を読むことで、あなたはObjectBoxを使いこなせるようになり、アプリ開発の効率と品質を大幅に向上させることができるでしょう。

ObjectBoxの概要

ObjectBoxとは

ObjectBoxは、高速で効率的なローカルデータベースです。モバイルやIoTデバイス向けに設計されており、CPU、メモリ、バッテリーの使用を最小限に抑えながら、高速なデータ操作を実現します。これにより、アプリケーションのパフォーマンスが向上し、ユーザー体験が向上します。

他のデータベースと比較した場合、ObjectBoxはパフォーマンスが高い、らしい(ObjectBoxの公式サイトのデータなので、そのまま受けるのに抵抗がある)。Hiveやsqfliteといった他のFlutter用データベースと比べると、データ操作の速度が非常に速いことがベンチマークで示されています。

他のデータベースとの比較

ObjectBoxは、その優れたパフォーマンスと効率性で他のデータベースと差別化されています。特に、以下の点で他のデータベースよりも優れています。

  • 速度: ObjectBoxは他のデータベースに比べて高速で、特に大量のデータを扱う際にその優位性が顕著です。
  • リソース効率: CPUやメモリの使用量が少なく、バッテリー寿命を延ばすことができます。
  • 使いやすさ: 直感的なAPIにより、開発者は迅速にデータ操作を行うことができます。

ObjectBoxを選ぶことで、開発者はパフォーマンスの向上とリソースの節約を両立できるため、より優れたユーザー体験を提供することができます。

導入手順

セットアップ方法

ObjectBoxのセットアップは以下の手順で行います。

  1. ObjectBoxとコード生成ツールを依存関係に追加: 次のコマンドを実行し、依存関係を追加します。
flutter pub add objectbox objectbox_flutter_libs
flutter pub add build_runner objectbox_generator -d
ObjectBoxをスマホではなく、WindowsやMac上で動作させたい場合(サーバ用途、コマンドラインツール、等)は、objectbox_flutter_libsは不要。代わりに、後述の「Windowsのセットアップ(ユニットテスト用)」や「Macのセットアップ(ユニットテスト用)」の設定を実施する。
  1. 依存関係をインストール: 先ほどのように、pubspec.yamlに依存関係を追加し、flutter pub getコマンドを実行します。

  2. モデルの定義: データベースモデルを定義します。例えば、以下のように@Entityアノテーションを使って定義します。

import 'package:objectbox/objectbox.dart';
import 'objectbox.g.dart'; // generated code

@Entity()
class CounterEntity {
  @Id()
  int id = 0;

  final int value;
  late bool isOdd = (value % 2 == 1);

  CounterEntity(this.value);
}

@Id()のあるint 型が必須となります。こちらは「0」で初期化しましょう。後で確認しますが、DBに入れるとidの値が変わります。
事前にobjectbox.g.dartもインポートしておきましょう。ファイル名はこちらで決め打ちです(lib直下にないと、調整が必要かも)。こちらをインポートしてないと、コード生成で見つけてくれません。
NoSQLのため検索条件に奇数の判別ができないため、事前にデータとして奇数であるか設定しています。

  1. コード生成: flutter pub run build_runner buildコマンドを実行して、ObjectBox用のコードを生成します。

  2. BoxStoreの初期化: アプリケーションのエントリポイントでBoxStoreを初期化します。

import 'objectbox.g.dart'; // コード生成される

Future<void> main() {
  final store =  openStore();
  runApp(MyApp(store));
}

これらの手順により、ObjectBoxを簡単にプロジェクトに組み込むことができます。依存関係が少なく、セットアップも容易なため、開発者にとって非常に有益です。

Windowsのセットアップ(ユニットテスト用)

ユニットテストで使い方を実験するのが好きなのですが、上記ではWIndows上で動作するユニットテストは動作しません(スマホで動作する用だから)。そのため、WIndowsで走らせるために、ちょっと頑張りました。

したら、動作するようになりました。ユニットテスト最高。testフォルダでは認識してくれないので、リリース時に一工夫しないとバイナリに含まれてしまうかも

Macのセットアップ(ユニットテスト用)

Windowsとほぼ一緒ですが、「objectbox-macos-universal.zip」をダウンロードして、「lib/libobjectbox.dylib」をlibにコピー。testを実行するとブロックされるので、「リンゴマーク→システム設定→プライバシーとセキュリティ」で「libobjectbox.dylibは開発元を確認できないため、使用がブロックされました」を「このまま許可」。実行するとさらに聞かれたが「開く」を押すと、二度と聞かれずに実行できるようになった。Macの方がセキュリティがしっかりしている、多分

Storeの取得方法

Flutterとユニットテストでの取得方法を紹介します。これらの方法を使い分けることで、テスト環境と実際のアプリケーション環境で適切にデータを管理できます。

Flutter用のStore取得

Flutterでの一般的なStore取得方法は、スマホ内のディレクトリを取得して、データを保存します。以下のように、getApplicationDocumentsDirectoryを使用してアプリケーションのドキュメントディレクトリを取得し、そのパスを使用してStoreを開きます。
また、不要になったらclose()を使用します。

Store? _store;

@override
void initState() {
  super.initState();

  getApplicationDocumentsDirectory().then((dir) async {
    final path = Directory('${dir.path}/object_box_test');
    _store = await openStore(directory: path.path);
  });
}

@override
void dispose() async {
    _store?.close();
    super.dispose();
}

この方法では、アプリケーションのドキュメントディレクトリ内にobject_box_testディレクトリを作成し、その中にデータを保存します。これにより、アプリケーションの再起動後もデータが保持されます。

テスト用のStore取得

テスト用のStoreは、テスト後にデータが消えるインメモリDBを使用しています。ディレクトリ名の最初に「memory:」を付けるとインメモリDBになり、テスト毎に異なるDB名になるように現在時刻のミリ秒を付けています。そのため、毎回新しいDBとして生成されます。

Future<Store> getStore() async {
  await Future.delayed(const Duration(milliseconds: 10));
  return openStore(
      directory:
          'memory:object-box-${DateTime.now().millisecondsSinceEpoch}');
}

このようにすることで、各テスト実行時に新しいインメモリDBが作成され、テスト後にデータが消去されます。ディレクトリ名の先頭にmemory:を付けることで、インメモリDBとして動作し、DateTime.now().millisecondsSinceEpochを使用してユニークなDB名を生成します。

モデルの定義

ObjectBoxでデータを扱うためには、まずデータモデルを定義する必要があります。データモデルは、@Entityアノテーションを使って簡単に定義できます。以下はその具体例です。

データモデルを定義するためには、以下のように@Entityアノテーションを用います。これにより、データベースに保存されるエンティティが定義されます。

import 'package:objectbox/objectbox.dart';

@Entity()
class Person {
  @Id()
  int id = 0;

  int group;
  String name;
  double height;
  DateTime birthday;
  String? club;

  Person(this.group, this.name, this.height, this.birthday, {this.club});
}

このモデル定義により、ObjectBoxはデータベースのテーブルを自動的に生成します。モデル定義は簡潔で読みやすく、開発者が直感的に理解できる設計になっています。
このモデルを使ってテストを行っていきます。

CRUD

概要

実際のデータはstore.boxを使用してテーブルにアクセスします。store.boxを変数やメソッドにすることで、簡単にアクセスできるよう工夫できます。

  • データの挿入と更新: putメソッドを使用します。IDが0であれば新規作成、それ以外であれば更新とみなされます。
  • データの取得: getメソッドを使用します。
  • データの削除: removeメソッドを使用します。

単体データ

まず、Storeを取得し、Personオブジェクトを作成します。次に、そのオブジェクトをStoreに保存し、新しいIDを取得します。その後、Storeからオブジェクトを取得し、データが正しく保存されているかを確認します。

final store = await getStore();
final person1 = Person(1, 'Alice', 165.5, DateTime(1990, 5, 17));
final newId = store.box<Person>().put(person1);
expect(newId, 1);

final person2 = store.box<Person>().get(newId);
expect(person2, isNotNull);
expect(person2!.id, 1);
expect(person2.name, 'Alice');
expect(person2.height, 165.5);

person2.height = 166.0;
final updatedId = store.box<Person>().put(person2);
expect(updatedId, 1);

final person3 = store.box<Person>().get(newId);
expect(person3!.name, 'Alice');
expect(person3.height, 166.0);

store.box<Person>().remove(1);
expect(store.box<Person>().getAll().length, 0);

store.close();

複数データ

次に、複数のPersonオブジェクトをStoreに保存し、正しく保存されたことを確認します。また、データの更新と削除のテストも行います。

final store = await getStore();
final person1 = Person(1, 'Alice', 165.5, DateTime(1990, 5, 17));
Person person2 = Person(2, 'Bob', 172.3, DateTime(1985, 8, 23));
final newId = store.box<Person>().putMany([person1, person2]);
expect(newId, [1, 2]);

final people = store.box<Person>().getAll();
expect(people.length, 2);

expect(people[0].name, 'Alice');
expect(people[0].height, 165.5);
expect(people[1].name, 'Bob');
expect(people[1].height, 172.3);

people[0].height = 170.0;
people[1].height = 173.0;
store.box<Person>().putMany(people);

expect(people[0].name, 'Alice');
expect(people[0].height, 170.0);
expect(people[1].name, 'Bob');
expect(people[1].height, 173.0);

expect(store.box<Person>().getAll().length, 2);
store.box<Person>().removeMany([1, 2]);
expect(store.box<Person>().getAll().length, 0);

store.box<Person>().putMany(people);
expect(store.box<Person>().getAll().length, 2);
store.box<Person>().removeAll();
expect(store.box<Person>().getAll().length, 0);

store.close();

非同期

最後に、非同期操作のテストを行います。Personオブジェクトの非同期保存、更新、および削除を確認します。

final store = await getStore();
final person1 = Person(1, 'Alice', 165.5, DateTime(1990, 5, 17));
final newId = await store.box<Person>().putAsync(person1);
expect(newId, 1);

final newIds = store.box<Person>().putManyAsync([
  Person(2, 'Bob', 172.3, DateTime(1985, 8, 23)),
  Person(1, 'Charlie', 180.2, DateTime(1992, 1, 10), club: 'Football')
]);
expect(await newIds, [2, 3]);

final person2 = Person(3, 'Diana', 158.0, DateTime(1995, 3, 5), club: 'Tennis');
final person2_ = await store.box<Person>().putAndGetAsync(person2);
expect(person2_.id, 4);
expect(person2_.name, 'Diana');

final people = await store.box<Person>().putManyAsync([
  Person(2, 'Eve', 167.8, DateTime(1988, 7, 30)),
  Person(3, 'Frank', 175.0, DateTime(1983, 11, 11), club: 'Chess'),
]);
expect(people, [5, 6]);

final person3 = store.box<Person>().putQueued(
  Person(1, 'Grace', 162.4, DateTime(1996, 4, 25), club: 'Swimming'));
expect(person3, 7);

store.close();

このように、CRUD操作の各ステップを通じて、Personオブジェクトの作成、取得、更新、削除が正しく行われることを確認できます。

検索

このグループでは、Personオブジェクトの検索操作に関するテストを行います。

IDの状態(putするとIDが変わる)

ObjectBoxをテストしている中で、自動的にIDが更新されるという挙動に戸惑うことがありました。このテストでは、Personオブジェクトを保存するとIDが自動的に更新されることを確認するために実施しています。
具体的には、putメソッドを使用してオブジェクトを保存すると、IDが自動的に割り当てられます。このテストでは、新しいIDが割り当てられることを確認します。IDが0以外のデータをputすると、新規作成でなく、更新として扱われます。中途半端に想定通りに動作したため、一部動作が想定と異なり、戸惑うことになりました。
また、getPeople関数を使うことで、毎回新しいデータを取得し、IDが0である状態のデータを作成しています。そのため、毎回のput処理が新規作成として扱われます。

List<Person> getPeople() => [
        Person(1, 'Alice', 165.5, DateTime(1990, 5, 17), club: 'Tennis'),
        Person(2, 'Bob', 172.3, DateTime(1985, 8, 23)),
        Person(1, 'Charlie', 180.2, DateTime(1992, 1, 10), club: 'Football'),
        Person(3, 'Diana', 158.0, DateTime(1995, 3, 5), club: 'Tennis'),
        Person(2, 'Eve', 167.8, DateTime(1988, 7, 30)),
  ];

final store = await getStore();
people() => store.box<Person>();

final person = getPeople()[0];
expect(person.id, 0);
people().put(person);
expect(person.id, 1);

// getPeople()が常に新しいデータを作ってくれることを確認
expect(getPeople()[0].id, 0);

store.close();

IDによる検索

このテストでは、PersonオブジェクトをIDで検索することを確認します。

  • getメソッドを使用してIDで検索
  • getManyメソッドを使用して複数のIDで検索
  • 取得できればそのデータ、なければnullを返す
final store = await getStore();
people() => store.box<Person>();

people().putMany(getPeople());
expect(people().get(1), isNotNull);
expect(people().get(5), isNotNull);
expect(people().get(6), isNull);
expect(people().get(99), isNull);

final result = people().getMany([1, 5, 6]);
expect(result.length, 3);
expect(result[0]?.name, 'Alice');
expect(result[2], isNull);

store.close();

一致条件による検索

このテストでは、条件に一致するオブジェクトを検索します。

  • queryメソッドを使用して特定の条件で検索
  • 条件に一致するオブジェクトの数を確認
final store = await getStore();
people() => store.box<Person>();

people().putMany(getPeople());
final query0 = people().query(Person_.group.equals(0)).build();
expect(query0.find().length, 0);

final query1 = people().query(Person_.group.equals(1)).build();
expect(query1.find().length, 2);

final query2 = people().query(Person_.group.notEquals(1)).build();
expect(query2.find().length, 3);

store.close();

範囲条件による検索

このテストでは、範囲条件に一致するオブジェクトを検索します。

  • queryメソッドを使用して範囲条件で検索
  • 条件に一致するオブジェクトの数を確認
final store = await getStore();
people() => store.box<Person>();
people().putMany(getPeople());

final query1 = people().query(Person_.height.greaterThan(165.5)).build();
expect(query1.find().length, 3);

final query2 =
    people().query(Person_.height.greaterOrEqual(165.5)).build();
expect(query2.find().length, 4);

final query3 = people().query(Person_.height.lessThan(165.5)).build();
expect(query3.find().length, 1);

final query4 = people().query(Person_.height.lessOrEqual(165.5)).build();
expect(query4.find().length, 2);

final query5 =
    people().query(Person_.height.between(165.5, 172.3)).build();
expect(query5.find().length, 3);

store.close();

プロパティがnullまたはnullでない

このテストでは、特定のプロパティがnullまたはnullでないオブジェクトを検索します。

  • queryメソッドを使用してnullまたはnullでないプロパティを検索
  • 条件に一致するオブジェクトの数を確認
final store = await getStore();
people() => store.box<Person>();
people().putMany(getPeople());

final query1 = people().query(Person_.club.isNull()).build();
expect(query1.find().length, 2);

final query2 = people().query(Person_.club.notNull()).build();
expect(query2.find().length, 3);

store.close();

指定した配列のいずれかと一致する値

このテストでは、指定した配列のいずれかと一致する値を持つオブジェクトを検索します。

  • queryメソッドを使用して指定した配列の値を検索
  • 条件に一致するオブジェクトの数を確認
final store = await getStore();
people() => store.box<Person>();
people().putMany(getPeople());

final query1 = people().query(Person_.group.oneOf([1, 3])).build();
expect(query1.find().length, 3);

final query2 = people().query(Person_.group.notOneOf([1, 3])).build();
expect(query2.find().length, 2);

store.close();

文字列の部分一致

このテストでは、文字列の部分一致によるオブジェクトを検索します。

  • queryメソッドを使用して部分一致する文字列を検索
final store = await getStore();
people() => store.box<Person>();
people().putMany(getPeople());

final query1 = people().query(Person_.name.startsWith('A')).build();
expect(query1.find().length, 1);

final query2 = people().query(Person_.name.endsWith('e')).build();
expect(query2.find().length, 3);

final query3 = people().query(Person_.name.contains('a')).build();
expect(query3.find().length, 2);

store.close();

AND条件による検索

このテストでは、AND条件で検索を行います。

  • queryメソッドを使用してAND条件を指定
final store = await getStore();
people() => store.box<Person>();
people().putMany(getPeople());

final queryAnd = people()
    .query(Person_.group.equals(1).and(Person_.height.lessThan(170)))
    .build();
List<Person> resultsAnd = queryAnd.find();
expect(resultsAnd.map((p) => p.name).toList(), ['Alice']);

store.close();

OR条件による検索

このテストでは、OR条件で検索を行います。

  • queryメソッドを使用してOR条件を指定
final store = await getStore();
people() => store.box<Person>();
people().putMany(getPeople());

final queryOr = people()
    .query(Person_.group.equals(1).or(Person_.height.lessThan(170)))
    .build();
List<Person> resultsOr = queryOr.find();
expect(resultsOr.map((p) => p.name).toList(),
    ['Alice', 'Charlie', 'Diana', 'Eve']);

store.close();

ANDとORを組み合わせた条件による検索

このテストでは、ANDとORを組み合わせた条件で検索を行います。

  • queryメソッドを使用してANDとOR条件を組み合わせ
final store = await getStore();
people() => store.box<Person>();
people().putMany(getPeople());

final queryCombined = people()
    .query(Person_.height
        .lessThan(175)
        .and(Person_.group.equals(1).or(Person_.club.isNull())))
    .build();
List<Person> resultsCombined = queryCombined.find();
expect(
    resultsCombined.map((p) => p.name).toList(), ['Alice', 'Bob', 'Eve']);

store.close();

ソート

このテストでは、ソート条件で検索を行います。

  • orderメソッドを使用して特定の順序で並び替え
  • orderの引数で、ソートする項目を指定
  • flags: Order.descending で降順、デフォルトは昇順
  • orderを連続させて、ソート順を複数指定
  • Order.caseSensitiveで大文字小文字の区別を付ける(この場合、意味ないけど)
  • flags: Order.descending | Order.caseSensitive ソート条件を複数指定
final store = await getStore();
people() => store.box<Person>();
people().putMany(getPeople());

final query1 = people().query().order(Person_.height).build();
expect(query1.find().map((e) => e.name).toList(),
    ['Diana', 'Alice', 'Eve', 'Bob', 'Charlie']);

final query2 = people()
    .query()
    .order(Person_.height, flags: Order.descending)
    .build();
expect(query2.find().map((e) => e.name).toList(),
    ['Charlie', 'Bob', 'Eve', 'Alice', 'Diana']);

final query3 =
    people().query().order(Person_.group).order(Person_.height).build();
expect(query3.find().map((e) => e.name).toList(),
    ['Alice', 'Charlie', 'Eve', 'Bob', 'Diana']);

final query4 = people()
    .query()
    .order(Person_.name, flags: Order.descending | Order.caseSensitive)
    .build();
expect(query4.find().map((e) => e.name).toList(),
    ['Eve', 'Diana', 'Charlie', 'Bob', 'Alice']);

store.close();

ソート: Order.caseSensitive

caseSensitiveを詳しく見ていきましょう。Personオブジェクトを名前でソートする際に、大文字小文字の区別をするかどうかを確認します。

  • orderメソッドを使用して名前でソート
  • Order.caseSensitiveフラグを使用して大文字小文字の区別を確認
final store = await getStore();
people() => store.box<Person>();
people().putMany([
  Person(2, 'eve', 167.8, DateTime(1988, 7, 30)),
  Person(2, 'Eve', 167.8, DateTime(1988, 7, 30)),
  Person(2, 'EVE', 167.8, DateTime(1988, 7, 30)),
  Person(2, 'EVE1', 167.8, DateTime(1988, 7, 30)),
]);

final query5 = people().query().order(Person_.name).build();
expect(
  query5.find().map((e) => e.name).toList(),
  ['eve', 'Eve', 'EVE', 'EVE1'],
);

final query6 = people()
    .query()
    .order(Person_.name, flags: Order.caseSensitive)
    .build();
expect(
  query6.find().map((e) => e.name).toList(),
  ['EVE', 'EVE1', 'Eve', 'eve'],
);

store.close();
  • デフォルトのソート
    デフォルトでは、orderメソッドを使用すると大文字小文字を区別せずにソートされます。この場合、ソート順は以下の通りになります。
    eveEveEVEEVE1

これは、デフォルトのソートが大文字小文字を区別しないため、小文字のeveが先に来て、大文字のEveEVEEVE1が後に来ることを意味します。暗黙的にidによってソートされている感じ(データの配置の都合と受け取った方が良く、前提としない方が良いでしょう)がします。

  • 大文字小文字を区別したソート
    Order.caseSensitiveフラグを使用すると、大文字小文字を区別してソートされます。この場合、ソート順は以下の通りになります。
    EVEEVE1Eveeve

これは、大文字小文字を区別するソートでは、大文字のEVEEVE1が先に来て、大文字・小文字のEve、小文字だけのeveが後に来ることを意味します。大文字がアルファベット順で優先され、その後に小文字が続く形になります。

制限

このテストでは、検索結果に制限をかけて検索を行います。

  • queryメソッドを使用して検索結果に制限を設定
  • limitを使用して結果の件数を制限
  • offsetを使用して結果の開始位置を指定
final store = await

 getStore();
people() => store.box<Person>();
people().putMany(getPeople());

final query1 = people().query().order(Person_.height).build();
expect(query1.find().map((e) => e.name).toList(),
    ['Diana', 'Alice', 'Eve', 'Bob', 'Charlie']);

final query2 = people().query().order(Person_.height).build()..limit = 2;
expect(query2.find().map((e) => e.name).toList(), ['Diana', 'Alice']);

final query3 = people().query().order(Person_.height).build()..offset = 3;
expect(query3.find().map((e) => e.name).toList(), ['Bob', 'Charlie']);

store.close();

find/findFirst

このテストでは、findおよびfindFirstメソッドによるデータの検索を行います。

  • queryメソッドを使用してオブジェクトを検索
  • findメソッドを使用して複数の結果を取得
  • findFirstメソッドを使用して一つだけ結果を取得
final store = await getStore();
people() => store.box<Person>();
people().putMany(getPeople());

final query1 = people().query().order(Person_.name).build();
expect(query1.find().length, 5);
expect(query1.findFirst()!.name, 'Alice');

final query2 = people().query(Person_.name.equals('Anne')).build();
expect(query2.find().length, 0);
expect(query2.findFirst(), null);

store.close();

findUnique

このテストでは、findUniqueメソッドによるデータの検索を行います。

  • queryメソッドを使用してオブジェクトを検索
  • findUniqueメソッドを使用して結果を確認
    • データがあったとき: findUniqueメソッドは該当するオブジェクトを返します。
    • データがないとき: findUniqueメソッドはnullを返します。
    • 複数あったとき: findUniqueメソッドは例外が発生する。
final store = await getStore();
people() => store.box<Person>();
people().putMany(getPeople());

final query1 = people().query(Person_.name.equals('Alice')).build();
expect(query1.findUnique()!.name, 'Alice');

final query2 = people().query(Person_.name.equals('Anne')).build();
expect(query2.findUnique(), null);

final query3 = people().query().build();
expect(
    () => query3.findUnique(),
    throwsA(isA<ObjectBoxException>().having(
        (e) => e.message,
        'check message',
        'Query findUnique() matched more than one object')));

store.close();

プロパティ

このグループでは、Personオブジェクトの特定のプロパティに関する操作をテストします。

ユニーク

このテストでは、ユニークなプロパティの検索を行います。

  • queryメソッドを使用してクエリを作成
  • distinctプロパティで重複を排除
  • orderメソッドで並び替えをしたつもりですが、されてない。無効か。
final store = await getStore();
people() => store.box<Person>();
people().putMany(getPeople());

final query = people()
    .query()
    .order(Person_.club)
    .build()
    .property(Person_.club)
  ..distinct = true;

// order(Person_.club)が無効?
expect(query.find(), ['Tennis', 'Football']);
store.close();

集計

このテストでは、特定のプロパティに関する集計操作を行います。

  • queryメソッドを使用してクエリを作成
  • propertyメソッドで特定のプロパティに対する操作を実行
  • 件数、最小値、最大値、合計、平均値を取得
final store = await getStore();
people() => store.box<Person>();
people().putMany(getPeople());

final query = people().query().build();
expect(query.count(), 5);
expect(query.property(Person_.group).min(), 1);
expect(query.property(Person_.group).max(), 3);
expect(query.property(Person_.group).sum(), 1 + 2 + 1 + 3 + 2);
expect(query.property(Person_.height).average(),
    (165.5 + 172.3 + 180.2 + 158.0 + 167.8) / 5);

final distinctGroup = query.property(Person_.group)..distinct = true;
expect(distinctGroup.count(), 3);

store.close();

Stream

Streamを使用したリアクティブプログラミングのテストを行います。データの変更を監視し、listenCompleterを組み合わせて非同期にテストを行います。
emitsInOrderを使用したテストを試みましたが、できませんでした。記事の最後には付けておきます。動作するコードを作れたら教えてください!

listenとcompleterを組み合わせ

このテストでは、Personオブジェクトの変更を監視し、データが正しくストリームに反映されているかを確認します。

  • queryメソッドとwatchメソッドを使用してデータの変更を監視
  • listenメソッドを使用してデータの変更を非同期に受け取る
  • Completerを使用して非同期処理を待機
  • Completerの中身を確認して、データが正しくストリームに反映されているかを確認
  • そして新しいCompleterを作成して非同期処理を待機
final store = await getStore();
final box = store.box<Person>();

var completer = Completer<Query<Person>>();

box
    .query()
    .watch(triggerImmediately: true)
    .listen((Query<Person> puttedPeople) {
  completer.complete(puttedPeople);
});

final [alice, bob, ...] = getPeople();

box.put(alice);
final aliceDone = await completer.future;
expect(aliceDone.count(), 1);
expect(aliceDone.find()[0].name, 'Alice');

completer = Completer<Query<Person>>();
box.put(bob);

final bobDone = await completer.future;
expect(bobDone.count(), 2);
expect(bobDone.property(Person_.name).find(), ['Alice', 'Bob']);

store.close();

このテストでは、Personオブジェクトを保存するたびにストリームがトリガーされ、その変更を監視して正しくデータが更新されていることを確認します。Completerを使用することで、非同期処理が完了するまで待機し、テストを進めることができます。

Annotation

このグループでは、ObjectBoxのアノテーション機能に関するテストを行います。

AnnotatedEntityクラスの紹介

まず、ObjectBoxで使用するためのエンティティクラスAnnotatedEntityを定義します。このクラスでは、さまざまなアノテーションを使用してデータベースの動作をカスタマイズしています。

@Entity()
class AnnotatedEntity {
  @Id()
  int id = 0;

  @Transient()
  int? notPersisted;

  @Index()
  String uid;

  @Index()
  String? uidNullable;

  @Unique()
  String? name;

  int? _age;

  int? get age1 => _age;
  set age1(int? value) => _age = value;

  int get age2 => _age ?? 0;
  set age2(int value) => _age = value;

  AnnotatedEntity(this.uid);
}

このクラスでは、以下のようなアノテーションを使用しています:

  • @Id(): エンティティのIDを示します。IDは自動的に管理されます。
  • @Transient(): 永続化しないフィールドを示します。データベースには保存されません。
  • @Index(): インデックスを作成し、検索パフォーマンスを向上させます。
  • @Unique(): 一意制約を設定します。このフィールドは一意でなければなりません。

また、カスタムゲッターとセッターを使用してプロパティage1age2を管理しています。

Index

このテストでは、Indexアノテーションを使ってインデックスを作成できます。一般的なDBと異なり検索パフォーマンスを向上させる目的でない印象を受けました。
IDが自動的に管理されるので、それとは別に独自のIDを作りたいときにIndexを使用します。

final store = await getStore();
final box = store.box<AnnotatedEntity>();
final entity1 = AnnotatedEntity('uid1');
final entity2 = AnnotatedEntity('uid2');
box.putMany([entity1, entity2]);

final result = box.query(AnnotatedEntity_.uid.equals('uid1')).build().find();
expect(result.length, 1);
expect(result[0].uid, 'uid1');

final resultMany = box
    .query(AnnotatedEntity_.uid.oneOf(['uid1', 'uid2']))
    .build()
    .find();
expect(resultMany.length, 2);
expect(resultMany[0].uid, 'uid1');
expect(resultMany[1].uid, 'uid2');

store.close();

Unique – nullは被っても大丈夫

このテストでは、Uniqueアノテーションを使用して一意制約を設定し、null値の重複が許容されることを確認します。

  • Uniqueでもnull値の一意制約が適用されないことを確認
final store = await getStore();
final box = store.box<AnnotatedEntity>();
final entity1 = AnnotatedEntity('uid1');
entity1.name = 'name';
final entity2 = AnnotatedEntity('uid2');
final entity3 = AnnotatedEntity('uid3');
box.putMany([entity1, entity2, entity3]);

store.close();

Unique – uniqueが被ると、例外発生

このテストでは、Uniqueアノテーションによる一意制約が重複すると例外が発生することを確認します。

  • putManyメソッドで重複する一意制約のエンティティを保存
  • 一意制約の重複により例外が発生することを確認
final store = await getStore();
final box = store.box<AnnotatedEntity>();
final entity1 = AnnotatedEntity('uid1');
entity1.name = 'name';
final entity2 = AnnotatedEntity('uid2');
entity2.name = 'name';
expect(() => box.putMany([entity1, entity2]),
    throwsA(isA<ObjectBoxException>()));

store.close();

Transient

このテストでは、Transientアノテーションを使ってエンティティの特定のフィールドを永続化しないようにする方法を確認します。

  • putメソッドでエンティティを保存
  • Transientフィールドが保存されないことを確認
final store = await getStore();
final box = store.box<AnnotatedEntity>();
final entity = AnnotatedEntity('uid');
entity.notPersisted = 100;
box.put(entity);
expect(entity.notPersisted, 100);

final result = box.getAll();
expect(result.length, 1);
expect(result[0].uid, 'uid');
expect(result[0].notPersisted, isNull);

store.close();

property

このテストでは、カスタムゲッターとセッターを使用したプロパティの操作を確認します。

  • ゲッターとセッターを使用してプロパティを設定
  • putManyメソッドで複数のエンティティを保存
  • ゲッターとセッターを使用してプロパティを取得
final store = await getStore();
final box = store.box<AnnotatedEntity>();
final entity1 = AnnotatedEntity('uid1');
entity1.age1 = 1;
expect(entity1.age1, 1);

final entity2 = AnnotatedEntity('uid2');
entity2.age2 = 2;
expect(entity2.age2, 2);

final entity3 = AnnotatedEntity('uid3');
expect(entity3.age1, isNull);
expect(entity3.age2, 0);

box.putMany([entity1, entity2, entity3]);

final result = box.getAll();
expect(result.length, 3);
expect(result[0].age1, 1);
expect(result[0].age2, 1);
expect(result[1].age1, 2);
expect(result[1].age2, 2);
expect(result[2].age1, isNull);
expect(result[2].age2, 0);

store.close();

Vector Database

Vector Databaseとは

ObjectBoxのVector Databaseは、デバイス上で高速に動作するベクトルデータベースです。これは、ベクトル(特徴ベクトルなど)の検索と操作を効率的に行うためのもので、機械学習やAIアプリケーションに最適です。

ベクトルデータベースは、特徴ベクトルの類似性検索を行うために特化したデータベースです。ObjectBoxのベクトルデータベースは、特にモバイルやエッジデバイスでの使用に最適化されており、高速かつ省電力で動作します。

一致データ、近いデータの取得

ベクターデータベースの特徴として、近似のベクターのデータを取得できます。
Storeに複数のVectorEntityオブジェクトを保存し、ベクトルデータの一致および近いデータの検索を行います。

  • nearestNeighborsF32: 指定したベクトルに最も近いデータを検索するメソッド
    • 第一引数: 検索対象のベクトル(リスト形式)
    • 第二引数: 取得する近似データの数

@Entity()
class VectorEntity {
  @Id()
  int id = 0;

  String message;

  @HnswIndex(dimensions: 3)
  @Property(type: PropertyType.floatVector)
  List<double> vectors;
  VectorEntity(this.message, this.vectors);
}


List<VectorEntity> vectorEntities = [
    VectorEntity('First vector', [0.1, 0.2, 0.3]),
    VectorEntity('Second vector', [0.4, 0.5, 0.6]),
    VectorEntity('Third vector', [0.7, 0.8, 0.9]),
];

final store = await getStore();
store.box<VectorEntity>().putMany(vectorEntities);

final queryEquals = store
    .box<VectorEntity>()
    .query(VectorEntity_.vectors.nearestNeighborsF32([0.4, 0.5, 0.6], 1));
final result1 = queryEquals.build().find();
expect(result1.length, 1);
expect(result1[0].message, 'Second vector');

final queryNearest = store
    .box<VectorEntity>()
    .query(VectorEntity_.vectors.nearestNeighborsF32([0.4, 0.6, 0.6], 2));
final result2 = queryNearest.build().find();
expect(result2.length, 2);
expect(result2[0].message, 'Second vector');
expect(result2[1].message, 'Third vector');

store.close();

このテストでは、まずStoreにVectorEntityオブジェクトを保存します。
次に、近いベクトルを検索し、結果が期待通りであることを確認します。
nearestNeighborsF32([0.4, 0.5, 0.6], 1)は、ベクトル[0.4, 0.5, 0.6]に近い(今回は一致している)データを1件取得します。nearestNeighborsF32([0.4, 0.6, 0.6], 2)は、ベクトル[0.4, 0.6, 0.6]に近いデータを2件取得します。

このように、nearestNeighborsF32を使用することで、指定したベクトルに最も近いデータを簡単に取得することができます。

アプリケーションでの活用例

ベクトルデータベースは、以下のようなアプリケーションで活用できます。

  • レコメンデーションシステム: ユーザーの好みをベクトルとして保存し、類似したアイテムを推薦。
  • 画像認識: 画像の特徴ベクトルを保存し、類似画像の検索。
  • 自然言語処理: 単語や文の埋め込みベクトルを保存し、類似性検索を行う。

ObjectBoxのベクトルデータベースは、高速で効率的な検索と操作を提供し、機械学習やAIアプリケーションのパフォーマンスを大幅に向上させます。これにより、エッジデバイスでも高度なデータ処理が可能になります。

FlutterでのStreamリアクティブ使用方法

ObjectBoxのセットアップ

ObjectBoxをFlutterで使用するために、以下のコードを使って初期設定を行います。

import 'dart:async';
import 'dart:io';

import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:objectbox/objectbox.dart';
import 'objectbox.g.dart'; // generated code

@Entity()
class CounterEntity {
  @Id()
  int id = 0;

  final int value;
  late bool isOdd = (value % 2 == 1);

  CounterEntity(this.value);
}

CounterEntityクラスは、@Entityアノテーションを使用してObjectBoxのエンティティとして定義されています。このクラスは以下のフィールドを持ちます。

  • id: エンティティのID。ObjectBoxが管理します。
  • value: カウンタの値。
  • isOdd: 値が奇数かどうかを示すフラグ。

この設定により、ObjectBoxを使用したデータベース操作が可能になります。

初期化とストリームの作成

initStateの中で、ApplicationDocumentsDirectoryを取得し、Storeを作成します。サンプルコードのため、以前に作成されたデータベースディレクトリがあれば削除し、新規のデータベースとして実行できるようにしています。実際のアプリでは削除しないでください。

Store? _store;
Stream<Query<CounterEntity>>? _stream;

@override
void initState() {
  super.initState();

  getApplicationDocumentsDirectory().then((dir) async {
    final path = Directory('${dir.path}/object_box_test');
    if (path.existsSync()) {
      path.delete(recursive: true);
    }

    _store = await openStore(directory: path.path);
    final newStream = createStream(false);
    setState(() => _stream = newStream);
  });
}

forループとFuture.delayedを使用して、1秒間隔でデータを追加します。putメソッドを使用して、CounterEntityオブジェクトをストアに保存します。
これにより、データベースが初期化され、1秒ごとに新しいデータが追加される仕組みが実現されます。画面にはリアルタイムでデータの変化が反映されるため、データベース操作の結果を直感的に確認できます。

@override
void initState() {
    // 1秒間隔でデータを追加する
    for (int i = 0; i < 5; i++) {
      await Future.delayed(const Duration(seconds: 1));
      _store?.box<CounterEntity>().put(CounterEntity(i));
    }
  });
}

ストリームの作成と更新

createStream関数では、奇数のみを表示するかどうかの条件を設定し、ストリームを作成します。

Stream<Query<CounterEntity>> createStream(bool isOddOnly) {
  return _store!
      .box<CounterEntity>()
      .query(isOddOnly ? _oddOnlyCondition : null)
      .watch(triggerImmediately: true);
}

StreamBuilderの使用

StreamBuilderを使用して、データベースの変更が画面に自動的に反映されるようにします。

@override
Widget build(BuildContext context) {
  return Scaffold(
    body: Column(
      children: [
        Expanded(
          child: StreamBuilder(
              stream: _stream,
              builder: (builder, snapshot) {
                final entities = snapshot.data?.find() ?? [];

                return ListView.builder(
                  itemCount: entities.length,
                  itemBuilder: (context, index) {
                    return ListTile(
                      title: Text(entities[index].value.toString()),
                    );
                  },
                );
              }),
        )
      ],
    ),
  );
}
  • StreamBuilderのstreamプロパティ: _stream変数は、initStateメソッドで設定されたObjectBoxのストリームです。このストリームにより、データの変更が監視されます。
  • snapshotの取り扱い: builderメソッドの中でsnapshotを使用し、ストリームから取得したデータを処理します。snapshot.data?.find() ?? []を使用して、データが存在しない場合は空のリストを返します。
  • ListView.builder: リストビューを動的に作成するためにListView.builderを使用します。itemCountにはデータの長さを指定し、itemBuilderでリストアイテムを作成します。
  • ListTileの使用: 各リストアイテムをListTileウィジェットで表示し、CounterEntityの値をテキストとして表示します。

これにより、データベースの変更がリアルタイムで画面に反映され、ユーザーが即座に確認できるようになります。

チェックボックスで条件を切り替える

チェックボックスの状態を変更することで、表示されるデータの条件を切り替えます。

var _isOddChecked = false;

Checkbox(
    value: _isOddChecked,
    onChanged: (value) {
      if (value == null) {
        return;
      }

      final newStream = createStream(value);

      setState(() {
        _isOddChecked = value;
        _stream = newStream;
      });
    })
  • ストリームの再生成: createStreamメソッドを呼び出して新しいストリームを生成します。valuetrueの場合、奇数のみを表示する条件が適用されます。
  • setStateの使用: setStateメソッドを使用して、チェックボックスの状態とストリームを更新します。これにより、画面が再描画され、新しい条件に基づいたデータが表示されます。特に、_streamsetStateで更新されることでStreamBuilderも新しくなり、全部・奇数のみの条件が切り替わった状態で表示されます。(setStateで更新してなかったため、条件が切り替わらず、苦労しましたorz)

データの追加

プラスボタンを押すことでデータを追加し、ストリームに反映させます。
putメソッドを使用して新しいCounterEntityオブジェクトをデータベースに追加します。CounterEntityのコンストラクタには、現在のデータの数に1を足した値を渡します。この方法により、新しいデータが画面で一意の値を持つようになります。

floatingActionButton: FloatingActionButton(
  onPressed: () async {
    final length = _store?.box<CounterEntity>().count();
    _store?.box<CounterEntity>().put(CounterEntity(length ?? -100 + 1));
  },
  child: const Icon(Icons.add),
)

ストアのクローズ

disposeメソッドでストアをクローズし、データベースを終了させます。

@override
void dispose() {
  _store?.close();
  super.dispose();
}

発生したエラー

Invalid argument(s): Failed to load dynamic library 'libobjectbox-jni.so': dlopen failed: library "libobjectbox-jni.so" not found

objectbox_flutter_libsを依存関係に追加し忘れていた。追加して解決。

StorageException: failed to create store: Could not prepare directory: objectbox (30: Read-only file system)

openStoreで渡したディレクトリに書き込み権限がなかった。別のディレクトリを指定して解決。

Invalid argument(s): object put failed: ID is higher or equal to internal ID sequence: 1 (vs. 1). Use ID 0 (zero) to insert new objects. (OBX_ERROR code 10002)

新規作成したEntitiyのIDに0以外を設定した場合に発生する。

Invalid argument(s): Failed to load dynamic library 'objectbox.dll': The specified module could not be found. (error code: 126)

Windowsでユニットテストしようとしていたときに発生。「Windowsのセットアップ(ユニットテスト用)」を実施して解決。

Failed to load ObjectBox library. For Flutter apps, check if objectbox_flutter_libs is added to dependencies. For unit tests and Dart apps, check if the ObjectBox library was downloaded (https://docs.objectbox.io/getting-started).

Invalid argument(s): Cannot use the default constructor of ‘CannotBuildEntity’: don’t know how to initialize param storeTest – no such property.

build_runner実行時に発生。コンストラクタにStoreを入れていたのが原因。コンストラクタから外した。コンストラクタにプリミティブでない値を引数に入れると、発生するっぽい(Transientアノテーションをつけても)。
DDDのつもりでコンストラクタにStoreを入れて処理しようとしたら大失敗。各メソッドの引数にStoreを含めるようにしました

Q&A

Q1: ObjectBoxを使うメリットは何ですか?

A1: ObjectBoxは高速で効率的なローカルデータベースで、特にモバイルアプリやIoTデバイスに最適です。CPUやメモリの使用量が少なく、バッテリー寿命を延ばすことができます。また、直感的なAPIにより、開発者が迅速にデータ操作を行える点も大きなメリットです。

Q2: ObjectBoxのセットアップ方法は簡単ですか?

A2: はい、ObjectBoxのセットアップは簡単です。pubspec.yamlに依存関係を追加し、コード生成ツールをインストールするだけで始められます。次に、エンティティを定義し、トランザクションを使用してデータを効率的に操作できます。

Q3: ObjectBoxのクエリ機能はどのように役立ちますか?

A3: ObjectBoxのクエリ機能は、複雑な検索条件を簡単に設定でき、データベースから必要な情報を迅速に取得するのに役立ちます。クエリビルダーを使用することで、柔軟かつ効率的にデータ検索を行えます。

まとめ

この記事を通じて、ObjectBoxのセットアップから基本的なCRUD操作、クエリ機能、ストリームの活用方法まで学びました。具体的には、ObjectBoxの高速で効率的なデータベースの特性、エンティティの定義方法、ストアの初期化、リアクティブなデータ表示方法について理解しました。また、条件付きクエリやソート、制限付き検索、Vector Databaseの活用方法についても深く勉強しました。これらの知識を活かし、モバイルアプリ開発の効率と品質を向上させることができると確信しています。

参考

ソース(main.dartにコピペして動作確認用)

ユニットテストでの動作検証

import 'dart:async';

import 'package:flutter_test/flutter_test.dart';
import 'package:objectbox/objectbox.dart';

import 'objectbox.g.dart'; // generated code

@Entity()
class Person {
  @Id()
  int id = 0;

  int group;
  String name;
  double height;
  DateTime birthday;
  String? club;

  Person(this.group, this.name, this.height, this.birthday, {this.club});
}

@Entity()
class VectorEntity {
  @Id()
  int id = 0;

  String message;

  @HnswIndex(dimensions: 3)
  @Property(type: PropertyType.floatVector)
  List<double> vectors;
  VectorEntity(this.message, this.vectors);
}

void main() async {
  Future<Store> getStore() async {
    await Future.delayed(const Duration(milliseconds: 10));
    return openStore(
        directory:
            'memory:object-box-${DateTime.now().millisecondsSinceEpoch}');
  }

  group('CRUD', () {
    test('単体データ', () async {
      final store = await getStore();
      final person1 = Person(1, 'Alice', 165.5, DateTime(1990, 5, 17));
      final newId = store.box<Person>().put(person1);
      expect(newId, 1);

      final person2 = store.box<Person>().get(newId);
      expect(person2, isNotNull);
      expect(person2!.id, 1);
      expect(person2.name, 'Alice');
      expect(person2.height, 165.5);

      person2.height = 166.0;
      final updatedId = store.box<Person>().put(person2);
      expect(updatedId, 1);

      final person3 = store.box<Person>().get(newId);
      expect(person3!.name, 'Alice');
      expect(person3.height, 166.0);

      store.box<Person>().remove(1);
      expect(store.box<Person>().getAll().length, 0);

      store.close();
    });

    test('複数データ', () async {
      final store = await getStore();
      final person1 = Person(1, 'Alice', 165.5, DateTime(1990, 5, 17));
      Person person2 = Person(2, 'Bob', 172.3, DateTime(1985, 8, 23));
      final newId = store.box<Person>().putMany([person1, person2]);
      expect(newId, [1, 2]);

      final people = store.box<Person>().getAll();
      expect(people.length, 2);

      expect(people[0].name, 'Alice');
      expect(people[0].height, 165.5);
      expect(people[1].name, 'Bob');
      expect(people[1].height, 172.3);

      people[0].height = 170.0;
      people[1].height = 173.0;
      store.box<Person>().putMany(people);

      expect(people[0].name, 'Alice');
      expect(people[0].height, 170.0);
      expect(people[1].name, 'Bob');
      expect(people[1].height, 173.0);

      expect(store.box<Person>().getAll().length, 2);
      store.box<Person>().removeMany([1, 2]);
      expect(store.box<Person>().getAll().length, 0);

      store.box<Person>().putMany(people);
      expect(store.box<Person>().getAll().length, 2);
      store.box<Person>().removeAll();
      expect(store.box<Person>().getAll().length, 0);

      store.close();
    });

    test('非同期', () async {
      final store = await getStore();
      final person1 = Person(1, 'Alice', 165.5, DateTime(1990, 5, 17));
      final newId = await store.box<Person>().putAsync(person1);
      expect(newId, 1);

      final newIds = store.box<Person>().putManyAsync([
        Person(2, 'Bob', 172.3, DateTime(1985, 8, 23)),
        Person(1, 'Charlie', 180.2, DateTime(1992, 1, 10), club: 'Football')
      ]);
      expect(await newIds, [2, 3]);

      final person2 =
          Person(3, 'Diana', 158.0, DateTime(1995, 3, 5), club: 'Tennis');
      final person2_ = await store.box<Person>().putAndGetAsync(person2);
      expect(person2_.id, 4);
      expect(person2_.name, 'Diana');

      final people = await store.box<Person>().putManyAsync([
        Person(2, 'Eve', 167.8, DateTime(1988, 7, 30)),
        Person(3, 'Frank', 175.0, DateTime(1983, 11, 11), club: 'Chess'),
      ]);
      expect(people, [5, 6]);

      final person3 = store.box<Person>().putQueued(
          Person(1, 'Grace', 162.4, DateTime(1996, 4, 25), club: 'Swimming'));
      expect(person3, 7);

      store.close();
    });
  });

  List<Person> getPeople() => [
        Person(1, 'Alice', 165.5, DateTime(1990, 5, 17), club: 'Tennis'),
        Person(2, 'Bob', 172.3, DateTime(1985, 8, 23)),
        Person(1, 'Charlie', 180.2, DateTime(1992, 1, 10), club: 'Football'),
        Person(3, 'Diana', 158.0, DateTime(1995, 3, 5), club: 'Tennis'),
        Person(2, 'Eve', 167.8, DateTime(1988, 7, 30)),
      ];

  group('検索', () {
    test('IDの状態(putするとIDが変わる)', () async {
      final store = await getStore();
      people() => store.box<Person>();

      final person = getPeople()[0];
      expect(person.id, 0);
      people().put(person);
      expect(person.id, 1);

      // getPeople()が常に新しいデータを作ってくれることを確認
      expect(getPeople()[0].id, 0);

      store.close();
    });

    test('IDによる検索', () async {
      final store = await getStore();
      people() => store.box<Person>();

      people().putMany(getPeople());
      expect(people().get(1), isNotNull);
      expect(people().get(5), isNotNull);
      expect(people().get(6), isNull);
      expect(people().get(99), isNull);

      store.close();
    });

    test('一致条件による検索', () async {
      final store = await getStore();
      people() => store.box<Person>();

      people().putMany(getPeople());
      final query0 = people().query(Person_.group.equals(0)).build();
      expect(query0.find().length, 0);

      final query1 = people().query(Person_.group.equals(1)).build();
      expect(query1.find().length, 2);

      final query2 = people().query(Person_.group.notEquals(1)).build();
      expect(query2.find().length, 3);

      store.close();
    });

    test('範囲条件による検索', () async {
      final store = await getStore();
      people() => store.box<Person>();
      people().putMany(getPeople());

      final query1 = people().query(Person_.height.greaterThan(165.5)).build();
      expect(query1.find().length, 3);

      final query2 =
          people().query(Person_.height.greaterOrEqual(165.5)).build();
      expect(query2.find().length, 4);

      final query3 = people().query(Person_.height.lessThan(165.5)).build();
      expect(query3.find().length, 1);

      final query4 = people().query(Person_.height.lessOrEqual(165.5)).build();
      expect(query4.find().length, 2);

      final query5 =
          people().query(Person_.height.between(165.5, 172.3)).build();
      expect(query5.find().length, 3);

      store.close();
    });

    test('プロパティがnullまたはnullでない', () async {
      final store = await getStore();
      people() => store.box<Person>();
      people().putMany(getPeople());

      final query1 = people().query(Person_.club.isNull()).build();
      expect(query1.find().length, 2);

      final query2 = people().query(Person_.club.notNull()).build();
      expect(query2.find().length, 3);

      store.close();
    });

    test('指定した配列のいずれかと一致する値', () async {
      final store = await getStore();
      people() => store.box<Person>();
      people().putMany(getPeople());

      final query1 = people().query(Person_.group.oneOf([1, 3])).build();
      expect(query1.find().length, 3);

      final query2 = people().query(Person_.group.notOneOf([1, 3])).build();
      expect(query2.find().length, 2);

      store.close();
    });

    test('文字列の部分一致', () async {
      final store = await getStore();
      people() => store.box<Person>();
      people().putMany(getPeople());

      final query1 = people().query(Person_.name.startsWith('A')).build();
      expect(query1.find().length, 1);

      final query2 = people().query(Person_.name.endsWith('e')).build();
      expect(query2.find().length, 3);

      final query3 = people().query(Person_.name.contains('a')).build();
      expect(query3.find().length, 2);

      store.close();
    });

    test('AND条件による検索', () async {
      final store = await getStore();
      people() => store.box<Person>();
      people().putMany(getPeople());

      // AND条件のクエリ: groupが1かつheightが170未満の人を検索
      final queryAnd = people()
          .query(Person_.group.equals(1).and(Person_.height.lessThan(170)))
          .build();
      List<Person> resultsAnd = queryAnd.find();
      expect(resultsAnd.map((p) => p.name).toList(), ['Alice']);

      store.close();
    });

    test('OR条件による検索', () async {
      final store = await getStore();
      people() => store.box<Person>();
      people().putMany(getPeople());

      // OR条件のクエリ: groupが1またはheightが170未満の人を検索
      final queryOr = people()
          .query(Person_.group.equals(1).or(Person_.height.lessThan(170)))
          .build();
      List<Person> resultsOr = queryOr.find();
      expect(resultsOr.map((p) => p.name).toList(),
          ['Alice', 'Charlie', 'Diana', 'Eve']);

      store.close();
    });

    test('ANDとORを組み合わせた条件による検索', () async {
      final store = await getStore();
      people() => store.box<Person>();
      people().putMany(getPeople());

      // ANDとORを組み合わせたクエリ: heightが175未満かつ(groupが1またはclubがnullの人を検索)
      final queryCombined = people()
          .query(Person_.height
              .lessThan(175)
              .and(Person_.group.equals(1).or(Person_.club.isNull())))
          .build();
      List<Person> resultsCombined = queryCombined.find();
      expect(
          resultsCombined.map((p) => p.name).toList(), ['Alice', 'Bob', 'Eve']);

      store.close();
    });

    test('ソート', () async {
      final store = await getStore();
      people() => store.box<Person>();
      people().putMany(getPeople());

      final query1 = people().query().order(Person_.height).build();
      expect(query1.find().map((e) => e.name).toList(),
          ['Diana', 'Alice', 'Eve', 'Bob', 'Charlie']);

      final query2 = people()
          .query()
          .order(Person_.height, flags: Order.descending)
          .build();
      expect(query2.find().map((e) => e.name).toList(),
          ['Charlie', 'Bob', 'Eve', 'Alice', 'Diana']);

      final query3 =
          people().query().order(Person_.group).order(Person_.height).build();
      expect(query3.find().map((e) => e.name).toList(),
          ['Alice', 'Charlie', 'Eve', 'Bob', 'Diana']);

      final query4 = people()
          .query()
          .order(Person_.name, flags: Order.descending | Order.caseSensitive)
          .build();
      expect(query4.find().map((e) => e.name).toList(),
          ['Eve', 'Diana', 'Charlie', 'Bob', 'Alice']);

      store.close();
    });

    test('ソート: Order.caseSensitive', () async {
      final store = await getStore();
      people() => store.box<Person>();
      people().putMany([
        Person(2, 'eve', 167.8, DateTime(1988, 7, 30)),
        Person(2, 'Eve', 167.8, DateTime(1988, 7, 30)),
        Person(2, 'EVE', 167.8, DateTime(1988, 7, 30)),
        Person(2, 'EVE1', 167.8, DateTime(1988, 7, 30)),
      ]);

      final query5 = people().query().order(Person_.name).build();
      expect(
        query5.find().map((e) => e.name).toList(),
        ['eve', 'Eve', 'EVE', 'EVE1'],
      );

      final query6 = people()
          .query()
          .order(Person_.name, flags: Order.caseSensitive)
          .build();
      expect(
        query6.find().map((e) => e.name).toList(),
        ['EVE', 'EVE1', 'Eve', 'eve'],
      );

      store.close();
    });

    test('制限', () async {
      final store = await getStore();
      people() => store.box<Person>();
      people().putMany(getPeople());

      final query1 = people().query().order(Person_.height).build();
      expect(query1.find().map((e) => e.name).toList(),
          ['Diana', 'Alice', 'Eve', 'Bob', 'Charlie']);

      final query2 = people().query().order(Person_.height).build()..limit = 2;
      expect(query2.find().map((e) => e.name).toList(), ['Diana', 'Alice']);

      final query3 = people().query().order(Person_.height).build()..offset = 3;
      expect(query3.find().map((e) => e.name).toList(), ['Bob', 'Charlie']);

      store.close();
    });

    test('find/findFirst', () async {
      final store = await getStore();
      people() => store.box<Person>();
      people().putMany(getPeople());

      final query1 = people().query().order(Person_.name).build();
      expect(query1.find().length, 5);
      expect(query1.findFirst()!.name, 'Alice');

      final query2 = people().query(Person_.name.equals('Anne')).build();
      expect(query2.find().length, 0);
      expect(query2.findFirst(), null);

      store.close();
    });

    test('findUnique', () async {
      final store = await getStore();
      people() => store.box<Person>();
      people().putMany(getPeople());

      final query1 = people().query(Person_.name.equals('Alice')).build();
      expect(query1.findUnique()!.name, 'Alice');

      final query2 = people().query(Person_.name.equals('Anne')).build();
      expect(query2.findUnique(), null);

      final query3 = people().query().build();
      expect(
          () => query3.findUnique(),
          throwsA(isA<ObjectBoxException>().having(
              (e) => e.message,
              'check message',
              'Query findUnique() matched more than one object')));

      store.close();
    });
  });

  group('プロパティ', () {
    test('NULLは削除、NULLはHOMEに変換', () async {
      final store = await getStore();
      people() => store.box<Person>();
      people().putMany(getPeople());

      expect(people().query().build().property(Person_.club).find(),
          ['Tennis', 'Football', 'Tennis']);

      expect(
          people()
              .query()
              .build()
              .property(Person_.club)
              .find(replaceNullWith: 'HOME'),
          ['Tennis', 'HOME', 'Football', 'Tennis', 'HOME']);

      store.close();
    });

    test('ユニーク', () async {
      final store = await getStore();
      people() => store.box<Person>();
      people().putMany(getPeople());

      final query = people()
          .query()
          .order(Person_.club)
          .build()
          .property(Person_.club)
        ..distinct = true;

      // order(Person_.club)が無効?
      expect(query.find(), ['Tennis', 'Football']);
      store.close();
    });

    test('集計', () async {
      final store = await getStore();
      people() => store.box<Person>();
      people().putMany(getPeople());

      final query = people().query().build();
      expect(query.count(), 5);
      expect(query.property(Person_.group).min(), 1);
      expect(query.property(Person_.group).max(), 3);
      expect(query.property(Person_.group).sum(), 1 + 2 + 1 + 3 + 2);
      expect(query.property(Person_.height).average(),
          (165.5 + 172.3 + 180.2 + 158.0 + 167.8) / 5);

      final distinctGroup = query.property(Person_.group)..distinct = true;
      expect(distinctGroup.count(), 3);

      store.close();
    });
  });

  group('Stream', () {
    test('Streamのテストをどうすれば良いか[listenとcompleterを組み合わせる]', () async {
      final store = await getStore();
      final box = store.box<Person>();

      var completer = Completer<Query<Person>>();

      box
          .query()
          .watch(triggerImmediately: true)
          .listen((Query<Person> puttedPeople) {
        completer.complete(puttedPeople);
      });

      final [alice, bob, ...] = getPeople();

      box.put(alice);
      final aliceDone = await completer.future;
      expect(aliceDone.count(), 1);
      expect(aliceDone.find()[0].name, 'Alice');

      completer = Completer<Query<Person>>();
      box.put(bob);

      final bobDone = await completer.future;
      expect(bobDone.count(), 2);
      expect(bobDone.property(Person_.name).find(), ['Alice', 'Bob']);

      store.close();
    });

    test('emitsInOrderを使用した方法は分からず', () async {
      final store = await getStore();
      final box = store.box<Person>();
      final stream = box
          .query()
          .watch(triggerImmediately: true)
          .map((e) => e.property(Person_.name));

      final [alice, bob, ...] = getPeople();
      /*
      expectLater(
        stream,
        emitsInOrder([
          [PropertyQuery<String>', bob],
          [alice, bob],
          [alice, bob],
        ]),
      );*/

      box.put(alice);
      box.put(bob);

      await Future.delayed(const Duration(milliseconds: 100));
      store.close();
    });
  });

  group('ベクターDB', () {
    List<VectorEntity> vectorEntities = [
      VectorEntity('First vector', [0.1, 0.2, 0.3]),
      VectorEntity('Second vector', [0.4, 0.5, 0.6]),
      VectorEntity('Third vector', [0.7, 0.8, 0.9]),
    ];

    test('一致データ、近いデータ', () async {
      final store = await getStore();
      store.box<VectorEntity>().putMany(vectorEntities);

      final queryEquals = store
          .box<VectorEntity>()
          .query(VectorEntity_.vectors.nearestNeighborsF32([0.4, 0.5, 0.6], 1));
      final result1 = queryEquals.build().find();
      expect(result1.length, 1);
      expect(result1[0].message, 'Second vector');

      final queryNearest = store
          .box<VectorEntity>()
          .query(VectorEntity_.vectors.nearestNeighborsF32([0.4, 0.6, 0.6], 2));
      final result2 = queryNearest.build().find();
      expect(result2.length, 2);
      expect(result2[0].message, 'Second vector');
      expect(result2[1].message, 'Third vector');

      store.close();
    });
  });
}

Flutterでの動作

import 'dart:async';
import 'dart:io';

import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:objectbox/objectbox.dart';
import 'objectbox.g.dart'; // generated code

@Entity()
class CounterEntity {
  @Id()
  int id = 0;

  final int value;
  late bool isOdd = (value % 2 == 1);

  CounterEntity(this.value);
}

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: MyHomePage(),
      theme: ThemeData(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage();

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  Store? _store;

  var _isOddChecked = false;

  final _oddOnlyCondition = CounterEntity_.isOdd.equals(true);

  Stream<Query<CounterEntity>>? _stream;

  @override
  void initState() {
    super.initState();

    getApplicationDocumentsDirectory().then((dir) async {
      final path = Directory('${dir.path}/object_box_test');
      if (path.existsSync()) {
        path.delete(recursive: true);
      }

      _store = await openStore(directory: path.path);
      final newStream = createStream(false);
      setState(() => _stream = newStream);

      // 1秒間隔で追加する
      for (int i = 0; i < 5; i++) {
        await Future.delayed(const Duration(seconds: 1));
        _store?.box<CounterEntity>().put(CounterEntity(i));
      }
    });
  }

  @override
  void dispose() async {
    _store?.close();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('ObjectBox Example'),
      ),
      body: Column(
        children: [
          Expanded(
            child: StreamBuilder(
                stream: _stream,
                builder: (builder, snapshot) {
                  final entities = snapshot.data?.find() ?? [];

                  return ListView.builder(
                    itemCount: entities.length,
                    itemBuilder: (context, index) {
                      return ListTile(
                        title: Text(entities[index].value.toString()),
                      );
                    },
                  );
                }),
          ),
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: Row(
              children: [
                const Text('Only odd number displayed'),
                Checkbox(
                    value: _isOddChecked,
                    onChanged: (value) {
                      if (value == null) {
                        return;
                      }

                      final newStream = createStream(value);

                      setState(() {
                        _isOddChecked = value;
                        _stream = newStream;
                      });
                    })
              ],
            ),
          )
        ],
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () async {
          final length = _store?.box<CounterEntity>().count();
          _store?.box<CounterEntity>().put(CounterEntity(length ?? -100 + 1));
        },
        child: const Icon(Icons.add),
      ),
    );
  }

  Stream<Query<CounterEntity>> createStream(bool isOddOnly) {
    return _store!
        .box<CounterEntity>()
        .query(isOddOnly ? _oddOnlyCondition : null)
        .watch(triggerImmediately: true);
  }
}