diff --git a/.github/workflows/web-test.yml b/.github/workflows/web-test.yml new file mode 100644 index 000000000..990d938df --- /dev/null +++ b/.github/workflows/web-test.yml @@ -0,0 +1,46 @@ +name: Web test + +# Runs the browser tests for the web implementation of objectbox +# (objectbox/lib/src/web/, tests in web_test/) with dart2js and dart2wasm. +# Avoid duplicate builds for pull requests, allow manual trigger. +on: + push: + branches: + - main + - dev + - web-support + pull_request: + workflow_dispatch: + +# Minimal access by default +permissions: + contents: read + +defaults: + run: + shell: bash + +env: + # Keep aligned with test.yml. + DART_VERSION: 3.12.1 # Available versions: https://dart.dev/get-dart/archive + +jobs: + web-test: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + - uses: dart-lang/setup-dart@v1 + with: + sdk: ${{ env.DART_VERSION }} + - name: Install dependencies + working-directory: web_test + run: dart pub get + - name: Generate binding code + working-directory: web_test + run: dart run build_runner build + - name: Test with dart2js (Chrome) + working-directory: web_test + run: dart test -p chrome + - name: Test with dart2wasm (Chrome) + working-directory: web_test + run: dart test -p chrome -c dart2wasm diff --git a/flutter_libs/lib/objectbox_flutter_libs.dart b/flutter_libs/lib/objectbox_flutter_libs.dart index bd336efd0..bdce156a8 100644 --- a/flutter_libs/lib/objectbox_flutter_libs.dart +++ b/flutter_libs/lib/objectbox_flutter_libs.dart @@ -1,39 +1,9 @@ /// This package contains platform-specific native libraries for flutter. /// See the actual library implementation in package "objectbox". -library objectbox_flutter_libs; - -import 'dart:io'; - -import 'package:flutter/services.dart'; -import 'package:objectbox/objectbox.dart'; -import 'package:path_provider/path_provider.dart'; - -/// Returns the default database directory inside this Flutter app's -/// `getApplicationDocumentsDirectory()`. /// -/// Note: on desktop platforms this returns a directory in the users documents -/// directory. It is advised to not use this then and instead create a directory -/// named specifically for your app. -Future defaultStoreDirectory() async => Directory( - '${(await getApplicationDocumentsDirectory()).path}/${Store.defaultDirectoryPath}', -); +/// On the web platform there are no native libraries; the web variant provides +/// compatible no-op implementations so that generated code compiles. +library; -const _platform = MethodChannel("objectbox_flutter_libs"); - -/// If your Flutter app runs on Android 6 (or older) devices, call this before -/// using any ObjectBox APIs, to fix loading the native ObjectBox library. -/// -/// If the device is running Android 6 (or older) this will try to load the -/// native library using Java APIs. Afterwards, calling ObjectBox APIs should -/// load the library successfully on the Dart/Flutter side. -/// -/// See the [GitHub issue for details](https://github.com/objectbox/objectbox-dart/issues/369). -Future loadObjectBoxLibraryAndroidCompat() async { - if (!Platform.isAndroid) { - // To support calling this in multi-platform Flutter apps - // do nothing if not Android (plugins for other platforms do not - // implement method below). - return; - } - await _platform.invokeMethod('loadObjectBoxLibrary'); -} +export 'src/objectbox_flutter_libs_native.dart' + if (dart.library.js_interop) 'src/objectbox_flutter_libs_web.dart'; diff --git a/flutter_libs/lib/src/objectbox_flutter_libs_native.dart b/flutter_libs/lib/src/objectbox_flutter_libs_native.dart new file mode 100644 index 000000000..ad885d5eb --- /dev/null +++ b/flutter_libs/lib/src/objectbox_flutter_libs_native.dart @@ -0,0 +1,35 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:objectbox/objectbox.dart'; +import 'package:path_provider/path_provider.dart'; + +/// Returns the default database directory inside this Flutter app's +/// `getApplicationDocumentsDirectory()`. +/// +/// Note: on desktop platforms this returns a directory in the users documents +/// directory. It is advised to not use this then and instead create a directory +/// named specifically for your app. +Future defaultStoreDirectory() async => Directory( + '${(await getApplicationDocumentsDirectory()).path}/${Store.defaultDirectoryPath}', +); + +const _platform = MethodChannel("objectbox_flutter_libs"); + +/// If your Flutter app runs on Android 6 (or older) devices, call this before +/// using any ObjectBox APIs, to fix loading the native ObjectBox library. +/// +/// If the device is running Android 6 (or older) this will try to load the +/// native library using Java APIs. Afterwards, calling ObjectBox APIs should +/// load the library successfully on the Dart/Flutter side. +/// +/// See the [GitHub issue for details](https://github.com/objectbox/objectbox-dart/issues/369). +Future loadObjectBoxLibraryAndroidCompat() async { + if (!Platform.isAndroid) { + // To support calling this in multi-platform Flutter apps + // do nothing if not Android (plugins for other platforms do not + // implement method below). + return; + } + await _platform.invokeMethod('loadObjectBoxLibrary'); +} diff --git a/flutter_libs/lib/src/objectbox_flutter_libs_web.dart b/flutter_libs/lib/src/objectbox_flutter_libs_web.dart new file mode 100644 index 000000000..ac5385b60 --- /dev/null +++ b/flutter_libs/lib/src/objectbox_flutter_libs_web.dart @@ -0,0 +1,22 @@ +import 'package:objectbox/objectbox.dart'; + +/// Stand-in for `dart:io`'s `Directory` on the web platform, where there is no +/// real file system. Only carries the [path] that generated `openStore()` code +/// passes to the [Store] constructor, which uses it as a logical database +/// name on web. +class WebStoreDirectory { + /// The logical database name. + final String path; + + /// Wrap a logical database name. + const WebStoreDirectory(this.path); +} + +/// Returns the default database location on web: there are no directories, +/// so this is just [Store.defaultDirectoryPath] used as a logical name. +Future defaultStoreDirectory() async => + const WebStoreDirectory(Store.defaultDirectoryPath); + +/// Does nothing on web (there is no native library to load). See the native +/// variant of this function for details. +Future loadObjectBoxLibraryAndroidCompat() async {} diff --git a/generator/lib/src/code_chunks.dart b/generator/lib/src/code_chunks.dart index 21e7f3e28..bb0710876 100644 --- a/generator/lib/src/code_chunks.dart +++ b/generator/lib/src/code_chunks.dart @@ -29,7 +29,7 @@ class CodeChunks { import 'dart:typed_data'; - import 'package:flat_buffers/flat_buffers.dart' as fb; + import 'package:objectbox/flatbuffers.dart' as fb; import 'package:objectbox/internal.dart' as $obxInt; // generated code can access "internal" functionality import 'package:objectbox/objectbox.dart' as $obx;${pubspec?.obxFlutterImport} @@ -85,7 +85,7 @@ class CodeChunks { bool queriesCaseSensitiveDefault = true, String? macosApplicationGroup})${obxFlutter ? ' async' : ''} { ${obxFlutter ? 'await loadObjectBoxLibraryAndroidCompat();' : ''} - return $obx.Store(getObjectBoxModel(), + final store = $obx.Store(getObjectBoxModel(), directory: directory${obxFlutter ? ' ?? (await defaultStoreDirectory()).path' : ''}, maxDBSizeInKB: maxDBSizeInKB, maxDataSizeInKB: maxDataSizeInKB, @@ -93,6 +93,8 @@ class CodeChunks { maxReaders: maxReaders, queriesCaseSensitiveDefault: queriesCaseSensitiveDefault, macosApplicationGroup: macosApplicationGroup); + ${obxFlutter ? '// On web the store loads persisted data asynchronously.\n await store.ready;' : ''} + return store; }'''; } @@ -114,10 +116,10 @@ class CodeChunks { lastIndexId: ${createIdUid(model.lastIndexId)}, lastRelationId: ${createIdUid(model.lastRelationId)}, lastSequenceId: ${createIdUid(model.lastSequenceId)}, - retiredEntityUids: const ${model.retiredEntityUids}, - retiredIndexUids: const ${model.retiredIndexUids}, - retiredPropertyUids: const ${model.retiredPropertyUids}, - retiredRelationUids: const ${model.retiredRelationUids}, + retiredEntityUids: ${createUidList(model.retiredEntityUids)}, + retiredIndexUids: ${createUidList(model.retiredIndexUids)}, + retiredPropertyUids: ${createUidList(model.retiredPropertyUids)}, + retiredRelationUids: ${createUidList(model.retiredRelationUids)}, modelVersion: ${model.modelVersion}, modelVersionParserMinimum: ${model.modelVersionParserMinimum}, version: ${model.version}); @@ -125,9 +127,19 @@ class CodeChunks { } static String createIdUid(IdUid value) { - return 'const $obxInt.IdUid(${value.id}, ${value.uid})'; + // Emitted as a string parsed at runtime: UIDs are 64-bit values that + // regularly exceed 2^53 and can't be written as integer literals in code + // that is compiled to JavaScript (dart2js). See issue #185 (web support). + return "$obxInt.IdUid.fromString('${value.id}:${value.uid}')"; } + /// Like [createIdUid]: retired UID lists are 64-bit values emitted as + /// runtime-parsed strings so generated code compiles with dart2js. + static String createUidList(List uids) => + uids.isEmpty + ? 'const []' + : "[${uids.map((uid) => "int.parse('$uid')").join(', ')}]"; + static String createModelEntity(ModelEntity entity) { var additionalArgs = ''; if (entity.externalName != null) { diff --git a/objectbox/CHANGELOG.md b/objectbox/CHANGELOG.md index 8d66dbe48..2ce225323 100644 --- a/objectbox/CHANGELOG.md +++ b/objectbox/CHANGELOG.md @@ -1,5 +1,58 @@ ## latest +* Web platform support, phase 3 ([#185](https://github.com/objectbox/objectbox-dart/issues/185)): + queries. `box.query()` now works on web: a pure-Dart evaluator over the + condition tree running against the in-memory records. Supported: all + property conditions (string with case-sensitivity flags, integer, double, + bool, date/dateNano, byte vector, string vector `containsElement`, + `isNull`/`notNull`, `oneOf`/`notOneOf`, `between`), and/or groups, + `order()` (descending, caseSensitive, nullsLast, nullsAsZero, unsigned), + offset/limit, `find`/`findFirst`/`findUnique`/`findIds`/`count`/`remove`/ + `stream` (+ async variants), query parameters (`param()` with alias + support), property queries (min/max/sum/average/count/find with distinct + and case sensitivity), relation links (`link`, `backlink`, `linkMany`, + `backlinkMany`, nested links, `relationCount`), `watch()` streams, and + vector search (`nearestNeighborsF32` with `findWithScores`/ + `findIdsWithScores`) evaluated as an exact brute-force scan - Euclidean + (squared), cosine and dot-product distances; Geo is not supported on web. + Note: on web queries scan the in-memory records; indexes are not used yet. +* Web platform support, phase 2 ([#185](https://github.com/objectbox/objectbox-dart/issues/185)): + a working database on web. `Store` and `Box` are now fully functional in the + browser: an in-memory engine (keeping the synchronous ObjectBox API) that is + persisted to IndexedDB with a write-behind queue. Supported: all CRUD + operations and `PutMode` semantics, monotonic ID assignment, `@Unique` + enforcement (including `ConflictStrategy.replace`), `ToOne`/`ToMany` + relations and backlinks, `runInTransaction` with rollback on error, + `store.watch()`/`entityChanges` streams, `Store.attach`, in-memory + databases via `memory:` directories, and persistence across sessions + (including the unique index and ID sequences). Not yet supported on web: + queries (phase 3), `Store.fromReference`, isolates (`runAsync` runs on the + same thread), Sync and Admin. + - New: `Store.ready`, a future that completes when the store has loaded its + persisted data; completes immediately on native platforms. The generated + `openStore()` of Flutter apps awaits it automatically; pure Dart apps on + web should `await store.ready` after constructing the store. + - Durability note: on web, writes are applied to memory synchronously and + flushed to IndexedDB within a microtask; a browser crash may lose the very + last writes. `Store.close()` flushes the queue. + - The minimum Dart SDK is now 3.4 (dart:js_interop / package:web). + - FlatBuffers on web use a vendored copy of `flat_buffers` with + JavaScript-safe 64-bit integer handling (dart2js does not support + `ByteData.get/setInt64`); native platforms keep using the + `flat_buffers` package unchanged via the new conditional export + `package:objectbox/flatbuffers.dart`, which generated code now imports. +* Web platform support, phase 1 ([#185](https://github.com/objectbox/objectbox-dart/issues/185)): + the package (including code generated by `objectbox_generator` and the + `objectbox_flutter_libs` helpers) now compiles for the web platform, with both + dart2js and dart2wasm. There is no database implementation on web yet: all + database operations throw `UnsupportedError` for now. `Admin.isAvailable()`, + `Sync.isAvailable()` and `Store.isOpen()` return `false` on web so existing + guard code keeps working. Native platforms are unchanged. +* Generator: emit model IDs/UIDs as `IdUid.fromString(...)` and retired UID + lists as runtime-parsed strings instead of integer literals, which cannot be + compiled to JavaScript for values above 2^53 (UIDs are random 64-bit + values). + ## 5.3.2 (2026-05-20) * Update ObjectBox database for Flutter Linux/Windows, Dart Native apps to [5.3.2-2026-05-05](https://github.com/objectbox/objectbox-c/releases/tag/v5.3.2) diff --git a/objectbox/lib/flatbuffers.dart b/objectbox/lib/flatbuffers.dart new file mode 100644 index 000000000..14d58d5e6 --- /dev/null +++ b/objectbox/lib/flatbuffers.dart @@ -0,0 +1,10 @@ +/// The FlatBuffers implementation used by ObjectBox and its generated code. +/// +/// On native platforms this is `package:flat_buffers` as-is. On web it is a +/// vendored copy with JavaScript-safe 64-bit integer handling, because +/// dart2js does not support `ByteData.get/setInt64` (see +/// `src/web/flatbuffers/`). +library; + +export 'package:flat_buffers/flat_buffers.dart' + if (dart.library.js_interop) 'src/web/flatbuffers/flat_buffers.dart'; diff --git a/objectbox/lib/internal.dart b/objectbox/lib/internal.dart index 01bf5a130..4b5ff9fb3 100644 --- a/objectbox/lib/internal.dart +++ b/objectbox/lib/internal.dart @@ -1,13 +1,15 @@ /// This library serves as an entrypoint for generated code and objectbox tools. /// Don't import into your own code, use 'objectbox.dart' instead. -library objectbox_internal; +library; +// Note: OBXVectorDistanceType and OBXHnswFlags are now exported via +// modelinfo/index.dart (enums.dart) instead of the ffigen bindings, and +// InternalStoreAccess via the platform-conditional store facade, so that this +// library never pulls dart:ffi into a web build. export 'src/modelinfo/index.dart'; export 'src/native/bindings/flatbuffers_readers.dart'; export 'src/native/bindings/flexbuffers.dart'; -export 'src/native/bindings/objectbox_c.dart' - show OBXVectorDistanceType, OBXHnswFlags; -export 'src/native/store.dart' show InternalStoreAccess; export 'src/relations/info.dart'; export 'src/relations/to_many.dart' show InternalToManyAccess, InternalToManyTestAccess; +export 'src/store.dart' show InternalStoreAccess; diff --git a/objectbox/lib/objectbox.dart b/objectbox/lib/objectbox.dart index 3cc28bee1..2acaaa982 100644 --- a/objectbox/lib/objectbox.dart +++ b/objectbox/lib/objectbox.dart @@ -2,14 +2,13 @@ /// with strong ACID semantics. /// /// Read the [Getting Started](https://docs.objectbox.io/getting-started) guide. -library objectbox; +library; export 'src/admin.dart' show Admin; export 'src/annotations.dart'; export 'src/box.dart' show Box, PutMode; export 'src/common.dart'; export 'src/modelinfo/enums.dart' show OBXSyncFlags; -export 'src/native/query/vector_search_results.dart'; export 'src/query.dart' show Query, @@ -55,3 +54,4 @@ export 'src/sync.dart' SyncState, SyncLoginEvent; export 'src/transaction.dart' show TxMode; +export 'src/vector_search_results.dart'; diff --git a/objectbox/lib/src/admin.dart b/objectbox/lib/src/admin.dart index 385037553..03316e15e 100644 --- a/objectbox/lib/src/admin.dart +++ b/objectbox/lib/src/admin.dart @@ -1 +1 @@ -export 'native/admin.dart' if (dart.library.html) 'web/admin.dart'; +export 'native/admin.dart' if (dart.library.js_interop) 'web/admin.dart'; diff --git a/objectbox/lib/src/box.dart b/objectbox/lib/src/box.dart index 05ac67c16..fe187e455 100644 --- a/objectbox/lib/src/box.dart +++ b/objectbox/lib/src/box.dart @@ -1 +1,13 @@ -export 'native/box.dart' if (dart.library.html) 'web/box.dart'; +export 'native/box.dart' if (dart.library.js_interop) 'web/box.dart'; + +/// Box put (write) mode. +enum PutMode { + /// Insert (if given object's ID is zero) or update an existing object. + put, + + /// Insert a new object. + insert, + + /// Update an existing object, fails if the given ID doesn't exist. + update, +} diff --git a/objectbox/lib/src/model.dart b/objectbox/lib/src/model.dart index 41d940590..c39ba4570 100644 --- a/objectbox/lib/src/model.dart +++ b/objectbox/lib/src/model.dart @@ -1 +1 @@ -export 'native/model.dart' if (dart.library.html) 'web/model.dart'; +export 'native/model.dart' if (dart.library.js_interop) 'web/model.dart'; diff --git a/objectbox/lib/src/modelinfo/entity_definition.dart b/objectbox/lib/src/modelinfo/entity_definition.dart index 658e96e7a..b2f76561e 100644 --- a/objectbox/lib/src/modelinfo/entity_definition.dart +++ b/objectbox/lib/src/modelinfo/entity_definition.dart @@ -1,7 +1,6 @@ -import 'dart:ffi'; import 'dart:typed_data'; -import 'package:flat_buffers/flat_buffers.dart' as fb; +import '../../flatbuffers.dart' as fb; import '../relations/info.dart'; import '../relations/to_many.dart'; @@ -31,15 +30,4 @@ class EntityDefinition { required this.toManyRelations}); Type type() => T; - - /// Shortcut that creates a [ByteData] view and passes it to [objectFromFB]. - T objectFromData(Store store, Pointer data, int size) { - // There has been a performance improvement in the past using memcpy for - // small buffers, but this is not longer faster than asTypedList. - // See /benchmark/bin/native_pointers.dart. - final uInt8List = data.asTypedList(size); - final byteData = - ByteData.view(uInt8List.buffer, uInt8List.offsetInBytes, size); - return objectFromFB(store, byteData); - } } diff --git a/objectbox/lib/src/modelinfo/enums.dart b/objectbox/lib/src/modelinfo/enums.dart index ac340e567..ec619fca9 100644 --- a/objectbox/lib/src/modelinfo/enums.dart +++ b/objectbox/lib/src/modelinfo/enums.dart @@ -85,8 +85,6 @@ int propertyTypeToOBXPropertyType(PropertyType type) { return OBXPropertyType.FloatVector; case PropertyType.flex: return OBXPropertyType.Flex; - default: - throw ArgumentError.value(type, 'type', 'Invalid PropertyType'); } } @@ -409,3 +407,60 @@ abstract class OBXSyncFlags { /// Skips invalid (put object) operations in the TX log instead of failing. static const int SkipInvalidTxOps = 32; } + +/// The vector distance algorithm used by an [HnswIndex] (vector search). +/// +/// Note: this is a copy of the enum in native/bindings/objectbox_c.dart +/// to avoid a dart:ffi import which would break compatibility with web. +abstract class OBXVectorDistanceType { + /// Not a real type, just best practice (e.g. forward compatibility) + static const int Unknown = 0; + + /// The default; typically "Euclidean squared" internally. + static const int Euclidean = 1; + + /// Cosine similarity compares two vectors irrespective of their magnitude (compares the angle of two vectors). + /// Often used for document or semantic similarity. + /// Value range: 0.0 - 2.0 (0.0: same direction, 1.0: orthogonal, 2.0: opposite direction) + static const int Cosine = 2; + + /// For normalized vectors (vector length == 1.0), the dot product is equivalent to the cosine similarity. + /// Because of this, the dot product is often preferred as it performs better. + /// Value range (normalized vectors): 0.0 - 2.0 (0.0: same direction, 1.0: orthogonal, 2.0: opposite direction) + static const int DotProduct = 3; + + /// For geospatial coordinates aka latitude/longitude pairs. + /// Note, that the vector dimension must be 2, with the latitude being the first element and longitude the second. + /// Internally, this uses haversine distance. + static const int Geo = 6; + + /// A custom dot product similarity measure that does not require the vectors to be normalized. + /// Note: this is no replacement for cosine similarity (like DotProduct for normalized vectors is). + /// The non-linear conversion provides a high precision over the entire float range (for the raw dot product). + /// The higher the dot product, the lower the distance is (the nearer the vectors are). + /// The more negative the dot product, the higher the distance is (the farther the vectors are). + /// Value range: 0.0 - 2.0 (nonlinear; 0.0: nearest, 1.0: orthogonal, 2.0: farthest) + static const int DotProductNonNormalized = 10; +} + +/// Flags for HNSW indexes (vector search). +/// +/// Note: this is a copy of the enum in native/bindings/objectbox_c.dart +/// to avoid a dart:ffi import which would break compatibility with web. +abstract class OBXHnswFlags { + static const int None = 0; + + /// Enables debug logs. + static const int DebugLogs = 1; + + /// Enables "high volume" debug logs, e.g. individual gets/puts. + static const int DebugLogsDetailed = 2; + + /// Padding for SIMD is enabled by default, which uses more memory but may be faster. This flag turns it off. + static const int VectorCacheSimdPaddingOff = 4; + + /// If the speed of removing nodes becomes a concern in your use case, you can speed it up by setting this flag. + /// By default, repairing the graph after node removals creates more connections to improve the graph's quality. + /// The extra costs for this are relatively low (e.g. vs. regular indexing), and thus the default is recommended. + static const int ReparationLimitCandidates = 8; +} diff --git a/objectbox/lib/src/modelinfo/iduid.dart b/objectbox/lib/src/modelinfo/iduid.dart index 09e0be46e..6f23ed5fa 100644 --- a/objectbox/lib/src/modelinfo/iduid.dart +++ b/objectbox/lib/src/modelinfo/iduid.dart @@ -35,7 +35,14 @@ class IdUid { static int _parse(String name, String part) { final value = int.parse(part); - RangeError.checkValueInInterval(value, 0, ((1 << 63) - 1), name); + // Note: only the lower bound is checked. The former upper bound of + // (1 << 63) - 1 is redundant on the VM, where int.parse already rejects + // anything that does not fit a signed 64-bit integer, and it cannot be + // expressed when compiling to JavaScript, where bit-shifts use 32-bit + // semantics (it wrapped to a negative value making all IDs invalid). + if (value < 0) { + throw RangeError.value(value, name, 'must not be negative'); + } return value; } } diff --git a/objectbox/lib/src/modelinfo/model_hnsw_params.dart b/objectbox/lib/src/modelinfo/model_hnsw_params.dart index dee5c1e60..e7c6c93de 100644 --- a/objectbox/lib/src/modelinfo/model_hnsw_params.dart +++ b/objectbox/lib/src/modelinfo/model_hnsw_params.dart @@ -1,5 +1,5 @@ import '../annotations.dart'; -import '../native/bindings/objectbox_c.dart'; +import 'enums.dart'; /// Describes HNSW index parameters for a float vector property. class ModelHnswParams { diff --git a/objectbox/lib/src/modelinfo/modelentity.dart b/objectbox/lib/src/modelinfo/modelentity.dart index 3b00cfa02..d82716632 100644 --- a/objectbox/lib/src/modelinfo/modelentity.dart +++ b/objectbox/lib/src/modelinfo/modelentity.dart @@ -54,7 +54,7 @@ class ModelEntity { } ModelInfo get model => - (_model == null) ? throw StateError('model is null') : _model!; + (_model == null) ? throw StateError('model is null') : _model; List get properties => _properties; diff --git a/objectbox/lib/src/native/bindings/flatbuffers_readers.dart b/objectbox/lib/src/native/bindings/flatbuffers_readers.dart index d4d1ca92b..62bab221e 100644 --- a/objectbox/lib/src/native/bindings/flatbuffers_readers.dart +++ b/objectbox/lib/src/native/bindings/flatbuffers_readers.dart @@ -1,6 +1,6 @@ import 'dart:typed_data'; -import 'package:flat_buffers/flat_buffers.dart'; +import '../../../flatbuffers.dart'; const int _sizeOfInt32 = 4; diff --git a/objectbox/lib/src/native/bindings/flexbuffers.dart b/objectbox/lib/src/native/bindings/flexbuffers.dart index f134311e4..ed732260b 100644 --- a/objectbox/lib/src/native/bindings/flexbuffers.dart +++ b/objectbox/lib/src/native/bindings/flexbuffers.dart @@ -1,7 +1,10 @@ import 'dart:typed_data'; -import 'package:flat_buffers/flat_buffers.dart'; -import 'package:flat_buffers/flex_buffers.dart' as flex; +import 'package:flat_buffers/flex_buffers.dart' + if (dart.library.js_interop) '../../web/flatbuffers/flex_buffers.dart' + as flex; + +import '../../../flatbuffers.dart'; /// Serializes any FlexBuffer-compatible value to bytes. /// diff --git a/objectbox/lib/src/native/bindings/helpers.dart b/objectbox/lib/src/native/bindings/helpers.dart index 0d847e41b..e1b7f0015 100644 --- a/objectbox/lib/src/native/bindings/helpers.dart +++ b/objectbox/lib/src/native/bindings/helpers.dart @@ -230,3 +230,20 @@ extension NativeStringArrayAccess on Pointer { return List.generate(cArray.count, (i) => items[i].toDartString()); } } + +/// Native-only shortcut for [EntityDefinition]: creates a [ByteData] view over +/// C memory and passes it to [EntityDefinition.objectFromFB]. +/// +/// Lives here (and not on EntityDefinition itself) to keep +/// modelinfo/entity_definition.dart free of dart:ffi for web support. +extension EntityDefinitionObjectFromData on EntityDefinition { + T objectFromData(Store store, Pointer data, int size) { + // There has been a performance improvement in the past using memcpy for + // small buffers, but this is not longer faster than asTypedList. + // See /benchmark/bin/native_pointers.dart. + final uInt8List = data.asTypedList(size); + final byteData = + ByteData.view(uInt8List.buffer, uInt8List.offsetInBytes, size); + return objectFromFB(store, byteData); + } +} diff --git a/objectbox/lib/src/native/bindings/objectbox_c.dart b/objectbox/lib/src/native/bindings/objectbox_c.dart index aef55a0f7..8a14268cc 100644 --- a/objectbox/lib/src/native/bindings/objectbox_c.dart +++ b/objectbox/lib/src/native/bindings/objectbox_c.dart @@ -1,5 +1,11 @@ // ignore_for_file: non_constant_identifier_names, public_member_api_docs, prefer_expression_function_bodies, avoid_positional_boolean_parameters, constant_identifier_names, camel_case_types +// Pinned to a pre-3.0 language version: this ffigen output predates Dart 3 +// class modifiers (Struct/Opaque subtypes must be `final` from 3.0 on) and +// the package minimum SDK is now 3.4 (for web support). Remove when the +// bindings are regenerated with a current ffigen. +// @dart=2.19 + // AUTO GENERATED FILE, DO NOT EDIT. // // Generated by `package:ffigen`. diff --git a/objectbox/lib/src/native/box.dart b/objectbox/lib/src/native/box.dart index e1f50bff6..81c5103de 100644 --- a/objectbox/lib/src/native/box.dart +++ b/objectbox/lib/src/native/box.dart @@ -6,6 +6,7 @@ import 'package:ffi/ffi.dart'; import 'package:meta/meta.dart'; import '../annotations.dart'; +import '../box.dart' show PutMode; import '../common.dart'; import '../modelinfo/index.dart'; import '../relations/info.dart'; @@ -18,18 +19,6 @@ import 'bindings/flatbuffers.dart'; import 'bindings/helpers.dart'; import 'query/query.dart'; -/// Box put (write) mode. -enum PutMode { - /// Insert (if given object's ID is zero) or update an existing object. - put, - - /// Insert a new object. - insert, - - /// Update an existing object, fails if the given ID doesn't exist. - update, -} - /// A Box instance gives you access to objects of a particular type. /// You get Box instances via [Store.box()] or [Box(Store)]. /// @@ -707,8 +696,6 @@ class InternalBoxAccess { cIdsPtr = C.box_rel_get_backlink_ids(box._ptr, rel.id, rel.objectId); break; - default: - throw UnimplementedError('Invalid relation type ${rel.type}'); } checkObxPtr(cIdsPtr); final result = []; diff --git a/objectbox/lib/src/native/query/params.dart b/objectbox/lib/src/native/query/params.dart index 09610b40a..efb1e276b 100644 --- a/objectbox/lib/src/native/query/params.dart +++ b/objectbox/lib/src/native/query/params.dart @@ -65,7 +65,7 @@ extension QueryParamString on QueryParam { _query._ptr, _entityId, _prop._model.id.id, cStr))); } else { withNativeStrings( - [_alias!, value], + [_alias, value], (Pointer> ptr, int size) => checkObx( C.query_param_alias_string(_query._ptr, ptr[0], ptr[1]))); } @@ -77,7 +77,7 @@ extension QueryParamString on QueryParam { ? C.query_param_strings( _query._ptr, _entityId, _prop._model.id.id, ptr, size) : withNativeString( - _alias!, + _alias, (Pointer cAlias) => C.query_param_alias_strings( _query._ptr, cAlias, ptr, size)))); } @@ -90,7 +90,7 @@ extension QueryParamBytes on QueryParam> { ? C.query_param_bytes( _query._ptr, _entityId, _prop._model.id.id, ptr, size) : withNativeString( - _alias!, + _alias, (Pointer cAlias) => C.query_param_alias_bytes(_query._ptr, cAlias, ptr, size)))); } @@ -100,7 +100,7 @@ extension QueryParamInt on QueryParam { set value(int value) => checkObx((_alias == null) ? C.query_param_int(_query._ptr, _entityId, _prop._model.id.id, value) : withNativeString( - _alias!, + _alias, (Pointer cAlias) => C.query_param_alias_int(_query._ptr, cAlias, value))); @@ -124,7 +124,7 @@ extension QueryParamInt on QueryParam { ptr as Pointer, values.length)); } else { withNativeString( - _alias!, + _alias, (Pointer cAlias) => checkObx(is64bit ? C.query_param_alias_int64s( _query._ptr, cAlias, ptr as Pointer, values.length) @@ -140,7 +140,7 @@ extension QueryParamInt on QueryParam { void twoValues(int a, int b) => checkObx((_alias == null) ? C.query_param_2ints(_query._ptr, _entityId, _prop._model.id.id, a, b) : withNativeString( - _alias!, + _alias, (Pointer cAlias) => C.query_param_alias_2ints(_query._ptr, cAlias, a, b))); } @@ -150,7 +150,7 @@ extension QueryParamDouble on QueryParam { set value(double value) => checkObx((_alias == null) ? C.query_param_double(_query._ptr, _entityId, _prop._model.id.id, value) : withNativeString( - _alias!, + _alias, (Pointer cAlias) => C.query_param_alias_double(_query._ptr, cAlias, value))); @@ -158,7 +158,7 @@ extension QueryParamDouble on QueryParam { void twoValues(double a, double b) => checkObx((_alias == null) ? C.query_param_2doubles(_query._ptr, _entityId, _prop._model.id.id, a, b) : withNativeString( - _alias!, + _alias, (Pointer cAlias) => C.query_param_alias_2doubles(_query._ptr, cAlias, a, b))); @@ -171,7 +171,7 @@ extension QueryParamDouble on QueryParam { checkObx(C.query_param_int( _query._ptr, _entityId, _prop._model.id.id, maxResultCount)); } else { - withNativeString(_alias!, (aliasPtr) { + withNativeString(_alias, (aliasPtr) { checkObx(C.query_param_alias_vector_float32( _query._ptr, aliasPtr, floatsPtr, size)); checkObx( @@ -188,7 +188,7 @@ extension QueryParamBool on QueryParam { ? C.query_param_int( _query._ptr, _entityId, _prop._model.id.id, value ? 1 : 0) : withNativeString( - _alias!, + _alias, (Pointer cAlias) => C.query_param_alias_int(_query._ptr, cAlias, value ? 1 : 0))); } diff --git a/objectbox/lib/src/native/query/query.dart b/objectbox/lib/src/native/query/query.dart index 067fa32f5..88fe22a0f 100644 --- a/objectbox/lib/src/native/query/query.dart +++ b/objectbox/lib/src/native/query/query.dart @@ -1,4 +1,4 @@ -library query; +library; import 'dart:async'; import 'dart:collection'; @@ -16,11 +16,11 @@ import '../../modelinfo/modelproperty.dart'; import '../../modelinfo/modelrelation.dart'; import '../../store.dart'; import '../../transaction.dart'; +import '../../vector_search_results.dart'; import '../bindings/bindings.dart'; import '../bindings/data_visitor.dart'; import '../bindings/helpers.dart'; import '../box.dart'; -import 'vector_search_results.dart'; part 'builder.dart'; @@ -533,7 +533,7 @@ abstract class Condition { final cid = _apply(builder, isRoot: isRoot); if (cid == 0) builder._throwExceptionIfNecessary(); if (_alias != null) { - checkObx(withNativeString(_alias!, + checkObx(withNativeString(_alias, (Pointer cStr) => C.qb_param_alias(builder._cBuilder, cStr))); } return cid; diff --git a/objectbox/lib/src/native/store.dart b/objectbox/lib/src/native/store.dart index 644d40452..2c21fa904 100644 --- a/objectbox/lib/src/native/store.dart +++ b/objectbox/lib/src/native/store.dart @@ -1,4 +1,4 @@ -library store; +library; import 'dart:async'; import 'dart:collection'; @@ -13,6 +13,7 @@ import 'package:path/path.dart' as path; import '../common.dart'; import '../modelinfo/index.dart'; +import '../store_config.dart'; import '../transaction.dart'; import '../util.dart'; import 'bindings/bindings.dart'; @@ -22,9 +23,9 @@ import 'model.dart'; import 'sync.dart'; import 'version.dart'; -part 'observable.dart'; +export '../store_config.dart'; -part 'store_config.dart'; +part 'observable.dart'; /// Represents an ObjectBox database and works together with [Box] to allow /// getting and putting. @@ -501,7 +502,7 @@ class Store implements Finalizable { void _attachConfiguration(Pointer storePtr, ModelDefinition model, String directoryPath, bool queriesCaseSensitiveDefault) { int id = C.store_id(storePtr); - _configuration = StoreConfiguration._( + _configuration = StoreConfiguration( id, model, directoryPath, queriesCaseSensitiveDefault); } @@ -604,6 +605,14 @@ class Store implements Finalizable { /// ``` Pointer _clone() => checkObxPtr(C.store_clone(_ptr)); + /// A future that completes when the store is ready for use. + /// + /// On native platforms the store is ready as soon as the constructor + /// returns, so this completes immediately. On web the store loads its + /// persisted data asynchronously: await this before reading (the generated + /// `openStore()` for Flutter apps does this automatically). + Future get ready => Future.value(); + /// Returns if this store is already closed and can no longer be used. bool isClosed() => _cStore.address == 0; diff --git a/objectbox/lib/src/native/store_config.dart b/objectbox/lib/src/native/store_config.dart deleted file mode 100644 index 1e85592e4..000000000 --- a/objectbox/lib/src/native/store_config.dart +++ /dev/null @@ -1,21 +0,0 @@ -part of 'store.dart'; - -/// Configuration of a [Store] containing everything required to obtain it -/// again, e.g. from another isolate. -class StoreConfiguration { - /// The ID of the store. - final int id; - - /// The ModelDefinition of the store. - final ModelDefinition modelDefinition; - - /// Path to the database directory. - final String directoryPath; - - /// Default value for the string query conditions [caseSensitive] argument. - final bool queriesCaseSensitiveDefault; - - /// Create a new [StoreConfiguration]. - StoreConfiguration._(this.id, this.modelDefinition, this.directoryPath, - this.queriesCaseSensitiveDefault); -} diff --git a/objectbox/lib/src/native/sync.dart b/objectbox/lib/src/native/sync.dart index 6b99b3713..762651e40 100644 --- a/objectbox/lib/src/native/sync.dart +++ b/objectbox/lib/src/native/sync.dart @@ -515,8 +515,6 @@ class SyncClient { case SyncRequestUpdatesMode.autoNoPushes: cMode = OBXRequestUpdatesMode.AUTO_NO_PUSHES; break; - default: - throw ArgumentError.value(mode, 'mode'); } checkObx(C.sync_request_updates_mode(_ptr, cMode)); } diff --git a/objectbox/lib/src/query.dart b/objectbox/lib/src/query.dart index 9fcb2b5ca..009755a0e 100644 --- a/objectbox/lib/src/query.dart +++ b/objectbox/lib/src/query.dart @@ -1 +1 @@ -export 'native/query/query.dart' if (dart.library.html) 'web/query.dart'; +export 'native/query/query.dart' if (dart.library.js_interop) 'web/query.dart'; diff --git a/objectbox/lib/src/relations/to_many.dart b/objectbox/lib/src/relations/to_many.dart index 33ca70d9b..1271f7b26 100644 --- a/objectbox/lib/src/relations/to_many.dart +++ b/objectbox/lib/src/relations/to_many.dart @@ -234,8 +234,6 @@ class ToMany extends Object with ListMixin { configuration.box(store), relInfo.id, id, relInfo.objectId); } break; - default: - throw UnimplementedError(); } }); if (ownedTx) tx.successAndClose(); diff --git a/objectbox/lib/src/relations/to_one.dart b/objectbox/lib/src/relations/to_one.dart index 25a305fc6..8116aac42 100644 --- a/objectbox/lib/src/relations/to_one.dart +++ b/objectbox/lib/src/relations/to_one.dart @@ -1,8 +1,8 @@ import '../annotations.dart'; import '../box.dart'; import '../modelinfo/entity_definition.dart'; -import '../native/transaction.dart'; import '../store.dart'; +import '../transaction.dart'; /// A to-one relation of an entity that references one object of a "target" entity [EntityT]. /// diff --git a/objectbox/lib/src/store.dart b/objectbox/lib/src/store.dart index f66c1a761..b359042cd 100644 --- a/objectbox/lib/src/store.dart +++ b/objectbox/lib/src/store.dart @@ -1,5 +1 @@ -// Web support is currently early-stage WIP, prevent pub.dev recognizing this -// objectbox as already web-capable. See tracking issue #185. -// export 'native/store.dart' if (dart.library.html) 'web/store.dart'; - -export 'native/store.dart'; +export 'native/store.dart' if (dart.library.js_interop) 'web/store.dart'; diff --git a/objectbox/lib/src/store_config.dart b/objectbox/lib/src/store_config.dart new file mode 100644 index 000000000..99471d7ee --- /dev/null +++ b/objectbox/lib/src/store_config.dart @@ -0,0 +1,32 @@ +import 'package:meta/meta.dart'; + +import 'modelinfo/index.dart'; + +/// Configuration of a `Store` containing everything required to obtain it +/// again, e.g. from another isolate. +/// +/// This is platform-independent (contains no FFI types), so it is shared +/// between the native and web implementations of the store. +class StoreConfiguration { + /// The ID of the store. + final int id; + + /// The ModelDefinition of the store. + final ModelDefinition modelDefinition; + + /// Path to the database directory. + final String directoryPath; + + /// Default value for the string query conditions `caseSensitive` argument. + final bool queriesCaseSensitiveDefault; + + /// Create a new [StoreConfiguration]. Internal only: this constructor is not + /// part of the public API and may change at any time. + @internal + StoreConfiguration( + this.id, + this.modelDefinition, + this.directoryPath, + // ignore: avoid_positional_boolean_parameters + this.queriesCaseSensitiveDefault); +} diff --git a/objectbox/lib/src/sync.dart b/objectbox/lib/src/sync.dart index 9f9d9ce3a..52010128e 100644 --- a/objectbox/lib/src/sync.dart +++ b/objectbox/lib/src/sync.dart @@ -1 +1 @@ -export 'native/sync.dart' if (dart.library.html) 'web/sync.dart'; +export 'native/sync.dart' if (dart.library.js_interop) 'web/sync.dart'; diff --git a/objectbox/lib/src/transaction.dart b/objectbox/lib/src/transaction.dart index 07e8e5f55..eb99f0150 100644 --- a/objectbox/lib/src/transaction.dart +++ b/objectbox/lib/src/transaction.dart @@ -1,4 +1,5 @@ -export 'native/transaction.dart' if (dart.library.html) 'web/transaction.dart'; +export 'native/transaction.dart' + if (dart.library.js_interop) 'web/transaction.dart'; /// Configure transaction mode. Used with [Store.runInTransaction()]. enum TxMode { diff --git a/objectbox/lib/src/native/query/vector_search_results.dart b/objectbox/lib/src/vector_search_results.dart similarity index 100% rename from objectbox/lib/src/native/query/vector_search_results.dart rename to objectbox/lib/src/vector_search_results.dart diff --git a/objectbox/lib/src/web/admin.dart b/objectbox/lib/src/web/admin.dart index 8b1378917..8407d70b2 100644 --- a/objectbox/lib/src/web/admin.dart +++ b/objectbox/lib/src/web/admin.dart @@ -1 +1,24 @@ +// Web (stub) implementation of the ObjectBox Admin: mirrors the public API of +// `../native/admin.dart` so the package compiles for the web platform, but +// throws `UnsupportedError` at runtime. See tracking issue #185. +// ignore_for_file: public_member_api_docs +import '../store.dart'; +import 'unsupported.dart'; + +/// ObjectBox Admin web interface. Not supported on the web platform. +class Admin { + /// Whether the Admin interface is available in this runtime: always false + /// on web, so `if (Admin.isAvailable())` guards keep working unchanged. + static bool isAvailable() => false; + + Admin(Store store, {String bindUri = 'http://127.0.0.1:8090'}) { + throwUnsupportedOnWeb(); + } + + void close() => throwUnsupportedOnWeb(); + + bool isClosed() => throwUnsupportedOnWeb(); + + int get port => throwUnsupportedOnWeb(); +} diff --git a/objectbox/lib/src/web/box.dart b/objectbox/lib/src/web/box.dart index 8b1378917..7c1b1bac8 100644 --- a/objectbox/lib/src/web/box.dart +++ b/objectbox/lib/src/web/box.dart @@ -1 +1,264 @@ +/// Web implementation of Box, backed by WebStoreEngine (see engine.dart). +/// Mirrors the native put/relations protocol so the shared ToOne/ToMany code +/// works unchanged. +/// +/// Note for maintainers: the analyzer resolves the conditional facades +/// ('../store.dart', '../transaction.dart') to the native variant, while this +/// library is only ever compiled together with the web variants. At the few +/// places where web types are passed to shared code whose signatures the +/// analyzer reads as native types, `// ignore: argument_type_not_assignable` +/// silences the resulting false positives - at web compile time the types are +/// identical. +// ignore_for_file: public_member_api_docs +library; +import 'dart:typed_data'; + +import 'package:meta/meta.dart'; + +import '../../flatbuffers.dart' as fb; +import '../box.dart' show PutMode; +import '../modelinfo/index.dart'; +import '../relations/info.dart'; +import '../relations/to_many.dart'; +import '../relations/to_one.dart'; +import '../transaction.dart' show TxMode; +import 'engine.dart'; +import 'query.dart'; +import 'store.dart'; +import 'transaction.dart'; + +class Box { + final Store _store; + final WebStoreEngine _engine; + final EntityData _data; + final EntityDefinition _entity; + final bool _hasToOneRelations; + final bool _hasToManyRelations; + final fb.Builder _builder = fb.Builder(initialSize: 256); + + factory Box(Store store) => store.box(); + + Box._(Store store, EntityDefinition entity) + : _store = store, + _entity = entity, + _engine = InternalStoreAccess.engine(store), + _data = InternalStoreAccess.engine(store).entities[entity.model.id.id]!, + _hasToOneRelations = entity.model.properties + .any((p) => p.type == OBXPropertyType.Relation), + _hasToManyRelations = entity.model.relations.isNotEmpty || + entity.model.backlinks.isNotEmpty; + + bool get _hasRelations => _hasToOneRelations || _hasToManyRelations; + + // ------------------------------------------------------------------- puts + + int put(T object, {PutMode mode = PutMode.put}) { + if (_hasRelations) { + return InternalStoreAccess.runInTransaction( + _store, TxMode.write, (Transaction tx) => _put(object, mode, tx)); + } + return _engine.runInTx(() => _put(object, mode, null)); + } + + Future putAsync(T object, {PutMode mode = PutMode.put}) => + Future.microtask(() => put(object, mode: mode)); + + Future putAndGetAsync(T object, {PutMode mode = PutMode.put}) => + Future.microtask(() { + put(object, mode: mode); + return object; + }); + + @Deprecated( + "Use putAsync which supports relations, or for a large number of parallel calls putQueued.") + Future putQueuedAwaitResult(T object, {PutMode mode = PutMode.put}) => + putAsync(object, mode: mode); + + /// On web there is no separate async queue: this is a normal (synchronous, + /// in-memory) put; persistence happens via the write-behind queue. + int putQueued(T object, {PutMode mode = PutMode.put}) => + put(object, mode: mode); + + List putMany(List objects, {PutMode mode = PutMode.put}) { + if (objects.isEmpty) return []; + return InternalStoreAccess.runInTransaction( + _store, + TxMode.write, + (Transaction tx) => + objects.map((object) => _put(object, mode, tx)).toList()); + } + + Future> putManyAsync(List objects, + {PutMode mode = PutMode.put}) => + Future.microtask(() => putMany(objects, mode: mode)); + + Future> putAndGetManyAsync(List objects, + {PutMode mode = PutMode.put}) => + Future.microtask(() { + putMany(objects, mode: mode); + return objects; + }); + + int _put(T object, PutMode mode, Transaction? tx) { + if (_hasRelations && tx == null) { + throw StateError( + 'Invalid state: can only use _put() on an entity with relations when' + ' executing from inside a write transaction.'); + } + if (_hasToOneRelations) { + // There may be relation cycles, so get the ID first. + if ((_entity.getId(object) ?? 0) == 0) { + _entity.setId(object, _engine.reserveId(_data)); + } + _putToOneRelFields(object, mode, tx!); + } + // OBXPutMode values: PUT=1, INSERT=2, UPDATE=3 (mode.index + 1). + final id = + _engine.checkPutId(_data, _entity.getId(object) ?? 0, mode.index + 1); + if ((_entity.getId(object) ?? 0) == 0) _entity.setId(object, id); + _builder.reset(); + _entity.objectToFB(object, _builder); + // Copy: the builder's buffer is a view that is reused by the next put. + final bytes = Uint8List.fromList(_builder.buffer); + _engine.putRecord(_data, id, bytes); + if (_hasToManyRelations) _putToManyRelFields(object, mode, tx!); + return id; + } + + void _putToOneRelFields(T object, PutMode mode, Transaction tx) { + for (final toOne in _entity.toOneRelations(object)) { + // ignore: argument_type_not_assignable + toOne.applyToDb(_store, mode, tx); + } + } + + void _putToManyRelFields(T object, PutMode mode, Transaction tx) { + _entity.toManyRelations(object).forEach((RelInfo info, ToMany rel) { + // Always set relation info so ToMany applyToDb can be used after put. + // ignore: argument_type_not_assignable + InternalToManyAccess.setRelInfo(rel, _store, info); + if (InternalToManyAccess.hasPendingDbChanges(rel)) { + // ignore: argument_type_not_assignable + rel.applyToDb(existingStore: _store, mode: mode, tx: tx); + } + }); + } + + // ------------------------------------------------------------------ reads + + T _fromBytes(Uint8List bytes) => _entity.objectFromFB( + // ignore: argument_type_not_assignable + _store, + ByteData.view(bytes.buffer, bytes.offsetInBytes, bytes.lengthInBytes)); + + T? get(int id) { + final bytes = _engine.getRecord(_data, id); + return bytes == null ? null : _fromBytes(bytes); + } + + Future getAsync(int id) => Future.microtask(() => get(id)); + + List getMany(List ids, {bool growableResult = false}) { + final result = List.filled(ids.length, null, growable: growableResult); + for (var i = 0; i < ids.length; i++) { + result[i] = get(ids[i]); + } + return result; + } + + Future> getManyAsync(List ids, {bool growableResult = false}) => + Future.microtask(() => getMany(ids, growableResult: growableResult)); + + List getAll() { + _engine.checkOpen(); + return _data.records.values.map(_fromBytes).toList(); + } + + Future> getAllAsync() => Future.microtask(getAll); + + QueryBuilder query([Condition? qc]) => + QueryBuilder(_store, _entity, qc); + + int count({int limit = 0}) { + _engine.checkOpen(); + final length = _data.records.length; + return limit > 0 && length > limit ? limit : length; + } + + bool isEmpty() => count() == 0; + + bool contains(int id) { + _engine.checkOpen(); + return _data.records.containsKey(id); + } + + bool containsMany(List ids) { + _engine.checkOpen(); + return ids.every(_data.records.containsKey); + } + + // ---------------------------------------------------------------- removes + + bool remove(int id) => _engine.runInTx(() => _engine.removeRecord(_data, id)); + + Future removeAsync(int id) => Future.microtask(() => remove(id)); + + int removeMany(List ids) => _engine.runInTx(() { + var removed = 0; + for (final id in ids) { + if (_engine.removeRecord(_data, id)) removed++; + } + return removed; + }); + + Future removeManyAsync(List ids) => + Future.microtask(() => removeMany(ids)); + + int removeAll() => _engine.runInTx(() => _engine.removeAllRecords(_data)); + + Future removeAllAsync() => Future.microtask(removeAll); +} + +/// Internal only. +@internal +class InternalBoxAccess { + static Box create(Store store, EntityDefinition entity) => + Box._(store, entity); + + static void close(Box box) { + // Nothing to release on web. + } + + static int put( + Box box, EntityT object, PutMode mode, Transaction? tx) => + box._put(object, mode, tx); + + static void relPut(Box box, int relationId, int sourceId, int targetId) => + box._engine.relPut(relationId, sourceId, targetId); + + static void relRemove(Box box, int relationId, int sourceId, int targetId) => + box._engine.relRemove(relationId, sourceId, targetId); + + static List getRelated(Box box, RelInfo rel) { + final engine = box._engine; + final List ids; + switch (rel.type) { + case RelType.toMany: + ids = engine.relTargets(rel.id, rel.objectId); + break; + case RelType.toOneBacklink: + ids = engine.toOneBacklinkSources(box._data, rel.id, rel.objectId); + break; + case RelType.toManyBacklink: + ids = engine.relBacklinkSources(rel.id, rel.objectId); + break; + } + final result = []; + for (final id in ids) { + final object = box.get(id); + if (object != null) result.add(object); + } + return result; + } +} diff --git a/objectbox/lib/src/web/engine.dart b/objectbox/lib/src/web/engine.dart new file mode 100644 index 000000000..6752dd2e0 --- /dev/null +++ b/objectbox/lib/src/web/engine.dart @@ -0,0 +1,699 @@ +// The web database engine backing the web implementation of Store/Box: +// an in-memory, synchronous store (matching the synchronous ObjectBox API) +// persisted to IndexedDB with a write-behind queue. +// +// Design notes: +// - All reads/writes operate on in-memory maps so the synchronous Box API +// keeps its native semantics. Persisted data is loaded once, asynchronously, +// when the store is opened: await `Store.ready` (generated `openStore()` for +// Flutter apps does this) before using the store. +// - Every mutation marks records dirty; a microtask flushes the batch into a +// single IndexedDB transaction. `Store.awaitQueueCompletion()` awaits the +// queue. Durability is therefore slightly weaker than native ObjectBox +// (a browser crash can lose the last moments of writes). +// - Write "transactions" (runInTransaction/relations puts) are implemented +// with an undo log: on abort/error the in-memory state is restored and the +// restored records are re-marked dirty. +// - `memory:` directories (Store.inMemoryPrefix) skip IndexedDB entirely. +// ignore_for_file: public_member_api_docs + +import 'dart:async'; +import 'dart:collection'; +import 'dart:js_interop'; +import 'dart:typed_data'; + +import 'package:web/web.dart' as web; + +import '../common.dart'; +import '../modelinfo/entity_definition.dart'; +import '../modelinfo/enums.dart'; +import '../modelinfo/model_definition.dart'; +import '../modelinfo/modelentity.dart'; +import '../modelinfo/modelproperty.dart'; +import '../store_config.dart'; +import 'fb_reader.dart'; +import 'idb_util.dart'; + +const String metaStoreName = '__obx_meta__'; +const String relStoreName = '__obx_rel__'; + +class EntityData { + final ModelEntity model; + final EntityDefinition definition; + + /// Records by id, sorted so getAll() returns objects in id order like the + /// native implementation. + final SplayTreeMap records = SplayTreeMap(); + + /// Highest id ever assigned (ids of removed objects are not reused). + int lastId = 0; + + final List uniqueProperties; + + /// Unique index: property model id -> value -> object id. + final Map> uniqueIndex = {}; + + /// ToOne properties of this entity (relation type), for backlinks and + /// cleanup of references when targets are removed. + final List relationProperties; + + EntityData(this.model, this.definition) + : uniqueProperties = model.properties + .where((p) => p.hasFlag(OBXPropertyFlags.UNIQUE)) + .toList(growable: false), + relationProperties = model.properties + .where((p) => p.type == OBXPropertyType.Relation) + .toList(growable: false) { + for (final property in uniqueProperties) { + uniqueIndex[property.id.id] = {}; + } + } + + bool get idSelfAssignable => + model.idProperty.hasFlag(OBXPropertyFlags.ID_SELF_ASSIGNABLE); + + void indexUniques(int id, Uint8List bytes) { + for (final property in uniqueProperties) { + final value = readProperty(property, _view(bytes)); + if (value != null) uniqueIndex[property.id.id]![value] = id; + } + } + + void unindexUniques(int id, Uint8List bytes) { + for (final property in uniqueProperties) { + final value = readProperty(property, _view(bytes)); + if (value != null) { + final index = uniqueIndex[property.id.id]!; + if (index[value] == id) index.remove(value); + } + } + } + + void rebuildUniqueIndex() { + for (final index in uniqueIndex.values) { + index.clear(); + } + records.forEach(indexUniques); + } +} + +ByteData _view(Uint8List bytes) => + ByteData.view(bytes.buffer, bytes.offsetInBytes, bytes.lengthInBytes); + +class _UndoLog { + /// First-touch snapshot of records: entity id -> object id -> old bytes + /// (null if the record did not exist). + final Map> records = {}; + + /// First-touch snapshot of id sequences. + final Map lastIds = {}; + + /// First-touch snapshot of relation target sets: + /// relation id -> source id -> old targets (null if absent). + final Map?>> relations = {}; +} + +class _DirtyEntityStore { + bool cleared = false; + + /// id -> bytes (null = delete) + final Map entries = {}; +} + +class WebStoreEngine { + static int _nextId = 1; + + final String directoryPath; + final ModelDefinition modelDefinition; + final bool inMemory; + final StoreConfiguration configuration; + + /// Number of Store handles sharing this engine (Store.attach & relations). + int refCount = 1; + + /// Close listeners registered via InternalStoreAccess. + final Map closeListeners = {}; + + final Future? _awaitBeforeOpen; + + /// Entity data by entity model id. + final Map entities = {}; + final Map entitiesByType = {}; + + /// Standalone ToMany relations: relation id -> source id -> target ids. + final Map>> relations = {}; + + /// Relation ids by source/target entity id (for cleanup on remove). + final Map> _relIdsBySourceEntity = {}; + final Map> _relIdsByTargetEntity = {}; + + web.IDBDatabase? _db; + late final Future ready; + bool _closed = false; + + // Transaction state (single-threaded, so a plain depth counter suffices). + int _txDepth = 0; + _UndoLog? _undo; + + // Write-behind queue. + final Map _dirtyEntities = {}; + final Map?> _dirtyRelations = {}; // 'relId:srcId' -> ids + final Map _dirtyMeta = {}; + bool _flushScheduled = false; + Future _flushChain = Future.value(); + + // Change notifications. + final StreamController> changes = + StreamController>.broadcast(); + final Set _pendingChanges = {}; + + WebStoreEngine(this.modelDefinition, this.directoryPath, + {required bool queriesCaseSensitiveDefault, + Future? awaitBeforeOpen}) + : inMemory = directoryPath.startsWith('memory:'), + _awaitBeforeOpen = awaitBeforeOpen, + configuration = StoreConfiguration(_nextId++, modelDefinition, + directoryPath, queriesCaseSensitiveDefault) { + for (final entity in modelDefinition.model.entities) { + final definition = modelDefinition.bindings.values.firstWhere( + (definition) => definition.model.id.id == entity.id.id, + orElse: () => throw SchemaException( + 'No binding for entity ${entity.name} - model out of sync')); + final data = EntityData(entity, definition); + entities[entity.id.id] = data; + entitiesByType[definition.type()] = data; + for (final relation in entity.relations) { + relations[relation.id.id] = {}; + _relIdsBySourceEntity + .putIfAbsent(entity.id.id, () => []) + .add(relation.id.id); + _relIdsByTargetEntity + .putIfAbsent(relation.targetId.id, () => []) + .add(relation.id.id); + } + } + ready = inMemory ? Future.value() : _load(); + } + + bool get isClosed => _closed; + + void checkOpen() { + if (_closed) throw StateError('Store is closed'); + } + + EntityData entityByType(Type type) { + final data = entitiesByType[type]; + if (data == null) { + throw ArgumentError( + 'Unknown entity type $type - did you run the generator?'); + } + return data; + } + + // ---------------------------------------------------------------- loading + + Future _load() async { + // Wait for a previous engine on the same path to finish closing. + if (_awaitBeforeOpen != null) { + try { + await _awaitBeforeOpen; + } catch (_) { + // The old engine failed to flush cleanly; open anyway. + } + } + final storeNames = [ + metaStoreName, + relStoreName, + for (final data in entities.values) data.model.name + ]; + final db = await idbOpen(directoryPath, storeNames); + _db = db; + + // Best-effort request for persistent (non-evictable) storage. + try { + web.window.navigator.storage.persist(); + } catch (_) { + // Not available (e.g. non-secure context) - ignore. + } + + for (final data in entities.values) { + for (final (id, value) in await idbReadAll(db, data.model.name)) { + if (value == null) continue; + final bytes = (value as JSUint8Array).toDart; + data.records[id] = bytes; + if (id > data.lastId) data.lastId = id; + } + data.rebuildUniqueIndex(); + } + + for (final (key, value) in await idbReadAllStringKeys(db, metaStoreName)) { + if (!key.startsWith('seq:')) continue; + final entityName = key.substring(4); + for (final data in entities.values) { + if (data.model.name == entityName) { + final sequence = (value as JSNumber).toDartInt; + if (sequence > data.lastId) data.lastId = sequence; + } + } + } + + for (final (key, value) in await idbReadAllStringKeys(db, relStoreName)) { + final separator = key.indexOf(':'); + if (separator <= 0 || value == null) continue; + final relId = int.parse(key.substring(0, separator)); + final sourceId = int.parse(key.substring(separator + 1)); + final targetMap = relations[relId]; + if (targetMap == null) continue; // relation removed from model + final ids = (value as JSArray).toDart; + targetMap[sourceId] = {for (final id in ids) (id as JSNumber).toDartInt}; + } + } + + // ----------------------------------------------------------- transactions + + void beginTx() { + checkOpen(); + if (_txDepth == 0) _undo = _UndoLog(); + _txDepth++; + } + + void commitTx() { + _txDepth--; + if (_txDepth == 0) { + _undo = null; + _emitChanges(); + _scheduleFlush(); + } + } + + void abortTx() { + _txDepth--; + if (_txDepth > 0) { + // Match native semantics loosely: an inner abort fails the whole + // transaction. Rethrowing from the failed inner scope (which is how + // aborts happen here) propagates the abort outwards. + return; + } + final undo = _undo!; + _undo = null; + + undo.records.forEach((entityId, snapshots) { + final data = entities[entityId]!; + snapshots.forEach((id, oldBytes) { + if (oldBytes == null) { + data.records.remove(id); + } else { + data.records[id] = oldBytes; + } + _markDirty(data.model.name, id, oldBytes); + }); + data.rebuildUniqueIndex(); + }); + undo.lastIds.forEach((entityId, lastId) { + entities[entityId]!.lastId = lastId; + _markMetaDirty(entities[entityId]!); + }); + undo.relations.forEach((relId, snapshots) { + final targetMap = relations[relId]!; + snapshots.forEach((sourceId, oldTargets) { + if (oldTargets == null) { + targetMap.remove(sourceId); + } else { + targetMap[sourceId] = oldTargets; + } + _markRelDirty(relId, sourceId); + }); + }); + _pendingChanges.clear(); + _scheduleFlush(); + } + + R runInTx(R Function() action) { + beginTx(); + try { + final result = action(); + commitTx(); + return result; + } catch (e) { + abortTx(); + rethrow; + } + } + + void _snapshotRecord(EntityData data, int id) { + final undo = _undo; + if (undo == null) return; + undo.records + .putIfAbsent(data.model.id.id, () => {}) + .putIfAbsent(id, () => data.records[id]); + } + + void _snapshotLastId(EntityData data) { + _undo?.lastIds.putIfAbsent(data.model.id.id, () => data.lastId); + } + + void _snapshotRelation(int relId, int sourceId) { + final undo = _undo; + if (undo == null) return; + undo.relations.putIfAbsent(relId, () => {}).putIfAbsent( + sourceId, + () => relations[relId]![sourceId] == null + ? null + : Set.of(relations[relId]![sourceId]!)); + } + + // ------------------------------------------------------------------- CRUD + + int reserveId(EntityData data) { + checkOpen(); + _snapshotLastId(data); + final id = ++data.lastId; + _markMetaDirty(data); + return id; + } + + /// Stores serialized [bytes] under [id]. The caller (Box) has already + /// assigned the id and enforced PutMode semantics via [checkPutId]. + void putRecord(EntityData data, int id, Uint8List bytes) { + checkOpen(); + _checkUniques(data, id, bytes); + _snapshotRecord(data, id); + final oldBytes = data.records[id]; + if (oldBytes != null) data.unindexUniques(id, oldBytes); + data.records[id] = bytes; + data.indexUniques(id, bytes); + if (id > data.lastId) { + _snapshotLastId(data); + data.lastId = id; + _markMetaDirty(data); + } + _markDirty(data.model.name, id, bytes); + _noteChange(data); + } + + /// Validates [PutMode]-like semantics and returns the id to use. + int checkPutId(EntityData data, int id, int mode) { + checkOpen(); + final exists = id != 0 && data.records.containsKey(id); + // OBXPutMode: 1 = PUT, 2 = INSERT, 3 = UPDATE + if (mode == 2 && exists) { + throw ObjectBoxException( + 'object put failed: ID $id already exists (mode insert)'); + } + if (mode == 3 && !exists) { + throw ObjectBoxException( + 'object put failed: ID $id does not exist (mode update)'); + } + if (id == 0) return reserveId(data); + if (id > data.lastId) { + if (!data.idSelfAssignable) { + throw ArgumentError( + 'object put failed: ID $id is higher than the internal ID sequence' + ' (${data.lastId}); use @Id(assignable: true) to assign IDs'); + } + } + return id; + } + + void _checkUniques(EntityData data, int id, Uint8List bytes) { + for (final property in data.uniqueProperties) { + final value = readProperty(property, _view(bytes)); + if (value == null) continue; + final existingId = data.uniqueIndex[property.id.id]![value]; + if (existingId != null && existingId != id) { + if (property.hasFlag(OBXPropertyFlags.UNIQUE_ON_CONFLICT_REPLACE)) { + removeRecord(data, existingId); + } else { + throw UniqueViolationException( + 'Unique constraint for ${data.model.name}.${property.name}' + ' would be violated by putting value "$value"'); + } + } + } + } + + Uint8List? getRecord(EntityData data, int id) { + checkOpen(); + return data.records[id]; + } + + bool removeRecord(EntityData data, int id) { + checkOpen(); + final oldBytes = data.records[id]; + if (oldBytes == null) return false; + _snapshotRecord(data, id); + data.records.remove(id); + data.unindexUniques(id, oldBytes); + _markDirty(data.model.name, id, null); + _removeRelationsOf(data, id); + _noteChange(data); + return true; + } + + int removeAllRecords(EntityData data) { + checkOpen(); + final count = data.records.length; + if (_undo != null) { + // Snapshot every record for rollback. + for (final id in data.records.keys.toList(growable: false)) { + _snapshotRecord(data, id); + } + } + final ids = data.records.keys.toList(growable: false); + data.records.clear(); + data.rebuildUniqueIndex(); + if (!inMemory) { + final dirty = + _dirtyEntities.putIfAbsent(data.model.name, _DirtyEntityStore.new); + dirty.cleared = true; + dirty.entries.clear(); + _scheduleFlush(); + } + for (final id in ids) { + _removeRelationsOf(data, id); + } + _noteChange(data); + return count; + } + + void _removeRelationsOf(EntityData data, int id) { + // Remove standalone relation rows where the object is the source ... + for (final relId + in _relIdsBySourceEntity[data.model.id.id] ?? const []) { + final targetMap = relations[relId]!; + if (targetMap.containsKey(id)) { + _snapshotRelation(relId, id); + targetMap.remove(id); + _markRelDirty(relId, id); + } + } + // ... and where it is the target. + for (final relId + in _relIdsByTargetEntity[data.model.id.id] ?? const []) { + final targetMap = relations[relId]!; + targetMap.forEach((sourceId, targets) { + if (targets.contains(id)) { + _snapshotRelation(relId, sourceId); + targets.remove(id); + _markRelDirty(relId, sourceId); + } + }); + } + } + + // -------------------------------------------------------------- relations + + void relPut(int relId, int sourceId, int targetId) { + checkOpen(); + final targetMap = relations[relId]; + if (targetMap == null) { + throw ArgumentError('Unknown standalone relation ID $relId'); + } + _snapshotRelation(relId, sourceId); + targetMap.putIfAbsent(sourceId, () => {}).add(targetId); + _markRelDirty(relId, sourceId); + } + + void relRemove(int relId, int sourceId, int targetId) { + checkOpen(); + final targetMap = relations[relId]; + if (targetMap == null) { + throw ArgumentError('Unknown standalone relation ID $relId'); + } + _snapshotRelation(relId, sourceId); + final targets = targetMap[sourceId]; + if (targets != null) { + targets.remove(targetId); + if (targets.isEmpty) targetMap.remove(sourceId); + } + _markRelDirty(relId, sourceId); + } + + List relTargets(int relId, int sourceId) { + checkOpen(); + final targets = relations[relId]?[sourceId]; + return targets == null ? const [] : (targets.toList()..sort()); + } + + List relBacklinkSources(int relId, int targetId) { + checkOpen(); + final targetMap = relations[relId]; + if (targetMap == null) return const []; + final sources = []; + targetMap.forEach((sourceId, targets) { + if (targets.contains(targetId)) sources.add(sourceId); + }); + return sources..sort(); + } + + /// Source ids of [sourceData] records whose ToOne property [propertyId] + /// points at [targetId] (one-to-many backlink). + List toOneBacklinkSources( + EntityData sourceData, int propertyId, int targetId) { + checkOpen(); + final property = sourceData.model.properties + .firstWhere((property) => property.id.id == propertyId); + final sources = []; + sourceData.records.forEach((id, bytes) { + if (readIntProperty(property, _view(bytes)) == targetId) { + sources.add(id); + } + }); + return sources; + } + + // ------------------------------------------------------------ persistence + + void _markDirty(String storeName, int id, Uint8List? bytes) { + if (inMemory) return; + _dirtyEntities.putIfAbsent(storeName, _DirtyEntityStore.new).entries[id] = + bytes; + _scheduleFlush(); + } + + void _markRelDirty(int relId, int sourceId) { + if (inMemory) return; + _dirtyRelations['$relId:$sourceId'] = relations[relId]![sourceId]?.toList(); + _scheduleFlush(); + } + + void _markMetaDirty(EntityData data) { + if (inMemory) return; + _dirtyMeta['seq:${data.model.name}'] = data.lastId; + _scheduleFlush(); + } + + void _scheduleFlush() { + if (inMemory || _flushScheduled || _txDepth > 0) return; + _flushScheduled = true; + _flushChain = _flushChain.then((_) => _flush()); + } + + Future _flush() async { + // Let the current synchronous batch of mutations finish first. + await Future.delayed(Duration.zero); + _flushScheduled = false; + if (_dirtyEntities.isEmpty && + _dirtyRelations.isEmpty && + _dirtyMeta.isEmpty) { + return; + } + await ready; + final db = _db; + if (db == null) return; + + final entityBatch = Map.of(_dirtyEntities); + final relBatch = Map.of(_dirtyRelations); + final metaBatch = Map.of(_dirtyMeta); + _dirtyEntities.clear(); + _dirtyRelations.clear(); + _dirtyMeta.clear(); + + final storeNames = { + ...entityBatch.keys, + if (relBatch.isNotEmpty) relStoreName, + if (metaBatch.isNotEmpty) metaStoreName, + }; + if (storeNames.isEmpty) return; + + final transaction = db.transaction( + storeNames.map((name) => name.toJS).toList().toJS, 'readwrite'); + entityBatch.forEach((storeName, dirty) { + final store = transaction.objectStore(storeName); + if (dirty.cleared) store.clear(); + dirty.entries.forEach((id, bytes) { + if (bytes == null) { + store.delete(id.toJS); + } else { + store.put(bytes.toJS, id.toJS); + } + }); + }); + if (relBatch.isNotEmpty) { + final store = transaction.objectStore(relStoreName); + relBatch.forEach((key, targets) { + if (targets == null) { + store.delete(key.toJS); + } else { + store.put(targets.map((id) => id.toJS).toList().toJS, key.toJS); + } + }); + } + if (metaBatch.isNotEmpty) { + final store = transaction.objectStore(metaStoreName); + metaBatch.forEach((key, value) { + store.put(value.toJS, key.toJS); + }); + } + await idbTransactionDone(transaction); + } + + /// Completes when all currently queued writes have been persisted. + Future awaitQueueCompletion() async { + await ready; + // The chain may grow while we wait; loop until it is stable. + Future current; + do { + current = _flushChain; + await current; + } while (!identical(current, _flushChain)); + } + + // ----------------------------------------------------------------- events + + void _noteChange(EntityData data) { + _pendingChanges.add(data.definition.type()); + if (_txDepth == 0) _emitChanges(); + } + + void _emitChanges() { + if (_pendingChanges.isEmpty) return; + final changed = _pendingChanges.toList(growable: false); + _pendingChanges.clear(); + if (changes.hasListener) changes.add(changed); + } + + // ------------------------------------------------------------------ close + + void notifyCloseListeners() { + for (final listener in closeListeners.values.toList(growable: false)) { + listener(); + } + closeListeners.clear(); + } + + Future close() async { + if (_closed) return; + _closed = true; + changes.close(); + if (!inMemory) { + try { + await ready; + await awaitQueueCompletion(); + } finally { + _db?.close(); + _db = null; + } + } + } +} diff --git a/objectbox/lib/src/web/fb_reader.dart b/objectbox/lib/src/web/fb_reader.dart new file mode 100644 index 000000000..be28fdb38 --- /dev/null +++ b/objectbox/lib/src/web/fb_reader.dart @@ -0,0 +1,79 @@ +// Generic FlatBuffers property reader for the web implementation: reads a +// single property value out of a stored record without needing the generated +// objectFromFB (used for @Unique enforcement and ToOne backlinks; later also +// by the query engine). +// +// The generated serialization assigns property with model id N to table field +// slot N-1, i.e. vtable offset 2 * N + 2 (see generator/lib/src/code_chunks.dart). +// ignore_for_file: public_member_api_docs + +import 'dart:typed_data'; + +import '../../flatbuffers.dart' as fb; + +import '../modelinfo/enums.dart'; +import '../modelinfo/modelproperty.dart'; + +int vTableOffset(ModelProperty property) => property.id.id * 2 + 2; + +/// Reads the value of [property] from a serialized record. +/// +/// Returns null for absent optional values. Scalars, strings and vectors are +/// supported; [OBXPropertyType.Flex] is not (returns null). +Object? readProperty(ModelProperty property, ByteData data) { + final buffer = fb.BufferContext(data); + final rootOffset = buffer.derefObject(0); + final offset = vTableOffset(property); + switch (property.type) { + case OBXPropertyType.Bool: + return const fb.BoolReader() + .vTableGetNullable(buffer, rootOffset, offset); + case OBXPropertyType.Byte: + case OBXPropertyType.Short: + case OBXPropertyType.Char: + case OBXPropertyType.Int: + case OBXPropertyType.Long: + case OBXPropertyType.Date: + case OBXPropertyType.DateNano: + case OBXPropertyType.Relation: + return const fb.Int64Reader() + .vTableGetNullable(buffer, rootOffset, offset); + case OBXPropertyType.Float: + return const fb.Float32Reader() + .vTableGetNullable(buffer, rootOffset, offset); + case OBXPropertyType.Double: + return const fb.Float64Reader() + .vTableGetNullable(buffer, rootOffset, offset); + case OBXPropertyType.String: + return const fb.StringReader(asciiOptimization: true) + .vTableGetNullable(buffer, rootOffset, offset); + case OBXPropertyType.ByteVector: + return const fb.Uint8ListReader() + .vTableGetNullable(buffer, rootOffset, offset); + case OBXPropertyType.StringVector: + return const fb.ListReader(fb.StringReader(), lazy: false) + .vTableGetNullable(buffer, rootOffset, offset); + case OBXPropertyType.FloatVector: + return const fb.ListReader(fb.Float32Reader(), lazy: false) + .vTableGetNullable(buffer, rootOffset, offset); + case OBXPropertyType.DoubleVector: + return const fb.ListReader(fb.Float64Reader(), lazy: false) + .vTableGetNullable(buffer, rootOffset, offset); + case OBXPropertyType.IntVector: + case OBXPropertyType.LongVector: + case OBXPropertyType.DateVector: + case OBXPropertyType.DateNanoVector: + return const fb.ListReader(fb.Int64Reader(), lazy: false) + .vTableGetNullable(buffer, rootOffset, offset); + default: + return null; + } +} + +/// Reads the int64 value of [property] (e.g. a ToOne target id), 0 if unset. +int readIntProperty(ModelProperty property, ByteData data) { + final buffer = fb.BufferContext(data); + final rootOffset = buffer.derefObject(0); + return const fb.Int64Reader() + .vTableGet(buffer, rootOffset, vTableOffset(property), 0); +} diff --git a/objectbox/lib/src/web/flatbuffers/flat_buffers.dart b/objectbox/lib/src/web/flatbuffers/flat_buffers.dart new file mode 100644 index 000000000..90ec8f90b --- /dev/null +++ b/objectbox/lib/src/web/flatbuffers/flat_buffers.dart @@ -0,0 +1,1543 @@ +// Vendored from package:flat_buffers 25.9.23 (Apache-2.0, Copyright Google +// Inc.) for the ObjectBox web implementation, with one change: all 64-bit +// integer ByteData accessors are replaced with JavaScript-safe versions built +// from two 32-bit halves, because dart2js does not support +// ByteData.get/setInt64/Uint64. Values keep full precision up to 2^53 (all +// JavaScript numbers are doubles). Used via the conditional export in +// lib/flatbuffers.dart; native platforms use the real package. +// ignore_for_file: type=lint, unused_element, unnecessary_cast +import 'dart:collection'; +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +const int _sizeofUint8 = 1; +const int _sizeofUint16 = 2; +const int _sizeofUint32 = 4; +const int _sizeofUint64 = 8; +const int _sizeofInt8 = 1; +const int _sizeofInt16 = 2; +const int _sizeofInt32 = 4; +const int _sizeofInt64 = 8; +const int _sizeofFloat32 = 4; +const int _sizeofFloat64 = 8; + +/// Callback used to invoke a struct builder's finish method. +/// +/// This callback is used by other struct's `finish` methods to write the nested +/// struct's fields inline. +typedef StructBuilder = void Function(); + +/// Buffer with data and some context about it. +class BufferContext { + final ByteData _buffer; + + ByteData get buffer => _buffer; + + /// Create from a FlatBuffer represented by a list of bytes (uint8). + factory BufferContext.fromBytes(List byteList) => BufferContext( + byteList is Uint8List + ? byteList.buffer.asByteData(byteList.offsetInBytes) + : ByteData.view(Uint8List.fromList(byteList).buffer), + ); + + /// Create from a FlatBuffer represented by ByteData. + BufferContext(this._buffer); + + @pragma('vm:prefer-inline') + int derefObject(int offset) => offset + _getUint32(offset); + + @pragma('vm:prefer-inline') + Uint8List _asUint8List(int offset, int length) => + _buffer.buffer.asUint8List(_buffer.offsetInBytes + offset, length); + + @pragma('vm:prefer-inline') + double _getFloat64(int offset) => _buffer.getFloat64(offset, Endian.little); + + @pragma('vm:prefer-inline') + double _getFloat32(int offset) => _buffer.getFloat32(offset, Endian.little); + + @pragma('vm:prefer-inline') + int _getInt64(int offset) => _getInt64Js(_buffer, offset); + + @pragma('vm:prefer-inline') + int _getInt32(int offset) => _buffer.getInt32(offset, Endian.little); + + @pragma('vm:prefer-inline') + int _getInt16(int offset) => _buffer.getInt16(offset, Endian.little); + + @pragma('vm:prefer-inline') + int _getInt8(int offset) => _buffer.getInt8(offset); + + @pragma('vm:prefer-inline') + int _getUint64(int offset) => _getUint64Js(_buffer, offset); + + @pragma('vm:prefer-inline') + int _getUint32(int offset) => _buffer.getUint32(offset, Endian.little); + + @pragma('vm:prefer-inline') + int _getUint16(int offset) => _buffer.getUint16(offset, Endian.little); + + @pragma('vm:prefer-inline') + int _getUint8(int offset) => _buffer.getUint8(offset); +} + +/// Interface implemented by the "object-api" classes (ending with "T"). +abstract class Packable { + /// Serialize the object using the given builder, returning the offset. + int pack(Builder fbBuilder); +} + +/// Class implemented by typed builders generated by flatc. +abstract class ObjectBuilder { + int? _firstOffset; + + /// Can be used to write the data represented by this builder to the [Builder] + /// and reuse the offset created in multiple tables. + /// + /// Note that this method assumes you call it using the same [Builder] instance + /// every time. The returned offset is only good for the [Builder] used in the + /// first call to this method. + int getOrCreateOffset(Builder fbBuilder) { + _firstOffset ??= finish(fbBuilder); + return _firstOffset!; + } + + /// Writes the data in this helper to the [Builder]. + int finish(Builder fbBuilder); + + /// Convenience method that will create a new [Builder], [finish]es the data, + /// and returns the buffer as a [Uint8List] of bytes. + Uint8List toBytes(); +} + +/// Class that helps building flat buffers. +class Builder { + bool _finished = false; + + final int initialSize; + + /// The list of existing VTable(s). + final List _vTables; + + final bool deduplicateTables; + + ByteData _buf; + + final Allocator _allocator; + + /// The maximum alignment that has been seen so far. If [_buf] has to be + /// reallocated in the future (to insert room at its start for more bytes) the + /// reallocation will need to be a multiple of this many bytes. + int _maxAlign = 1; + + /// The number of bytes that have been written to the buffer so far. The + /// most recently written byte is this many bytes from the end of [_buf]. + int _tail = 0; + + /// The location of the end of the current table, measured in bytes from the + /// end of [_buf]. + int _currentTableEndTail = 0; + + _VTable? _currentVTable; + + /// Map containing all strings that have been written so far. This allows us + /// to avoid duplicating strings. + /// + /// Allocated only if `internStrings` is set to true on the constructor. + Map? _strings; + + /// Creates a new FlatBuffers Builder. + /// + /// `initialSize` is the initial array size in bytes. The [Builder] will + /// automatically grow the array if/as needed. `internStrings`, if set to + /// true, will cause [writeString] to pool strings in the buffer so that + /// identical strings will always use the same offset in tables. + Builder({ + this.initialSize = 1024, + bool internStrings = false, + Allocator allocator = const DefaultAllocator(), + this.deduplicateTables = true, + }) : _allocator = allocator, + _buf = allocator.allocate(initialSize), + _vTables = deduplicateTables ? [] : const [] { + if (internStrings) { + _strings = {}; + } + } + + /// Calculate the finished buffer size (aligned). + @pragma('vm:prefer-inline') + int size() => _tail + ((-_tail) & (_maxAlign - 1)); + + /// Add the [field] with the given boolean [value]. The field is not added if + /// the [value] is equal to [def]. Booleans are stored as 8-bit fields with + /// `0` for `false` and `1` for `true`. + void addBool(int field, bool? value, [bool? def]) { + assert(_inVTable); + if (value != null && value != def) { + _prepare(_sizeofUint8, 1); + _trackField(field); + _buf.setInt8(_buf.lengthInBytes - _tail, value ? 1 : 0); + } + } + + /// Add the [field] with the given 32-bit signed integer [value]. The field is + /// not added if the [value] is equal to [def]. + void addInt32(int field, int? value, [int? def]) { + assert(_inVTable); + if (value != null && value != def) { + _prepare(_sizeofInt32, 1); + _trackField(field); + _setInt32AtTail(_tail, value); + } + } + + /// Add the [field] with the given 32-bit signed integer [value]. The field is + /// not added if the [value] is equal to [def]. + void addInt16(int field, int? value, [int? def]) { + assert(_inVTable); + if (value != null && value != def) { + _prepare(_sizeofInt16, 1); + _trackField(field); + _setInt16AtTail(_tail, value); + } + } + + /// Add the [field] with the given 8-bit signed integer [value]. The field is + /// not added if the [value] is equal to [def]. + void addInt8(int field, int? value, [int? def]) { + assert(_inVTable); + if (value != null && value != def) { + _prepare(_sizeofInt8, 1); + _trackField(field); + _setInt8AtTail(_tail, value); + } + } + + void addStruct(int field, int offset) { + assert(_inVTable); + _trackField(field); + _currentVTable!.addField(field, offset); + } + + /// Add the [field] referencing an object with the given [offset]. + void addOffset(int field, int? offset) { + assert(_inVTable); + if (offset != null) { + _prepare(_sizeofUint32, 1); + _trackField(field); + _setUint32AtTail(_tail, _tail - offset); + } + } + + /// Add the [field] with the given 32-bit unsigned integer [value]. The field + /// is not added if the [value] is equal to [def]. + void addUint32(int field, int? value, [int? def]) { + assert(_inVTable); + if (value != null && value != def) { + _prepare(_sizeofUint32, 1); + _trackField(field); + _setUint32AtTail(_tail, value); + } + } + + /// Add the [field] with the given 32-bit unsigned integer [value]. The field + /// is not added if the [value] is equal to [def]. + void addUint16(int field, int? value, [int? def]) { + assert(_inVTable); + if (value != null && value != def) { + _prepare(_sizeofUint16, 1); + _trackField(field); + _setUint16AtTail(_tail, value); + } + } + + /// Add the [field] with the given 8-bit unsigned integer [value]. The field + /// is not added if the [value] is equal to [def]. + void addUint8(int field, int? value, [int? def]) { + assert(_inVTable); + if (value != null && value != def) { + _prepare(_sizeofUint8, 1); + _trackField(field); + _setUint8AtTail(_tail, value); + } + } + + /// Add the [field] with the given 32-bit float [value]. The field + /// is not added if the [value] is equal to [def]. + void addFloat32(int field, double? value, [double? def]) { + assert(_inVTable); + if (value != null && value != def) { + _prepare(_sizeofFloat32, 1); + _trackField(field); + _setFloat32AtTail(_tail, value); + } + } + + /// Add the [field] with the given 64-bit double [value]. The field + /// is not added if the [value] is equal to [def]. + void addFloat64(int field, double? value, [double? def]) { + assert(_inVTable); + if (value != null && value != def) { + _prepare(_sizeofFloat64, 1); + _trackField(field); + _setFloat64AtTail(_tail, value); + } + } + + /// Add the [field] with the given 64-bit unsigned integer [value]. The field + /// is not added if the [value] is equal to [def]. + void addUint64(int field, int? value, [double? def]) { + assert(_inVTable); + if (value != null && value != def) { + _prepare(_sizeofUint64, 1); + _trackField(field); + _setUint64AtTail(_tail, value); + } + } + + /// Add the [field] with the given 64-bit unsigned integer [value]. The field + /// is not added if the [value] is equal to [def]. + void addInt64(int field, int? value, [double? def]) { + assert(_inVTable); + if (value != null && value != def) { + _prepare(_sizeofInt64, 1); + _trackField(field); + _setInt64AtTail(_tail, value); + } + } + + /// End the current table and return its offset. + int endTable() { + assert(_inVTable); + // Prepare for writing the VTable. + _prepare(_sizeofInt32, 1); + final tableTail = _tail; + // Prepare the size of the current table. + final currentVTable = _currentVTable!; + currentVTable.tableSize = tableTail - _currentTableEndTail; + // Prepare the VTable to use for the current table. + int? vTableTail; + { + currentVTable.computeFieldOffsets(tableTail); + + // Try to find an existing compatible VTable. + if (deduplicateTables) { + // Search backward - more likely to have recently used one + for (var i = _vTables.length - 1; i >= 0; i--) { + final vt2Offset = _vTables[i]; + final vt2Start = _buf.lengthInBytes - vt2Offset; + final vt2Size = _buf.getUint16(vt2Start, Endian.little); + + if (currentVTable._vTableSize == vt2Size && + currentVTable._offsetsMatch(vt2Start, _buf)) { + vTableTail = vt2Offset; + break; + } + } + } + + // Write a new VTable. + if (vTableTail == null) { + _prepare(_sizeofUint16, _currentVTable!.numOfUint16); + vTableTail = _tail; + currentVTable.tail = vTableTail; + currentVTable.output(_buf, _buf.lengthInBytes - _tail); + if (deduplicateTables) _vTables.add(currentVTable.tail); + } + } + // Set the VTable offset. + _setInt32AtTail(tableTail, vTableTail - tableTail); + // Done with this table. + _currentVTable = null; + return tableTail; + } + + /// Returns the finished buffer. You must call [finish] before accessing this. + @pragma('vm:prefer-inline') + Uint8List get buffer { + assert(_finished); + final finishedSize = size(); + return _buf.buffer.asUint8List( + _buf.lengthInBytes - finishedSize, + finishedSize, + ); + } + + /// Finish off the creation of the buffer. The given [offset] is used as the + /// root object offset, and usually references directly or indirectly every + /// written object. If [fileIdentifier] is specified (and not `null`), it is + /// interpreted as a 4-byte Latin-1 encoded string that should be placed at + /// bytes 4-7 of the file. + void finish(int offset, [String? fileIdentifier]) { + final sizeBeforePadding = size(); + final requiredBytes = _sizeofUint32 * (fileIdentifier == null ? 1 : 2); + _prepare(max(requiredBytes, _maxAlign), 1); + final finishedSize = size(); + _setUint32AtTail(finishedSize, finishedSize - offset); + if (fileIdentifier != null) { + for (var i = 0; i < 4; i++) { + _setUint8AtTail( + finishedSize - _sizeofUint32 - i, + fileIdentifier.codeUnitAt(i), + ); + } + } + + // zero out the added padding + for (var i = sizeBeforePadding + 1; + i <= finishedSize - requiredBytes; + i++) { + _setUint8AtTail(i, 0); + } + _finished = true; + } + + /// Writes a Float64 to the tail of the buffer after preparing space for it. + /// + /// Updates the [offset] pointer. This method is intended for use when writing structs to the buffer. + void putFloat64(double value) { + _prepare(_sizeofFloat64, 1); + _setFloat64AtTail(_tail, value); + } + + /// Writes a Float32 to the tail of the buffer after preparing space for it. + /// + /// Updates the [offset] pointer. This method is intended for use when writing structs to the buffer. + void putFloat32(double value) { + _prepare(_sizeofFloat32, 1); + _setFloat32AtTail(_tail, value); + } + + /// Writes a bool to the tail of the buffer after preparing space for it. + /// Bools are represented as a Uint8, with the value set to '1' for true, and '0' for false + /// + /// Updates the [offset] pointer. This method is intended for use when writing structs to the buffer. + void putBool(bool value) { + _prepare(_sizeofUint8, 1); + _buf.setInt8(_buf.lengthInBytes - _tail, value ? 1 : 0); + } + + /// Writes a Int64 to the tail of the buffer after preparing space for it. + /// + /// Updates the [offset] pointer. This method is intended for use when writing structs to the buffer. + void putInt64(int value) { + _prepare(_sizeofInt64, 1); + _setInt64AtTail(_tail, value); + } + + /// Writes a Uint32 to the tail of the buffer after preparing space for it. + /// + /// Updates the [offset] pointer. This method is intended for use when writing structs to the buffer. + void putInt32(int value) { + _prepare(_sizeofInt32, 1); + _setInt32AtTail(_tail, value); + } + + /// Writes a Uint16 to the tail of the buffer after preparing space for it. + /// + /// Updates the [offset] pointer. This method is intended for use when writing structs to the buffer. + void putInt16(int value) { + _prepare(_sizeofInt16, 1); + _setInt16AtTail(_tail, value); + } + + /// Writes a Uint8 to the tail of the buffer after preparing space for it. + /// + /// Updates the [offset] pointer. This method is intended for use when writing structs to the buffer. + void putInt8(int value) { + _prepare(_sizeofInt8, 1); + _buf.setInt8(_buf.lengthInBytes - _tail, value); + } + + /// Writes a Uint64 to the tail of the buffer after preparing space for it. + /// + /// Updates the [offset] pointer. This method is intended for use when writing structs to the buffer. + void putUint64(int value) { + _prepare(_sizeofUint64, 1); + _setUint64AtTail(_tail, value); + } + + /// Writes a Uint32 to the tail of the buffer after preparing space for it. + /// + /// Updates the [offset] pointer. This method is intended for use when writing structs to the buffer. + void putUint32(int value) { + _prepare(_sizeofUint32, 1); + _setUint32AtTail(_tail, value); + } + + /// Writes a Uint16 to the tail of the buffer after preparing space for it. + /// + /// Updates the [offset] pointer. This method is intended for use when writing structs to the buffer. + void putUint16(int value) { + _prepare(_sizeofUint16, 1); + _setUint16AtTail(_tail, value); + } + + /// Writes a Uint8 to the tail of the buffer after preparing space for it. + /// + /// Updates the [offset] pointer. This method is intended for use when writing structs to the buffer. + void putUint8(int value) { + _prepare(_sizeofUint8, 1); + _buf.setUint8(_buf.lengthInBytes - _tail, value); + } + + /// Reset the builder and make it ready for filling a new buffer. + void reset() { + _finished = false; + _maxAlign = 1; + _tail = 0; + _currentVTable = null; + if (deduplicateTables) _vTables.clear(); + if (_strings != null) { + _strings = {}; + } + } + + /// Start a new table. Must be finished with [endTable] invocation. + void startTable(int numFields) { + assert(!_inVTable); // Inline tables are not supported. + _currentVTable = _VTable(numFields); + _currentTableEndTail = _tail; + } + + /// Finish a Struct vector. Most callers should preferto use [writeListOfStructs]. + /// + /// Most callers should prefer [writeListOfStructs]. + int endStructVector(int count) { + putUint32(count); + return _tail; + } + + /// Writes a list of Structs to the buffer, returning the offset + int writeListOfStructs(List structBuilders) { + assert(!_inVTable); + for (var i = structBuilders.length - 1; i >= 0; i--) { + structBuilders[i].finish(this); + } + return endStructVector(structBuilders.length); + } + + /// Write the given list of [values]. + int writeList(List values) { + assert(!_inVTable); + _prepare(_sizeofUint32, 1 + values.length); + final result = _tail; + var tail = _tail; + _setUint32AtTail(tail, values.length); + tail -= _sizeofUint32; + for (final value in values) { + _setUint32AtTail(tail, tail - value); + tail -= _sizeofUint32; + } + return result; + } + + /// Write the given list of 64-bit float [values]. + int writeListFloat64(List values) { + assert(!_inVTable); + _prepare(_sizeofFloat64, values.length, additionalBytes: _sizeofUint32); + final result = _tail; + var tail = _tail; + _setUint32AtTail(tail, values.length); + tail -= _sizeofUint32; + for (final value in values) { + _setFloat64AtTail(tail, value); + tail -= _sizeofFloat64; + } + return result; + } + + /// Write the given list of 32-bit float [values]. + int writeListFloat32(List values) { + assert(!_inVTable); + _prepare(_sizeofFloat32, 1 + values.length); + final result = _tail; + var tail = _tail; + _setUint32AtTail(tail, values.length); + tail -= _sizeofUint32; + for (final value in values) { + _setFloat32AtTail(tail, value); + tail -= _sizeofFloat32; + } + return result; + } + + /// Write the given list of signed 64-bit integer [values]. + int writeListInt64(List values) { + assert(!_inVTable); + _prepare(_sizeofInt64, values.length, additionalBytes: _sizeofUint32); + final result = _tail; + var tail = _tail; + _setUint32AtTail(tail, values.length); + tail -= _sizeofUint32; + for (final value in values) { + _setInt64AtTail(tail, value); + tail -= _sizeofInt64; + } + return result; + } + + /// Write the given list of signed 64-bit integer [values]. + int writeListUint64(List values) { + assert(!_inVTable); + _prepare(_sizeofUint64, values.length, additionalBytes: _sizeofUint32); + final result = _tail; + var tail = _tail; + _setUint32AtTail(tail, values.length); + tail -= _sizeofUint32; + for (final value in values) { + _setUint64AtTail(tail, value); + tail -= _sizeofUint64; + } + return result; + } + + /// Write the given list of signed 32-bit integer [values]. + int writeListInt32(List values) { + assert(!_inVTable); + _prepare(_sizeofUint32, 1 + values.length); + final result = _tail; + var tail = _tail; + _setUint32AtTail(tail, values.length); + tail -= _sizeofUint32; + for (final value in values) { + _setInt32AtTail(tail, value); + tail -= _sizeofInt32; + } + return result; + } + + /// Write the given list of unsigned 32-bit integer [values]. + int writeListUint32(List values) { + assert(!_inVTable); + _prepare(_sizeofUint32, 1 + values.length); + final result = _tail; + var tail = _tail; + _setUint32AtTail(tail, values.length); + tail -= _sizeofUint32; + for (final value in values) { + _setUint32AtTail(tail, value); + tail -= _sizeofUint32; + } + return result; + } + + /// Write the given list of signed 16-bit integer [values]. + int writeListInt16(List values) { + assert(!_inVTable); + _prepare(_sizeofUint32, 1, additionalBytes: 2 * values.length); + final result = _tail; + var tail = _tail; + _setUint32AtTail(tail, values.length); + tail -= _sizeofUint32; + for (final value in values) { + _setInt16AtTail(tail, value); + tail -= _sizeofInt16; + } + return result; + } + + /// Write the given list of unsigned 16-bit integer [values]. + int writeListUint16(List values) { + assert(!_inVTable); + _prepare(_sizeofUint32, 1, additionalBytes: 2 * values.length); + final result = _tail; + var tail = _tail; + _setUint32AtTail(tail, values.length); + tail -= _sizeofUint32; + for (final value in values) { + _setUint16AtTail(tail, value); + tail -= _sizeofUint16; + } + return result; + } + + /// Write the given list of bools as unsigend 8-bit integer [values]. + int writeListBool(List values) { + return writeListUint8(values.map((b) => b ? 1 : 0).toList()); + } + + /// Write the given list of signed 8-bit integer [values]. + int writeListInt8(List values) { + assert(!_inVTable); + _prepare(_sizeofUint32, 1, additionalBytes: values.length); + final result = _tail; + var tail = _tail; + _setUint32AtTail(tail, values.length); + tail -= _sizeofUint32; + for (final value in values) { + _setInt8AtTail(tail, value); + tail -= _sizeofUint8; + } + return result; + } + + /// Write the given list of unsigned 8-bit integer [values]. + int writeListUint8(List values) { + assert(!_inVTable); + _prepare(_sizeofUint32, 1, additionalBytes: values.length); + final result = _tail; + var tail = _tail; + _setUint32AtTail(tail, values.length); + tail -= _sizeofUint32; + for (final value in values) { + _setUint8AtTail(tail, value); + tail -= _sizeofUint8; + } + return result; + } + + /// Write the given string [value] and return its offset. + /// + /// Dart strings are UTF-16 but must be stored as UTF-8 in FlatBuffers. + /// If the given string consists only of ASCII characters, you can indicate + /// enable [asciiOptimization]. In this mode, [writeString()] first tries to + /// copy the ASCII string directly to the output buffer and if that fails + /// (because there are no-ASCII characters in the string) it falls back and to + /// the default UTF-16 -> UTF-8 conversion (with slight performance penalty). + int writeString(String value, {bool asciiOptimization = false}) { + assert(!_inVTable); + if (_strings != null) { + return _strings!.putIfAbsent( + value, + () => _writeString(value, asciiOptimization), + ); + } else { + return _writeString(value, asciiOptimization); + } + } + + int _writeString(String value, bool asciiOptimization) { + if (asciiOptimization) { + // [utf8.encode()] is slow (up to at least Dart SDK 2.13). If the given + // string is ASCII we can just write it directly, without any conversion. + final originalTail = _tail; + if (_tryWriteASCIIString(value)) return _tail; + // if non-ASCII: reset the output buffer position for [_writeUTFString()] + _tail = originalTail; + } + _writeUTFString(value); + return _tail; + } + + // Try to write the string as ASCII, return false if there's a non-ascii char. + @pragma('vm:prefer-inline') + bool _tryWriteASCIIString(String value) { + _prepare(4, 1, additionalBytes: value.length + 1); + final length = value.length; + var offset = _buf.lengthInBytes - _tail + 4; + for (var i = 0; i < length; i++) { + // utf16 code unit, e.g. for '†' it's [0x20 0x20], which is 8224 decimal. + // ASCII characters go from 0x00 to 0x7F (which is 0 to 127 decimal). + final char = value.codeUnitAt(i); + if ((char & ~0x7F) != 0) { + return false; + } + _buf.setUint8(offset++, char); + } + _buf.setUint8(offset, 0); // trailing zero + _setUint32AtTail(_tail, value.length); + return true; + } + + @pragma('vm:prefer-inline') + void _writeUTFString(String value) { + final bytes = utf8.encode(value) as Uint8List; + final length = bytes.length; + _prepare(4, 1, additionalBytes: length + 1); + _setUint32AtTail(_tail, length); + var offset = _buf.lengthInBytes - _tail + 4; + for (var i = 0; i < length; i++) { + _buf.setUint8(offset++, bytes[i]); + } + _buf.setUint8(offset, 0); // trailing zero + } + + /// Used to assert whether a "Table" is currently being built. + /// + /// If you hit `assert(!_inVTable())`, you're trying to add table fields + /// without starting a table with [Builder.startTable()]. + /// + /// If you hit `assert(_inVTable())`, you're trying to construct a + /// Table/Vector/String during the construction of its parent table, + /// between the MyTableBuilder and [Builder.endTable()]. + /// Move the creation of these sub-objects to before the MyTableBuilder to + /// not get this assert. + @pragma('vm:prefer-inline') + bool get _inVTable => _currentVTable != null; + + /// The number of bytes that have been written to the buffer so far. The + /// most recently written byte is this many bytes from the end of the buffer. + @pragma('vm:prefer-inline') + int get offset => _tail; + + /// Zero-pads the buffer, which may be required for some struct layouts. + @pragma('vm:prefer-inline') + void pad(int howManyBytes) { + for (var i = 0; i < howManyBytes; i++) { + putUint8(0); + } + } + + /// Prepare for writing the given `count` of scalars of the given `size`. + /// Additionally allocate the specified `additionalBytes`. Update the current + /// tail pointer to point at the allocated space. + @pragma('vm:prefer-inline') + void _prepare(int size, int count, {int additionalBytes = 0}) { + assert(!_finished); + // Update the alignment. + if (_maxAlign < size) { + _maxAlign = size; + } + // Prepare amount of required space. + final dataSize = size * count + additionalBytes; + final alignDelta = (-(_tail + dataSize)) & (size - 1); + final bufSize = alignDelta + dataSize; + // Ensure that we have the required amount of space. + { + final oldCapacity = _buf.lengthInBytes; + if (_tail + bufSize > oldCapacity) { + final desiredNewCapacity = (oldCapacity + bufSize) * 2; + var deltaCapacity = desiredNewCapacity - oldCapacity; + deltaCapacity += (-deltaCapacity) & (_maxAlign - 1); + final newCapacity = oldCapacity + deltaCapacity; + _buf = _allocator.resize(_buf, newCapacity, _tail, 0); + } + } + + // zero out the added padding + for (var i = _tail + 1; i <= _tail + alignDelta; i++) { + _setUint8AtTail(i, 0); + } + + // Update the tail pointer. + _tail += bufSize; + } + + /// Record the offset of the given [field]. + @pragma('vm:prefer-inline') + void _trackField(int field) => _currentVTable!.addField(field, _tail); + + @pragma('vm:prefer-inline') + void _setFloat64AtTail(int tail, double x) => + _buf.setFloat64(_buf.lengthInBytes - tail, x, Endian.little); + + @pragma('vm:prefer-inline') + void _setFloat32AtTail(int tail, double x) => + _buf.setFloat32(_buf.lengthInBytes - tail, x, Endian.little); + + @pragma('vm:prefer-inline') + void _setUint64AtTail(int tail, int x) => + _setInt64Js(_buf, _buf.lengthInBytes - tail, x); + + @pragma('vm:prefer-inline') + void _setInt64AtTail(int tail, int x) => + _setInt64Js(_buf, _buf.lengthInBytes - tail, x); + + @pragma('vm:prefer-inline') + void _setInt32AtTail(int tail, int x) => + _buf.setInt32(_buf.lengthInBytes - tail, x, Endian.little); + + @pragma('vm:prefer-inline') + void _setUint32AtTail(int tail, int x) => + _buf.setUint32(_buf.lengthInBytes - tail, x, Endian.little); + + @pragma('vm:prefer-inline') + void _setInt16AtTail(int tail, int x) => + _buf.setInt16(_buf.lengthInBytes - tail, x, Endian.little); + + @pragma('vm:prefer-inline') + void _setUint16AtTail(int tail, int x) => + _buf.setUint16(_buf.lengthInBytes - tail, x, Endian.little); + + @pragma('vm:prefer-inline') + void _setInt8AtTail(int tail, int x) => + _buf.setInt8(_buf.lengthInBytes - tail, x); + + @pragma('vm:prefer-inline') + void _setUint8AtTail(int tail, int x) => + _buf.setUint8(_buf.lengthInBytes - tail, x); +} + +/// Reader of lists of boolean values. +/// +/// The returned unmodifiable lists lazily read values on access. +class BoolListReader extends Reader> { + const BoolListReader(); + + @override + @pragma('vm:prefer-inline') + int get size => _sizeofUint32; + + @override + @pragma('vm:prefer-inline') + List read(BufferContext bc, int offset) => + _FbBoolList(bc, bc.derefObject(offset)); +} + +/// The reader of booleans. +class BoolReader extends Reader { + const BoolReader() : super(); + + @override + @pragma('vm:prefer-inline') + int get size => _sizeofUint8; + + @override + @pragma('vm:prefer-inline') + bool read(BufferContext bc, int offset) => bc._getInt8(offset) != 0; +} + +/// The reader of lists of 64-bit float values. +/// +/// The returned unmodifiable lists lazily read values on access. +class Float64ListReader extends Reader> { + const Float64ListReader(); + + @override + @pragma('vm:prefer-inline') + int get size => _sizeofFloat64; + + @override + @pragma('vm:prefer-inline') + List read(BufferContext bc, int offset) => + _FbFloat64List(bc, bc.derefObject(offset)); +} + +class Float32ListReader extends Reader> { + const Float32ListReader(); + + @override + @pragma('vm:prefer-inline') + int get size => _sizeofFloat32; + + @override + @pragma('vm:prefer-inline') + List read(BufferContext bc, int offset) => + _FbFloat32List(bc, bc.derefObject(offset)); +} + +class Float64Reader extends Reader { + const Float64Reader(); + + @override + @pragma('vm:prefer-inline') + int get size => _sizeofFloat64; + + @override + @pragma('vm:prefer-inline') + double read(BufferContext bc, int offset) => bc._getFloat64(offset); +} + +class Float32Reader extends Reader { + const Float32Reader(); + + @override + @pragma('vm:prefer-inline') + int get size => _sizeofFloat32; + + @override + @pragma('vm:prefer-inline') + double read(BufferContext bc, int offset) => bc._getFloat32(offset); +} + +class Int64Reader extends Reader { + const Int64Reader() : super(); + + @override + @pragma('vm:prefer-inline') + int get size => _sizeofInt64; + + @override + @pragma('vm:prefer-inline') + int read(BufferContext bc, int offset) => bc._getInt64(offset); +} + +/// The reader of signed 32-bit integers. +class Int32Reader extends Reader { + const Int32Reader() : super(); + + @override + @pragma('vm:prefer-inline') + int get size => _sizeofInt32; + + @override + @pragma('vm:prefer-inline') + int read(BufferContext bc, int offset) => bc._getInt32(offset); +} + +/// The reader of signed 32-bit integers. +class Int16Reader extends Reader { + const Int16Reader() : super(); + + @override + @pragma('vm:prefer-inline') + int get size => _sizeofInt16; + + @override + @pragma('vm:prefer-inline') + int read(BufferContext bc, int offset) => bc._getInt16(offset); +} + +/// The reader of 8-bit signed integers. +class Int8Reader extends Reader { + const Int8Reader() : super(); + + @override + @pragma('vm:prefer-inline') + int get size => _sizeofInt8; + + @override + @pragma('vm:prefer-inline') + int read(BufferContext bc, int offset) => bc._getInt8(offset); +} + +/// The reader of lists of objects. Lazy by default - see [lazy]. +class ListReader extends Reader> { + final Reader _elementReader; + + /// Enables lazy reading of the list + /// + /// If true, the returned unmodifiable list lazily reads objects on access. + /// Therefore, the underlying buffer must not change while accessing the list. + /// + /// If false, reads the whole list immediately on access. + final bool lazy; + + const ListReader(this._elementReader, {this.lazy = true}); + + @override + @pragma('vm:prefer-inline') + int get size => _sizeofUint32; + + @override + List read(BufferContext bc, int offset) { + final listOffset = bc.derefObject(offset); + return lazy + ? _FbGenericList(_elementReader, bc, listOffset) + : List.generate( + bc.buffer.getUint32(listOffset, Endian.little), + (int index) => _elementReader.read( + bc, + listOffset + size + _elementReader.size * index, + ), + growable: true, + ); + } +} + +/// Object that can read a value at a [BufferContext]. +abstract class Reader { + const Reader(); + + /// The size of the value in bytes. + int get size; + + /// Read the value at the given [offset] in [bc]. + T read(BufferContext bc, int offset); + + /// Read the value of the given [field] in the given [object]. + @pragma('vm:prefer-inline') + T vTableGet(BufferContext object, int offset, int field, T defaultValue) { + final fieldOffset = _vTableFieldOffset(object, offset, field); + return fieldOffset == 0 ? defaultValue : read(object, offset + fieldOffset); + } + + /// Read the value of the given [field] in the given [object]. + @pragma('vm:prefer-inline') + T? vTableGetNullable(BufferContext object, int offset, int field) { + final fieldOffset = _vTableFieldOffset(object, offset, field); + return fieldOffset == 0 ? null : read(object, offset + fieldOffset); + } + + @pragma('vm:prefer-inline') + int _vTableFieldOffset(BufferContext object, int offset, int field) { + final vTableSOffset = object._getInt32(offset); + final vTableOffset = offset - vTableSOffset; + final vTableSize = object._getUint16(vTableOffset); + if (field >= vTableSize) return 0; + return object._getUint16(vTableOffset + field); + } +} + +/// The reader of string values. +class StringReader extends Reader { + final bool asciiOptimization; + + const StringReader({this.asciiOptimization = false}) : super(); + + @override + @pragma('vm:prefer-inline') + int get size => _sizeofUint32; + + @override + @pragma('vm:prefer-inline') + String read(BufferContext bc, int offset) { + final strOffset = bc.derefObject(offset); + final length = bc._getUint32(strOffset); + final bytes = bc._asUint8List(strOffset + _sizeofUint32, length); + if (asciiOptimization && _isLatin(bytes)) { + return String.fromCharCodes(bytes); + } + return utf8.decode(bytes); + } + + @pragma('vm:prefer-inline') + static bool _isLatin(Uint8List bytes) { + final length = bytes.length; + for (var i = 0; i < length; i++) { + if (bytes[i] > 127) { + return false; + } + } + return true; + } +} + +/// An abstract reader for structs. +abstract class StructReader extends Reader { + const StructReader(); + + /// Return the object at `offset`. + T createObject(BufferContext bc, int offset); + + @override + T read(BufferContext bc, int offset) { + return createObject(bc, offset); + } +} + +/// An abstract reader for tables. +abstract class TableReader extends Reader { + const TableReader(); + + @override + @pragma('vm:prefer-inline') + int get size => 4; + + /// Return the object at [offset]. + T createObject(BufferContext bc, int offset); + + @override + T read(BufferContext bc, int offset) { + final objectOffset = bc.derefObject(offset); + return createObject(bc, objectOffset); + } +} + +/// Reader of lists of unsigned 32-bit integer values. +/// +/// The returned unmodifiable lists lazily read values on access. +class Uint32ListReader extends Reader> { + const Uint32ListReader(); + + @override + @pragma('vm:prefer-inline') + int get size => _sizeofUint32; + + @override + @pragma('vm:prefer-inline') + List read(BufferContext bc, int offset) => + _FbUint32List(bc, bc.derefObject(offset)); +} + +/// The reader of unsigned 64-bit integers. +/// +/// WARNING: May have compatibility issues with JavaScript +class Uint64Reader extends Reader { + const Uint64Reader() : super(); + + @override + @pragma('vm:prefer-inline') + int get size => _sizeofUint64; + + @override + @pragma('vm:prefer-inline') + int read(BufferContext bc, int offset) => bc._getUint64(offset); +} + +/// The reader of unsigned 32-bit integers. +class Uint32Reader extends Reader { + const Uint32Reader() : super(); + + @override + @pragma('vm:prefer-inline') + int get size => _sizeofUint32; + + @override + @pragma('vm:prefer-inline') + int read(BufferContext bc, int offset) => bc._getUint32(offset); +} + +/// Reader of lists of unsigned 32-bit integer values. +/// +/// The returned unmodifiable lists lazily read values on access. +class Uint16ListReader extends Reader> { + const Uint16ListReader(); + + @override + @pragma('vm:prefer-inline') + int get size => _sizeofUint32; + + @override + @pragma('vm:prefer-inline') + List read(BufferContext bc, int offset) => + _FbUint16List(bc, bc.derefObject(offset)); +} + +/// The reader of unsigned 32-bit integers. +class Uint16Reader extends Reader { + const Uint16Reader() : super(); + + @override + @pragma('vm:prefer-inline') + int get size => _sizeofUint16; + + @override + @pragma('vm:prefer-inline') + int read(BufferContext bc, int offset) => bc._getUint16(offset); +} + +/// Reader of unmodifiable binary data (a list of unsigned 8-bit integers). +class Uint8ListReader extends Reader> { + /// Enables lazy reading of the list + /// + /// If true, the returned unmodifiable list lazily reads bytes on access. + /// Therefore, the underlying buffer must not change while accessing the list. + /// + /// If false, reads the whole list immediately as an Uint8List. + final bool lazy; + + const Uint8ListReader({this.lazy = true}); + + @override + @pragma('vm:prefer-inline') + int get size => _sizeofUint32; + + @override + @pragma('vm:prefer-inline') + List read(BufferContext bc, int offset) { + final listOffset = bc.derefObject(offset); + if (lazy) return _FbUint8List(bc, listOffset); + + final length = bc._getUint32(listOffset); + final result = Uint8List(length); + var pos = listOffset + _sizeofUint32; + for (var i = 0; i < length; i++, pos++) { + result[i] = bc._getUint8(pos); + } + return result; + } +} + +/// The reader of unsigned 8-bit integers. +class Uint8Reader extends Reader { + const Uint8Reader() : super(); + + @override + @pragma('vm:prefer-inline') + int get size => _sizeofUint8; + + @override + @pragma('vm:prefer-inline') + int read(BufferContext bc, int offset) => bc._getUint8(offset); +} + +/// Reader of unmodifiable binary data (a list of signed 8-bit integers). +class Int8ListReader extends Reader> { + /// Enables lazy reading of the list + /// + /// If true, the returned unmodifiable list lazily reads bytes on access. + /// Therefore, the underlying buffer must not change while accessing the list. + /// + /// If false, reads the whole list immediately as an Uint8List. + final bool lazy; + + const Int8ListReader({this.lazy = true}); + + @override + @pragma('vm:prefer-inline') + int get size => _sizeofUint32; + + @override + @pragma('vm:prefer-inline') + List read(BufferContext bc, int offset) { + final listOffset = bc.derefObject(offset); + if (lazy) return _FbUint8List(bc, listOffset); + + final length = bc._getUint32(listOffset); + final result = Int8List(length); + var pos = listOffset + _sizeofUint32; + for (var i = 0; i < length; i++, pos++) { + result[i] = bc._getInt8(pos); + } + return result; + } +} + +/// The list backed by 64-bit values - Uint64 length and Float64. +class _FbFloat64List extends _FbList { + _FbFloat64List(BufferContext bc, int offset) : super(bc, offset); + + @override + @pragma('vm:prefer-inline') + double operator [](int i) => bc._getFloat64(offset + 4 + 8 * i); +} + +/// The list backed by 32-bit values - Float32. +class _FbFloat32List extends _FbList { + _FbFloat32List(BufferContext bc, int offset) : super(bc, offset); + + @override + @pragma('vm:prefer-inline') + double operator [](int i) => bc._getFloat32(offset + 4 + 4 * i); +} + +/// List backed by a generic object which may have any size. +class _FbGenericList extends _FbList { + final Reader elementReader; + + List? _items; + + _FbGenericList(this.elementReader, BufferContext bp, int offset) + : super(bp, offset); + + @override + @pragma('vm:prefer-inline') + E operator [](int i) { + _items ??= List.filled(length, null); + var item = _items![i]; + if (item == null) { + item = elementReader.read(bc, offset + 4 + elementReader.size * i); + _items![i] = item; + } + return item!; + } +} + +/// The base class for immutable lists read from flat buffers. +abstract class _FbList extends Object with ListMixin implements List { + final BufferContext bc; + final int offset; + int? _length; + + _FbList(this.bc, this.offset); + + @override + @pragma('vm:prefer-inline') + int get length => _length ??= bc._getUint32(offset); + + @override + set length(int i) => throw StateError('Attempt to modify immutable list'); + + @override + void operator []=(int i, E e) => + throw StateError('Attempt to modify immutable list'); +} + +/// List backed by 32-bit unsigned integers. +class _FbUint32List extends _FbList { + _FbUint32List(BufferContext bc, int offset) : super(bc, offset); + + @override + @pragma('vm:prefer-inline') + int operator [](int i) => bc._getUint32(offset + 4 + 4 * i); +} + +/// List backed by 16-bit unsigned integers. +class _FbUint16List extends _FbList { + _FbUint16List(BufferContext bc, int offset) : super(bc, offset); + + @override + @pragma('vm:prefer-inline') + int operator [](int i) => bc._getUint16(offset + 4 + 2 * i); +} + +/// List backed by 8-bit unsigned integers. +class _FbUint8List extends _FbList { + _FbUint8List(BufferContext bc, int offset) : super(bc, offset); + + @override + @pragma('vm:prefer-inline') + int operator [](int i) => bc._getUint8(offset + 4 + i); +} + +/// List backed by 8-bit signed integers. +class _FbInt8List extends _FbList { + _FbInt8List(BufferContext bc, int offset) : super(bc, offset); + + @override + @pragma('vm:prefer-inline') + int operator [](int i) => bc._getInt8(offset + 4 + i); +} + +/// List backed by 8-bit unsigned integers. +class _FbBoolList extends _FbList { + _FbBoolList(BufferContext bc, int offset) : super(bc, offset); + + @override + @pragma('vm:prefer-inline') + bool operator [](int i) => bc._getUint8(offset + 4 + i) == 1 ? true : false; +} + +/// Class that describes the structure of a table. +class _VTable { + static const int _metadataLength = 4; + + final int numFields; + + // Note: fieldOffsets start as "tail offsets" and are then transformed by + // [computeFieldOffsets()] to actual offsets when a table is finished. + final Uint32List fieldOffsets; + bool offsetsComputed = false; + + _VTable(this.numFields) : fieldOffsets = Uint32List(numFields); + + /// The size of the table that uses this VTable. + int tableSize = 0; + + /// The tail of this VTable. It is used to share the same VTable between + /// multiple tables of identical structure. + int tail = 0; + + int get _vTableSize => numOfUint16 * _sizeofUint16; + + int get numOfUint16 => 1 + 1 + numFields; + + @pragma('vm:prefer-inline') + void addField(int field, int offset) { + assert(!offsetsComputed); + assert(offset > 0); // it's impossible for field to start at the buffer end + assert(offset <= 4294967295); // uint32 max + fieldOffsets[field] = offset; + } + + @pragma('vm:prefer-inline') + bool _offsetsMatch(int vt2Start, ByteData buf) { + assert(offsetsComputed); + for (var i = 0; i < numFields; i++) { + if (fieldOffsets[i] != + buf.getUint16(vt2Start + _metadataLength + (2 * i), Endian.little)) { + return false; + } + } + return true; + } + + /// Fill the [fieldOffsets] field. + @pragma('vm:prefer-inline') + void computeFieldOffsets(int tableTail) { + assert(!offsetsComputed); + offsetsComputed = true; + for (var i = 0; i < numFields; i++) { + if (fieldOffsets[i] != 0) { + fieldOffsets[i] = tableTail - fieldOffsets[i]; + } + } + } + + /// Outputs this VTable to [buf], which is is expected to be aligned to 16-bit + /// and have at least [numOfUint16] 16-bit words available. + @pragma('vm:prefer-inline') + void output(ByteData buf, int bufOffset) { + assert(offsetsComputed); + // VTable size. + buf.setUint16(bufOffset, numOfUint16 * 2, Endian.little); + bufOffset += 2; + // Table size. + buf.setUint16(bufOffset, tableSize, Endian.little); + bufOffset += 2; + // Field offsets. + for (var i = 0; i < numFields; i++) { + buf.setUint16(bufOffset, fieldOffsets[i], Endian.little); + bufOffset += 2; + } + } +} + +/// The interface that [Builder] uses to allocate buffers for encoding. +abstract class Allocator { + const Allocator(); + + /// Allocate a [ByteData] buffer of a given size. + ByteData allocate(int size); + + /// Free the given [ByteData] buffer previously allocated by [allocate]. + void deallocate(ByteData data); + + /// Reallocate [newSize] bytes of memory, replacing the old [oldData]. This + /// grows downwards, and is intended specifically for use with [Builder]. + /// Params [inUseBack] and [inUseFront] indicate how much of [oldData] is + /// actually in use at each end, and needs to be copied. + ByteData resize( + ByteData oldData, + int newSize, + int inUseBack, + int inUseFront, + ) { + final newData = allocate(newSize); + _copyDownward(oldData, newData, inUseBack, inUseFront); + deallocate(oldData); + return newData; + } + + /// Called by [resize] to copy memory from [oldData] to [newData]. Only + /// memory of size [inUseFront] and [inUseBack] will be copied from the front + /// and back of the old memory allocation. + void _copyDownward( + ByteData oldData, + ByteData newData, + int inUseBack, + int inUseFront, + ) { + if (inUseBack != 0) { + newData.buffer.asUint8List().setAll( + newData.lengthInBytes - inUseBack, + oldData.buffer.asUint8List().getRange( + oldData.lengthInBytes - inUseBack, + oldData.lengthInBytes, + ), + ); + } + if (inUseFront != 0) { + newData.buffer.asUint8List().setAll( + 0, + oldData.buffer.asUint8List().getRange(0, inUseFront), + ); + } + } +} + +class DefaultAllocator extends Allocator { + const DefaultAllocator(); + + @override + ByteData allocate(int size) => ByteData(size); + + @override + void deallocate(ByteData data) { + // nothing to do, it's garbage-collected + } +} + +// JavaScript-safe 64-bit integer accessors (see file header). +int _getInt64Js(ByteData buffer, int offset) { + final lo = buffer.getUint32(offset, Endian.little); + final hi = buffer.getInt32(offset + 4, Endian.little); + return hi * 4294967296 + lo; +} + +int _getUint64Js(ByteData buffer, int offset) { + final lo = buffer.getUint32(offset, Endian.little); + final hi = buffer.getUint32(offset + 4, Endian.little); + return hi * 4294967296 + lo; +} + +void _setInt64Js(ByteData buffer, int offset, int value) { + final hi = (value / 4294967296).floor(); + final lo = value - hi * 4294967296; + buffer.setUint32(offset, lo, Endian.little); + buffer.setUint32(offset + 4, hi & 0xFFFFFFFF, Endian.little); +} diff --git a/objectbox/lib/src/web/flatbuffers/flex_buffers.dart b/objectbox/lib/src/web/flatbuffers/flex_buffers.dart new file mode 100644 index 000000000..7481c12f6 --- /dev/null +++ b/objectbox/lib/src/web/flatbuffers/flex_buffers.dart @@ -0,0 +1,10 @@ +// Vendored from package:flat_buffers 25.9.23 (Apache-2.0, Copyright Google +// Inc.) for the ObjectBox web implementation, with one change: all 64-bit +// integer ByteData accessors are replaced with JavaScript-safe versions built +// from two 32-bit halves, because dart2js does not support +// ByteData.get/setInt64/Uint64. Values keep full precision up to 2^53 (all +// JavaScript numbers are doubles). Used via the conditional export in +// lib/flatbuffers.dart; native platforms use the real package. +// ignore_for_file: type=lint +export 'src/builder.dart'; +export 'src/reference.dart'; diff --git a/objectbox/lib/src/web/flatbuffers/src/builder.dart b/objectbox/lib/src/web/flatbuffers/src/builder.dart new file mode 100644 index 000000000..157242b45 --- /dev/null +++ b/objectbox/lib/src/web/flatbuffers/src/builder.dart @@ -0,0 +1,724 @@ +// Vendored from package:flat_buffers 25.9.23 (Apache-2.0, Copyright Google +// Inc.) for the ObjectBox web implementation, with one change: all 64-bit +// integer ByteData accessors are replaced with JavaScript-safe versions built +// from two 32-bit halves, because dart2js does not support +// ByteData.get/setInt64/Uint64. Values keep full precision up to 2^53 (all +// JavaScript numbers are doubles). Used via the conditional export in +// lib/flatbuffers.dart; native platforms use the real package. +// ignore_for_file: type=lint, unused_element, unnecessary_cast +import 'dart:convert'; +import 'dart:typed_data'; + +import 'types.dart'; + +/// The main builder class for creation of a FlexBuffer. +class Builder { + final ByteData _buffer; + List<_StackValue> _stack = []; + List<_StackPointer> _stackPointers = []; + int _offset = 0; + bool _finished = false; + final Map _stringCache = {}; + final Map _keyCache = {}; + final Map<_KeysHash, _StackValue> _keyVectorCache = {}; + final Map _indirectIntCache = {}; + final Map _indirectDoubleCache = {}; + + /// Instantiate the builder if you intent to gradually build up the buffer by calling + /// add... methods and calling [finish] to receive the resulting byte array. + /// + /// The default size of internal buffer is set to 2048. Provide a different value in order to avoid buffer copies. + Builder({int size = 2048}) : _buffer = ByteData(size); + + /// Use this method in order to turn an object into a FlexBuffer directly. + /// + /// Use the manual instantiation of the [Builder] and gradual addition of values, if performance is more important than convenience. + static ByteBuffer buildFromObject(Object? value) { + final builder = Builder(); + builder._add(value); + final buffer = builder.finish(); + final byteData = ByteData(buffer.lengthInBytes); + byteData.buffer.asUint8List().setAll(0, buffer); + return byteData.buffer; + } + + void _add(Object? value) { + if (value == null) { + addNull(); + } else if (value is bool) { + addBool(value); + } else if (value is int) { + addInt(value); + } else if (value is double) { + addDouble(value); + } else if (value is ByteBuffer) { + addBlob(value); + } else if (value is String) { + addString(value); + } else if (value is List) { + startVector(); + for (var i = 0; i < value.length; i++) { + _add(value[i]); + } + end(); + } else if (value is Map) { + startMap(); + value.forEach((key, value) { + addKey(key); + _add(value); + }); + end(); + } else { + throw UnsupportedError('Value of unexpected type: $value'); + } + } + + /// Use this method if you want to store a null value. + /// + /// Specifically useful when building up a vector where values can be null. + void addNull() { + _integrityCheckOnValueAddition(); + _stack.add(_StackValue.withNull()); + } + + /// Adds a string value. + void addInt(int value) { + _integrityCheckOnValueAddition(); + _stack.add(_StackValue.withInt(value)); + } + + /// Adds a bool value. + void addBool(bool value) { + _integrityCheckOnValueAddition(); + _stack.add(_StackValue.withBool(value)); + } + + /// Adds a double value. + void addDouble(double value) { + _integrityCheckOnValueAddition(); + _stack.add(_StackValue.withDouble(value)); + } + + /// Adds a string value. + void addString(String value) { + _integrityCheckOnValueAddition(); + if (_stringCache.containsKey(value)) { + _stack.add(_stringCache[value]!); + return; + } + final utf8String = utf8.encode(value); + final length = utf8String.length; + final bitWidth = BitWidthUtil.uwidth(length); + final byteWidth = _align(bitWidth); + _writeUInt(length, byteWidth); + final stringOffset = _offset; + final newOffset = _newOffset(length + 1); + _pushBuffer(utf8String); + _offset = newOffset; + final stackValue = _StackValue.withOffset( + stringOffset, + ValueType.String, + bitWidth, + ); + _stack.add(stackValue); + _stringCache[value] = stackValue; + } + + /// This methods adds a key to a map and should be followed by an add... value call. + /// + /// It also implies that you call this method only after you called [startMap]. + void addKey(String value) { + _integrityCheckOnKeyAddition(); + if (_keyCache.containsKey(value)) { + _stack.add(_keyCache[value]!); + return; + } + final utf8String = utf8.encode(value); + final length = utf8String.length; + final keyOffset = _offset; + final newOffset = _newOffset(length + 1); + _pushBuffer(utf8String); + _offset = newOffset; + final stackValue = _StackValue.withOffset( + keyOffset, + ValueType.Key, + BitWidth.width8, + ); + _stack.add(stackValue); + _keyCache[value] = stackValue; + } + + /// Adds a byte array. + /// + /// This method can be used to store any generic BLOB. + void addBlob(ByteBuffer value) { + _integrityCheckOnValueAddition(); + final length = value.lengthInBytes; + final bitWidth = BitWidthUtil.uwidth(length); + final byteWidth = _align(bitWidth); + _writeUInt(length, byteWidth); + final blobOffset = _offset; + final newOffset = _newOffset(length); + _pushBuffer(value.asUint8List()); + _offset = newOffset; + final stackValue = _StackValue.withOffset( + blobOffset, + ValueType.Blob, + bitWidth, + ); + _stack.add(stackValue); + } + + /// Stores int value indirectly in the buffer. + /// + /// Adding large integer values indirectly might be beneficial if those values suppose to be store in a vector together with small integer values. + /// This is due to the fact that FlexBuffers will add padding to small integer values, if they are stored together with large integer values. + /// When we add integer indirectly the vector of ints will contain not the value itself, but only the relative offset to the value. + /// By setting the [cache] parameter to true, you make sure that the builder tracks added int value and performs deduplication. + void addIntIndirectly(int value, {bool cache = false}) { + _integrityCheckOnValueAddition(); + if (_indirectIntCache.containsKey(value)) { + _stack.add(_indirectIntCache[value]!); + return; + } + final stackValue = _StackValue.withInt(value); + final byteWidth = _align(stackValue.width); + final newOffset = _newOffset(byteWidth); + final valueOffset = _offset; + _pushBuffer(stackValue.asU8List(stackValue.width)); + final stackOffset = _StackValue.withOffset( + valueOffset, + ValueType.IndirectInt, + stackValue.width, + ); + _stack.add(stackOffset); + _offset = newOffset; + if (cache) { + _indirectIntCache[value] = stackOffset; + } + } + + /// Stores double value indirectly in the buffer. + /// + /// Double are stored as 8 or 4 byte values in FlexBuffers. If they are stored in a mixed vector, values which are smaller than 4 / 8 bytes will be padded. + /// When we add double indirectly, the vector will contain not the value itself, but only the relative offset to the value. Which could occupy only 1 or 2 bytes, reducing the odds for unnecessary padding. + /// By setting the [cache] parameter to true, you make sure that the builder tracks already added double value and performs deduplication. + void addDoubleIndirectly(double value, {bool cache = false}) { + _integrityCheckOnValueAddition(); + if (cache && _indirectDoubleCache.containsKey(value)) { + _stack.add(_indirectDoubleCache[value]!); + return; + } + final stackValue = _StackValue.withDouble(value); + final byteWidth = _align(stackValue.width); + final newOffset = _newOffset(byteWidth); + final valueOffset = _offset; + _pushBuffer(stackValue.asU8List(stackValue.width)); + final stackOffset = _StackValue.withOffset( + valueOffset, + ValueType.IndirectFloat, + stackValue.width, + ); + _stack.add(stackOffset); + _offset = newOffset; + if (cache) { + _indirectDoubleCache[value] = stackOffset; + } + } + + /// This method starts a vector definition and needs to be followed by 0 to n add... value calls. + /// + /// The vector definition needs to be finished with an [end] call. + /// It is also possible to add nested vector or map by calling [startVector] / [startMap]. + void startVector() { + _integrityCheckOnValueAddition(); + _stackPointers.add(_StackPointer(_stack.length, true)); + } + + /// This method starts a map definition. + /// + /// This method call needs to be followed by 0 to n [addKey] + add... value calls. + /// The map definition needs to be finished with an [end] call. + /// It is also possible to add nested vector or map by calling [startVector] / [startMap] after calling [addKey]. + void startMap() { + _integrityCheckOnValueAddition(); + _stackPointers.add(_StackPointer(_stack.length, false)); + } + + /// Marks that the addition of values to the last vector, or map have ended. + void end() { + final pointer = _stackPointers.removeLast(); + if (pointer.isVector) { + _endVector(pointer); + } else { + _sortKeysAndEndMap(pointer); + } + } + + /// Finish building the FlatBuffer and return array of bytes. + /// + /// Can be called multiple times, to get the array of bytes. + /// After the first call, adding values, or starting vectors / maps will result in an exception. + Uint8List finish() { + if (_finished == false) { + _finish(); + } + return _buffer.buffer.asUint8List(0, _offset); + } + + /// Builds a FlatBuffer with current state without finishing the builder. + /// + /// Creates an internal temporary copy of current builder and finishes the copy. + /// Use this method, when the state of a long lasting builder need to be persisted periodically. + ByteBuffer snapshot() { + final tmp = Builder(size: _offset + 200); + tmp._offset = _offset; + tmp._stack = List.from(_stack); + tmp._stackPointers = List.from(_stackPointers); + tmp._buffer.buffer.asUint8List().setAll( + 0, + _buffer.buffer.asUint8List(0, _offset), + ); + for (var i = 0; i < tmp._stackPointers.length; i++) { + tmp.end(); + } + final buffer = tmp.finish(); + final bd = ByteData(buffer.lengthInBytes); + bd.buffer.asUint8List().setAll(0, buffer); + return bd.buffer; + } + + void _integrityCheckOnValueAddition() { + if (_finished) { + throw StateError('Adding values after finish is prohibited'); + } + if (_stackPointers.isNotEmpty && _stackPointers.last.isVector == false) { + if (_stack.last.type != ValueType.Key) { + throw StateError( + 'Adding value to a map before adding a key is prohibited', + ); + } + } + } + + void _integrityCheckOnKeyAddition() { + if (_finished) { + throw StateError('Adding values after finish is prohibited'); + } + if (_stackPointers.isEmpty || _stackPointers.last.isVector) { + throw StateError('Adding key before staring a map is prohibited'); + } + } + + void _finish() { + if (_stack.length != 1) { + throw StateError( + 'Stack has to be exactly 1, but is ${_stack.length}. You have to end all started vectors and maps, before calling [finish]', + ); + } + final value = _stack[0]; + final byteWidth = _align(value.elementWidth(_offset, 0)); + _writeStackValue(value, byteWidth); + _writeUInt(value.storedPackedType(), 1); + _writeUInt(byteWidth, 1); + _finished = true; + } + + _StackValue _createVector( + int start, + int vecLength, + int step, [ + _StackValue? keys, + ]) { + var bitWidth = BitWidthUtil.uwidth(vecLength); + var prefixElements = 1; + if (keys != null) { + var elemWidth = keys.elementWidth(_offset, 0); + if (elemWidth.index > bitWidth.index) { + bitWidth = elemWidth; + } + prefixElements += 2; + } + var vectorType = ValueType.Key; + var typed = keys == null; + for (var i = start; i < _stack.length; i += step) { + final elemWidth = _stack[i].elementWidth(_offset, i + prefixElements); + if (elemWidth.index > bitWidth.index) { + bitWidth = elemWidth; + } + if (i == start) { + vectorType = _stack[i].type; + typed &= ValueTypeUtils.isTypedVectorElement(vectorType); + } else { + if (vectorType != _stack[i].type) { + typed = false; + } + } + } + final byteWidth = _align(bitWidth); + final fix = typed & ValueTypeUtils.isNumber(vectorType) && + vecLength >= 2 && + vecLength <= 4; + if (keys != null) { + _writeStackValue(keys, byteWidth); + _writeUInt(1 << keys.width.index, byteWidth); + } + if (fix == false) { + _writeUInt(vecLength, byteWidth); + } + final vecOffset = _offset; + for (var i = start; i < _stack.length; i += step) { + _writeStackValue(_stack[i], byteWidth); + } + if (typed == false) { + for (var i = start; i < _stack.length; i += step) { + _writeUInt(_stack[i].storedPackedType(), 1); + } + } + if (keys != null) { + return _StackValue.withOffset(vecOffset, ValueType.Map, bitWidth); + } + if (typed) { + final vType = ValueTypeUtils.toTypedVector( + vectorType, + fix ? vecLength : 0, + ); + return _StackValue.withOffset(vecOffset, vType, bitWidth); + } + return _StackValue.withOffset(vecOffset, ValueType.Vector, bitWidth); + } + + void _endVector(_StackPointer pointer) { + final vecLength = _stack.length - pointer.stackPosition; + final vec = _createVector(pointer.stackPosition, vecLength, 1); + _stack.removeRange(pointer.stackPosition, _stack.length); + _stack.add(vec); + } + + void _sortKeysAndEndMap(_StackPointer pointer) { + if (((_stack.length - pointer.stackPosition) & 1) == 1) { + throw StateError( + 'The stack needs to hold key value pairs (even number of elements). Check if you combined [addKey] with add... method calls properly.', + ); + } + + var sorted = true; + for (var i = pointer.stackPosition; i < _stack.length - 2; i += 2) { + if (_shouldFlip(_stack[i], _stack[i + 2])) { + sorted = false; + break; + } + } + + if (sorted == false) { + for (var i = pointer.stackPosition; i < _stack.length; i += 2) { + var flipIndex = i; + for (var j = i + 2; j < _stack.length; j += 2) { + if (_shouldFlip(_stack[flipIndex], _stack[j])) { + flipIndex = j; + } + } + if (flipIndex != i) { + var k = _stack[flipIndex]; + var v = _stack[flipIndex + 1]; + _stack[flipIndex] = _stack[i]; + _stack[flipIndex + 1] = _stack[i + 1]; + _stack[i] = k; + _stack[i + 1] = v; + } + } + } + _endMap(pointer); + } + + void _endMap(_StackPointer pointer) { + final vecLength = (_stack.length - pointer.stackPosition) >> 1; + final offsets = []; + for (var i = pointer.stackPosition; i < _stack.length; i += 2) { + offsets.add(_stack[i].offset!); + } + final keysHash = _KeysHash(offsets); + _StackValue? keysStackValue; + if (_keyVectorCache.containsKey(keysHash)) { + keysStackValue = _keyVectorCache[keysHash]; + } else { + keysStackValue = _createVector(pointer.stackPosition, vecLength, 2); + _keyVectorCache[keysHash] = keysStackValue; + } + final vec = _createVector( + pointer.stackPosition + 1, + vecLength, + 2, + keysStackValue, + ); + _stack.removeRange(pointer.stackPosition, _stack.length); + _stack.add(vec); + } + + bool _shouldFlip(_StackValue v1, _StackValue v2) { + if (v1.type != ValueType.Key || v2.type != ValueType.Key) { + throw StateError( + 'Stack values are not keys $v1 | $v2. Check if you combined [addKey] with add... method calls properly.', + ); + } + + late int c1, c2; + var index = 0; + do { + c1 = _buffer.getUint8(v1.offset! + index); + c2 = _buffer.getUint8(v2.offset! + index); + if (c2 < c1) return true; + if (c1 < c2) return false; + index += 1; + } while (c1 != 0 && c2 != 0); + return false; + } + + int _align(BitWidth width) { + final byteWidth = BitWidthUtil.toByteWidth(width); + _offset += BitWidthUtil.paddingSize(_offset, byteWidth); + return byteWidth; + } + + void _writeStackValue(_StackValue value, int byteWidth) { + final newOffset = _newOffset(byteWidth); + if (value.isOffset) { + final relativeOffset = _offset - value.offset!; + if (byteWidth == 8 || relativeOffset < (1 << (byteWidth * 8))) { + _writeUInt(relativeOffset, byteWidth); + } else { + throw StateError( + 'Unexpected size $byteWidth. This might be a bug. Please create an issue https://github.com/google/flatbuffers/issues/new', + ); + } + } else { + _pushBuffer(value.asU8List(BitWidthUtil.fromByteWidth(byteWidth))); + } + _offset = newOffset; + } + + void _writeUInt(int value, int byteWidth) { + final newOffset = _newOffset(byteWidth); + _pushUInt(value, BitWidthUtil.fromByteWidth(byteWidth)); + _offset = newOffset; + } + + int _newOffset(int newValueSize) { + final newOffset = _offset + newValueSize; + var size = _buffer.lengthInBytes; + final prevSize = size; + while (size < newOffset) { + size <<= 1; + } + if (prevSize < size) { + final newBuf = ByteData(size); + newBuf.buffer.asUint8List().setAll(0, _buffer.buffer.asUint8List()); + } + return newOffset; + } + + void _pushInt(int value, BitWidth width) { + switch (width) { + case BitWidth.width8: + _buffer.setInt8(_offset, value); + break; + case BitWidth.width16: + _buffer.setInt16(_offset, value, Endian.little); + break; + case BitWidth.width32: + _buffer.setInt32(_offset, value, Endian.little); + break; + case BitWidth.width64: + _setInt64Js(_buffer, _offset, value); + break; + } + } + + void _pushUInt(int value, BitWidth width) { + switch (width) { + case BitWidth.width8: + _buffer.setUint8(_offset, value); + break; + case BitWidth.width16: + _buffer.setUint16(_offset, value, Endian.little); + break; + case BitWidth.width32: + _buffer.setUint32(_offset, value, Endian.little); + break; + case BitWidth.width64: + _setInt64Js(_buffer, _offset, value); + break; + } + } + + void _pushBuffer(List value) { + _buffer.buffer.asUint8List().setAll(_offset, value); + } +} + +class _StackValue { + late Object _value; + int? _offset; + final ValueType _type; + final BitWidth _width; + + _StackValue.withNull() + : _type = ValueType.Null, + _width = BitWidth.width8; + + _StackValue.withInt(int value) + : _type = ValueType.Int, + _width = BitWidthUtil.width(value), + _value = value; + + _StackValue.withBool(bool value) + : _type = ValueType.Bool, + _width = BitWidth.width8, + _value = value; + + _StackValue.withDouble(double value) + : _type = ValueType.Float, + _width = BitWidthUtil.width(value), + _value = value; + + _StackValue.withOffset(int value, ValueType type, BitWidth width) + : _offset = value, + _type = type, + _width = width; + + BitWidth storedWidth({BitWidth width = BitWidth.width8}) { + return ValueTypeUtils.isInline(_type) + ? BitWidthUtil.max(_width, width) + : _width; + } + + int storedPackedType({BitWidth width = BitWidth.width8}) { + return ValueTypeUtils.packedType(_type, storedWidth(width: width)); + } + + BitWidth elementWidth(int size, int index) { + if (ValueTypeUtils.isInline(_type)) return _width; + final offset = _offset!; + for (var i = 0; i < 4; i++) { + final width = 1 << i; + final bitWidth = BitWidthUtil.uwidth( + size + BitWidthUtil.paddingSize(size, width) + index * width - offset, + ); + if (1 << bitWidth.index == width) { + return bitWidth; + } + } + throw StateError( + 'Element is of unknown. Size: $size at index: $index. This might be a bug. Please create an issue https://github.com/google/flatbuffers/issues/new', + ); + } + + List asU8List(BitWidth width) { + if (ValueTypeUtils.isNumber(_type)) { + if (_type == ValueType.Float) { + if (width == BitWidth.width32) { + final result = ByteData(4); + result.setFloat32(0, _value as double, Endian.little); + return result.buffer.asUint8List(); + } else { + final result = ByteData(8); + result.setFloat64(0, _value as double, Endian.little); + return result.buffer.asUint8List(); + } + } else { + switch (width) { + case BitWidth.width8: + final result = ByteData(1); + result.setInt8(0, _value as int); + return result.buffer.asUint8List(); + case BitWidth.width16: + final result = ByteData(2); + result.setInt16(0, _value as int, Endian.little); + return result.buffer.asUint8List(); + case BitWidth.width32: + final result = ByteData(4); + result.setInt32(0, _value as int, Endian.little); + return result.buffer.asUint8List(); + case BitWidth.width64: + final result = ByteData(8); + _setInt64Js(result, 0, _value as int); + return result.buffer.asUint8List(); + } + } + } + if (_type == ValueType.Null) { + final result = ByteData(1); + result.setInt8(0, 0); + return result.buffer.asUint8List(); + } + if (_type == ValueType.Bool) { + final result = ByteData(1); + result.setInt8(0, _value as bool ? 1 : 0); + return result.buffer.asUint8List(); + } + + throw StateError( + 'Unexpected type: $_type. This might be a bug. Please create an issue https://github.com/google/flatbuffers/issues/new', + ); + } + + ValueType get type { + return _type; + } + + BitWidth get width { + return _width; + } + + bool get isOffset { + return !ValueTypeUtils.isInline(_type); + } + + int? get offset => _offset; + + bool get isFloat32 { + return _type == ValueType.Float && _width == BitWidth.width32; + } +} + +class _StackPointer { + int stackPosition; + bool isVector; + + _StackPointer(this.stackPosition, this.isVector); +} + +class _KeysHash { + final List keys; + + const _KeysHash(this.keys); + + @override + bool operator ==(Object other) { + if (other is _KeysHash) { + if (keys.length != other.keys.length) return false; + for (var i = 0; i < keys.length; i++) { + if (keys[i] != other.keys[i]) return false; + } + return true; + } + return false; + } + + @override + int get hashCode { + var result = 17; + for (var i = 0; i < keys.length; i++) { + result = result * 23 + keys[i]; + } + return result; + } +} + +// JavaScript-safe 64-bit integer accessors (see file header). +void _setInt64Js(ByteData buffer, int offset, int value) { + final hi = (value / 4294967296).floor(); + final lo = value - hi * 4294967296; + buffer.setUint32(offset, lo, Endian.little); + buffer.setUint32(offset + 4, hi & 0xFFFFFFFF, Endian.little); +} diff --git a/objectbox/lib/src/web/flatbuffers/src/reference.dart b/objectbox/lib/src/web/flatbuffers/src/reference.dart new file mode 100644 index 000000000..09c32bf86 --- /dev/null +++ b/objectbox/lib/src/web/flatbuffers/src/reference.dart @@ -0,0 +1,537 @@ +// Vendored from package:flat_buffers 25.9.23 (Apache-2.0, Copyright Google +// Inc.) for the ObjectBox web implementation, with one change: all 64-bit +// integer ByteData accessors are replaced with JavaScript-safe versions built +// from two 32-bit halves, because dart2js does not support +// ByteData.get/setInt64/Uint64. Values keep full precision up to 2^53 (all +// JavaScript numbers are doubles). Used via the conditional export in +// lib/flatbuffers.dart; native platforms use the real package. +// ignore_for_file: type=lint +import 'dart:collection'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'types.dart'; + +/// Main class to read a value out of a FlexBuffer. +/// +/// This class let you access values stored in the buffer in a lazy fashion. +class Reference { + final ByteData _buffer; + final int _offset; + final BitWidth _parentWidth; + final String _path; + final int _byteWidth; + final ValueType _valueType; + int? _length; + + Reference._( + this._buffer, + this._offset, + this._parentWidth, + int packedType, + this._path, [ + int? byteWidth, + ValueType? valueType, + ]) : _byteWidth = byteWidth ?? 1 << (packedType & 3), + _valueType = valueType ?? ValueTypeUtils.fromInt(packedType >> 2); + + /// Use this method to access the root value of a FlexBuffer. + static Reference fromBuffer(ByteBuffer buffer) { + final len = buffer.lengthInBytes; + if (len < 3) { + throw UnsupportedError('Buffer needs to be bigger than 3'); + } + final byteData = ByteData.view(buffer); + final byteWidth = byteData.getUint8(len - 1); + final packedType = byteData.getUint8(len - 2); + final offset = len - byteWidth - 2; + return Reference._( + ByteData.view(buffer), + offset, + BitWidthUtil.fromByteWidth(byteWidth), + packedType, + "/", + ); + } + + /// Returns true if the underlying value is null. + bool get isNull => _valueType == ValueType.Null; + + /// Returns true if the underlying value can be represented as [num]. + bool get isNum => + ValueTypeUtils.isNumber(_valueType) || + ValueTypeUtils.isIndirectNumber(_valueType); + + /// Returns true if the underlying value was encoded as a float (direct or indirect). + bool get isDouble => + _valueType == ValueType.Float || _valueType == ValueType.IndirectFloat; + + /// Returns true if the underlying value was encoded as an int or uint (direct or indirect). + bool get isInt => isNum && !isDouble; + + /// Returns true if the underlying value was encoded as a string or a key. + bool get isString => + _valueType == ValueType.String || _valueType == ValueType.Key; + + /// Returns true if the underlying value was encoded as a bool. + bool get isBool => _valueType == ValueType.Bool; + + /// Returns true if the underlying value was encoded as a blob. + bool get isBlob => _valueType == ValueType.Blob; + + /// Returns true if the underlying value points to a vector. + bool get isVector => ValueTypeUtils.isAVector(_valueType); + + /// Returns true if the underlying value points to a map. + bool get isMap => _valueType == ValueType.Map; + + /// If this [isBool], returns the bool value. Otherwise, returns null. + bool? get boolValue { + if (_valueType == ValueType.Bool) { + return _readInt(_offset, _parentWidth) != 0; + } + return null; + } + + /// Returns an [int], if the underlying value can be represented as an int. + /// + /// Otherwise returns [null]. + int? get intValue { + if (_valueType == ValueType.Int) { + return _readInt(_offset, _parentWidth); + } + if (_valueType == ValueType.UInt) { + return _readUInt(_offset, _parentWidth); + } + if (_valueType == ValueType.IndirectInt) { + return _readInt(_indirect, BitWidthUtil.fromByteWidth(_byteWidth)); + } + if (_valueType == ValueType.IndirectUInt) { + return _readUInt(_indirect, BitWidthUtil.fromByteWidth(_byteWidth)); + } + return null; + } + + /// Returns [double], if the underlying value [isDouble]. + /// + /// Otherwise returns [null]. + double? get doubleValue { + if (_valueType == ValueType.Float) { + return _readFloat(_offset, _parentWidth); + } + if (_valueType == ValueType.IndirectFloat) { + return _readFloat(_indirect, BitWidthUtil.fromByteWidth(_byteWidth)); + } + return null; + } + + /// Returns [num], if the underlying value is numeric, be it int uint, or float (direct or indirect). + /// + /// Otherwise returns [null]. + num? get numValue => doubleValue ?? intValue; + + /// Returns [String] value or null otherwise. + /// + /// This method performers a utf8 decoding, as FlexBuffers format stores strings in utf8 encoding. + String? get stringValue { + if (_valueType == ValueType.String || _valueType == ValueType.Key) { + return utf8.decode(_buffer.buffer.asUint8List(_indirect, length)); + } + return null; + } + + /// Returns [Uint8List] value or null otherwise. + Uint8List? get blobValue { + if (_valueType == ValueType.Blob) { + return _buffer.buffer.asUint8List(_indirect, length); + } + return null; + } + + /// Can be used with an [int] or a [String] value for key. + /// If the underlying value in FlexBuffer is a vector, then use [int] for access. + /// If the underlying value in FlexBuffer is a map, then use [String] for access. + /// Returns [Reference] value. Throws an exception when [key] is not applicable. + Reference operator [](Object key) { + if (key is int && ValueTypeUtils.isAVector(_valueType)) { + final index = key; + if (index >= length || index < 0) { + throw ArgumentError( + 'Key: [$key] is not applicable on: $_path of: $_valueType length: $length', + ); + } + final elementOffset = _indirect + index * _byteWidth; + int packedType = 0; + int? byteWidth; + ValueType? valueType; + if (ValueTypeUtils.isTypedVector(_valueType)) { + byteWidth = 1; + valueType = ValueTypeUtils.typedVectorElementType(_valueType); + } else if (ValueTypeUtils.isFixedTypedVector(_valueType)) { + byteWidth = 1; + valueType = ValueTypeUtils.fixedTypedVectorElementType(_valueType); + } else { + packedType = _buffer.getUint8(_indirect + length * _byteWidth + index); + } + return Reference._( + _buffer, + elementOffset, + BitWidthUtil.fromByteWidth(_byteWidth), + packedType, + "$_path[$index]", + byteWidth, + valueType, + ); + } + if (key is String && _valueType == ValueType.Map) { + final index = _keyIndex(key); + if (index != null) { + return _valueForIndexWithKey(index, key); + } + } + throw ArgumentError( + 'Key: [$key] is not applicable on: $_path of: $_valueType', + ); + } + + /// Get an iterable if the underlying flexBuffer value is a vector. + /// Otherwise throws an exception. + Iterable get vectorIterable { + if (isVector == false) { + throw UnsupportedError('Value is not a vector. It is: $_valueType'); + } + return _VectorIterator(this); + } + + /// Get an iterable for keys if the underlying flexBuffer value is a map. + /// Otherwise throws an exception. + Iterable get mapKeyIterable { + if (isMap == false) { + throw UnsupportedError('Value is not a map. It is: $_valueType'); + } + return _MapKeyIterator(this); + } + + /// Get an iterable for values if the underlying flexBuffer value is a map. + /// Otherwise throws an exception. + Iterable get mapValueIterable { + if (isMap == false) { + throw UnsupportedError('Value is not a map. It is: $_valueType'); + } + return _MapValueIterator(this); + } + + /// Returns the length of the underlying FlexBuffer value. + /// If the underlying value is [null] the length is 0. + /// If the underlying value is a number, or a bool, the length is 1. + /// If the underlying value is a vector, or map, the length reflects number of elements / element pairs. + /// If the values is a string or a blob, the length reflects a number of bytes the value occupies (strings are encoded in utf8 format). + int get length { + if (_length == null) { + // needs to be checked before more generic isAVector + if (ValueTypeUtils.isFixedTypedVector(_valueType)) { + _length = ValueTypeUtils.fixedTypedVectorElementSize(_valueType); + } else if (_valueType == ValueType.Blob || + ValueTypeUtils.isAVector(_valueType) || + _valueType == ValueType.Map) { + _length = _readUInt( + _indirect - _byteWidth, + BitWidthUtil.fromByteWidth(_byteWidth), + ); + } else if (_valueType == ValueType.Null) { + _length = 0; + } else if (_valueType == ValueType.String) { + final indirect = _indirect; + var sizeByteWidth = _byteWidth; + var size = _readUInt( + indirect - sizeByteWidth, + BitWidthUtil.fromByteWidth(sizeByteWidth), + ); + while (_buffer.getInt8(indirect + size) != 0) { + sizeByteWidth <<= 1; + size = _readUInt( + indirect - sizeByteWidth, + BitWidthUtil.fromByteWidth(sizeByteWidth), + ); + } + _length = size; + } else if (_valueType == ValueType.Key) { + final indirect = _indirect; + var size = 1; + while (_buffer.getInt8(indirect + size) != 0) { + size += 1; + } + _length = size; + } else { + _length = 1; + } + } + return _length!; + } + + /// Returns a minified JSON representation of the underlying FlexBuffer value. + /// + /// This method involves materializing the entire object tree, which may be + /// expensive. It is more efficient to work with [Reference] and access only the needed data. + /// Blob values are represented as base64 encoded string. + String get json { + if (_valueType == ValueType.Bool) { + return boolValue! ? 'true' : 'false'; + } + if (_valueType == ValueType.Null) { + return 'null'; + } + if (ValueTypeUtils.isNumber(_valueType)) { + return jsonEncode(numValue); + } + if (_valueType == ValueType.String) { + return jsonEncode(stringValue); + } + if (_valueType == ValueType.Blob) { + return jsonEncode(base64Encode(blobValue!)); + } + if (ValueTypeUtils.isAVector(_valueType)) { + final result = StringBuffer(); + result.write('['); + for (var i = 0; i < length; i++) { + result.write(this[i].json); + if (i < length - 1) { + result.write(','); + } + } + result.write(']'); + return result.toString(); + } + if (_valueType == ValueType.Map) { + final result = StringBuffer(); + result.write('{'); + for (var i = 0; i < length; i++) { + result.write(jsonEncode(_keyForIndex(i))); + result.write(':'); + result.write(_valueForIndex(i).json); + if (i < length - 1) { + result.write(','); + } + } + result.write('}'); + return result.toString(); + } + throw UnsupportedError( + 'Type: $_valueType is not supported for JSON conversion', + ); + } + + /// Computes the indirect offset of the value. + /// + /// To optimize for the more common case of being called only once, this + /// value is not cached. Callers that need to use it more than once should + /// cache the return value in a local variable. + int get _indirect { + final step = _readUInt(_offset, _parentWidth); + return _offset - step; + } + + int _readInt(int offset, BitWidth width) { + _validateOffset(offset, width); + if (width == BitWidth.width8) { + return _buffer.getInt8(offset); + } + if (width == BitWidth.width16) { + return _buffer.getInt16(offset, Endian.little); + } + if (width == BitWidth.width32) { + return _buffer.getInt32(offset, Endian.little); + } + return _getInt64Js(_buffer, offset); + } + + int _readUInt(int offset, BitWidth width) { + _validateOffset(offset, width); + if (width == BitWidth.width8) { + return _buffer.getUint8(offset); + } + if (width == BitWidth.width16) { + return _buffer.getUint16(offset, Endian.little); + } + if (width == BitWidth.width32) { + return _buffer.getUint32(offset, Endian.little); + } + return _getUint64Js(_buffer, offset); + } + + double _readFloat(int offset, BitWidth width) { + _validateOffset(offset, width); + if (width.index < BitWidth.width32.index) { + throw StateError('Bad width: $width'); + } + + if (width == BitWidth.width32) { + return _buffer.getFloat32(offset, Endian.little); + } + + return _buffer.getFloat64(offset, Endian.little); + } + + void _validateOffset(int offset, BitWidth width) { + if (_offset < 0 || + _buffer.lengthInBytes <= offset + width.index || + offset & (BitWidthUtil.toByteWidth(width) - 1) != 0) { + throw StateError('Bad offset: $offset, width: $width'); + } + } + + int? _keyIndex(String key) { + final input = utf8.encode(key); + final keysVectorOffset = _indirect - _byteWidth * 3; + final indirectOffset = keysVectorOffset - + _readUInt(keysVectorOffset, BitWidthUtil.fromByteWidth(_byteWidth)); + final byteWidth = _readUInt( + keysVectorOffset + _byteWidth, + BitWidthUtil.fromByteWidth(_byteWidth), + ); + var low = 0; + var high = length - 1; + while (low <= high) { + final mid = (high + low) >> 1; + final dif = _diffKeys(input, mid, indirectOffset, byteWidth); + if (dif == 0) return mid; + if (dif < 0) { + high = mid - 1; + } else { + low = mid + 1; + } + } + return null; + } + + int _diffKeys(List input, int index, int indirectOffset, int byteWidth) { + final keyOffset = indirectOffset + index * byteWidth; + final keyIndirectOffset = + keyOffset - _readUInt(keyOffset, BitWidthUtil.fromByteWidth(byteWidth)); + for (var i = 0; i < input.length; i++) { + final dif = input[i] - _buffer.getUint8(keyIndirectOffset + i); + if (dif != 0) { + return dif; + } + } + return (_buffer.getUint8(keyIndirectOffset + input.length) == 0) ? 0 : -1; + } + + Reference _valueForIndexWithKey(int index, String key) { + final indirect = _indirect; + final elementOffset = indirect + index * _byteWidth; + final packedType = _buffer.getUint8(indirect + length * _byteWidth + index); + return Reference._( + _buffer, + elementOffset, + BitWidthUtil.fromByteWidth(_byteWidth), + packedType, + "$_path/$key", + ); + } + + Reference _valueForIndex(int index) { + final indirect = _indirect; + final elementOffset = indirect + index * _byteWidth; + final packedType = _buffer.getUint8(indirect + length * _byteWidth + index); + return Reference._( + _buffer, + elementOffset, + BitWidthUtil.fromByteWidth(_byteWidth), + packedType, + "$_path/[$index]", + ); + } + + String _keyForIndex(int index) { + final keysVectorOffset = _indirect - _byteWidth * 3; + final indirectOffset = keysVectorOffset - + _readUInt(keysVectorOffset, BitWidthUtil.fromByteWidth(_byteWidth)); + final byteWidth = _readUInt( + keysVectorOffset + _byteWidth, + BitWidthUtil.fromByteWidth(_byteWidth), + ); + final keyOffset = indirectOffset + index * byteWidth; + final keyIndirectOffset = + keyOffset - _readUInt(keyOffset, BitWidthUtil.fromByteWidth(byteWidth)); + var length = 0; + while (_buffer.getUint8(keyIndirectOffset + length) != 0) { + length += 1; + } + return utf8.decode(_buffer.buffer.asUint8List(keyIndirectOffset, length)); + } +} + +class _VectorIterator + with IterableMixin + implements Iterator { + final Reference _vector; + int index = -1; + + _VectorIterator(this._vector); + + @override + Reference get current => _vector[index]; + + @override + bool moveNext() { + index++; + return index < _vector.length; + } + + @override + Iterator get iterator => this; +} + +class _MapKeyIterator with IterableMixin implements Iterator { + final Reference _map; + int index = -1; + + _MapKeyIterator(this._map); + + @override + String get current => _map._keyForIndex(index); + + @override + bool moveNext() { + index++; + return index < _map.length; + } + + @override + Iterator get iterator => this; +} + +class _MapValueIterator + with IterableMixin + implements Iterator { + final Reference _map; + int index = -1; + + _MapValueIterator(this._map); + + @override + Reference get current => _map._valueForIndex(index); + + @override + bool moveNext() { + index++; + return index < _map.length; + } + + @override + Iterator get iterator => this; +} + +// JavaScript-safe 64-bit integer accessors (see file header). +int _getInt64Js(ByteData buffer, int offset) { + final lo = buffer.getUint32(offset, Endian.little); + final hi = buffer.getInt32(offset + 4, Endian.little); + return hi * 4294967296 + lo; +} + +int _getUint64Js(ByteData buffer, int offset) { + final lo = buffer.getUint32(offset, Endian.little); + final hi = buffer.getUint32(offset + 4, Endian.little); + return hi * 4294967296 + lo; +} diff --git a/objectbox/lib/src/web/flatbuffers/src/types.dart b/objectbox/lib/src/web/flatbuffers/src/types.dart new file mode 100644 index 000000000..87fe3ea85 --- /dev/null +++ b/objectbox/lib/src/web/flatbuffers/src/types.dart @@ -0,0 +1,205 @@ +// Vendored from package:flat_buffers 25.9.23 (Apache-2.0, Copyright Google +// Inc.) for the ObjectBox web implementation, with one change: all 64-bit +// integer ByteData accessors are replaced with JavaScript-safe versions built +// from two 32-bit halves, because dart2js does not support +// ByteData.get/setInt64/Uint64. Values keep full precision up to 2^53 (all +// JavaScript numbers are doubles). Used via the conditional export in +// lib/flatbuffers.dart; native platforms use the real package. +// ignore_for_file: type=lint +import 'dart:typed_data'; + +/// Represents the number of bits a value occupies. +enum BitWidth { width8, width16, width32, width64 } + +class BitWidthUtil { + static int toByteWidth(BitWidth self) { + return 1 << self.index; + } + + static BitWidth width(num value) { + if (value is int) { + var v = value.toInt().abs(); + if (v >> 7 == 0) return BitWidth.width8; + if (v >> 15 == 0) return BitWidth.width16; + if (v >> 31 == 0) return BitWidth.width32; + return BitWidth.width64; + } + return value == _toF32(value as double) + ? BitWidth.width32 + : BitWidth.width64; + } + + static BitWidth uwidth(num value) { + if (value.toInt() == value) { + var v = value.toInt().abs(); + if (v >> 8 == 0) return BitWidth.width8; + if (v >> 16 == 0) return BitWidth.width16; + if (v >> 32 == 0) return BitWidth.width32; + return BitWidth.width64; + } + return value == _toF32(value as double) + ? BitWidth.width32 + : BitWidth.width64; + } + + static BitWidth fromByteWidth(int value) { + if (value == 1) { + return BitWidth.width8; + } + if (value == 2) { + return BitWidth.width16; + } + if (value == 4) { + return BitWidth.width32; + } + if (value == 8) { + return BitWidth.width64; + } + throw Exception('Unexpected value $value'); + } + + static int paddingSize(int bufSize, int scalarSize) { + return (~bufSize + 1) & (scalarSize - 1); + } + + static double _toF32(double value) { + var bdata = ByteData(4); + bdata.setFloat32(0, value); + return bdata.getFloat32(0); + } + + static BitWidth max(BitWidth self, BitWidth other) { + if (self.index < other.index) { + return other; + } + return self; + } +} + +/// Represents all internal FlexBuffer types. +enum ValueType { + Null, + Int, + UInt, + Float, + Key, + String, + IndirectInt, + IndirectUInt, + IndirectFloat, + Map, + Vector, + VectorInt, + VectorUInt, + VectorFloat, + VectorKey, + @Deprecated( + 'VectorString is deprecated due to a flaw in the binary format (https://github.com/google/flatbuffers/issues/5627)', + ) + VectorString, + VectorInt2, + VectorUInt2, + VectorFloat2, + VectorInt3, + VectorUInt3, + VectorFloat3, + VectorInt4, + VectorUInt4, + VectorFloat4, + Blob, + Bool, + VectorBool, +} + +class ValueTypeUtils { + static int toInt(ValueType self) { + if (self == ValueType.VectorBool) return 36; + return self.index; + } + + static ValueType fromInt(int value) { + if (value == 36) return ValueType.VectorBool; + return ValueType.values[value]; + } + + static bool isInline(ValueType self) { + return self == ValueType.Bool || toInt(self) <= toInt(ValueType.Float); + } + + static bool isNumber(ValueType self) { + return toInt(self) >= toInt(ValueType.Int) && + toInt(self) <= toInt(ValueType.Float); + } + + static bool isIndirectNumber(ValueType self) { + return toInt(self) >= toInt(ValueType.IndirectInt) && + toInt(self) <= toInt(ValueType.IndirectFloat); + } + + static bool isTypedVectorElement(ValueType self) { + return self == ValueType.Bool || + (toInt(self) >= toInt(ValueType.Int) && + toInt(self) <= toInt(ValueType.String)); + } + + static bool isTypedVector(ValueType self) { + return self == ValueType.VectorBool || + (toInt(self) >= toInt(ValueType.VectorInt) && + toInt(self) <= toInt(ValueType.VectorString)); + } + + static bool isFixedTypedVector(ValueType self) { + return (toInt(self) >= toInt(ValueType.VectorInt2) && + toInt(self) <= toInt(ValueType.VectorFloat4)); + } + + static bool isAVector(ValueType self) { + return (isTypedVector(self) || + isFixedTypedVector(self) || + self == ValueType.Vector); + } + + static ValueType toTypedVector(ValueType self, int length) { + if (length == 0) { + return ValueTypeUtils.fromInt( + toInt(self) - toInt(ValueType.Int) + toInt(ValueType.VectorInt), + ); + } + if (length == 2) { + return ValueTypeUtils.fromInt( + toInt(self) - toInt(ValueType.Int) + toInt(ValueType.VectorInt2), + ); + } + if (length == 3) { + return ValueTypeUtils.fromInt( + toInt(self) - toInt(ValueType.Int) + toInt(ValueType.VectorInt3), + ); + } + if (length == 4) { + return ValueTypeUtils.fromInt( + toInt(self) - toInt(ValueType.Int) + toInt(ValueType.VectorInt4), + ); + } + throw Exception('unexpected length ' + length.toString()); + } + + static ValueType typedVectorElementType(ValueType self) { + return ValueTypeUtils.fromInt( + toInt(self) - toInt(ValueType.VectorInt) + toInt(ValueType.Int), + ); + } + + static ValueType fixedTypedVectorElementType(ValueType self) { + return ValueTypeUtils.fromInt( + (toInt(self) - toInt(ValueType.VectorInt2)) % 3 + toInt(ValueType.Int), + ); + } + + static int fixedTypedVectorElementSize(ValueType self) { + return (toInt(self) - toInt(ValueType.VectorInt2)) ~/ 3 + 2; + } + + static int packedType(ValueType self, BitWidth bitWidth) { + return bitWidth.index | (toInt(self) << 2); + } +} diff --git a/objectbox/lib/src/web/idb_util.dart b/objectbox/lib/src/web/idb_util.dart new file mode 100644 index 000000000..bd6926d95 --- /dev/null +++ b/objectbox/lib/src/web/idb_util.dart @@ -0,0 +1,111 @@ +// Small promise-style wrapper around IndexedDB via package:web, shared by the +// web implementation of Store/Box. Kept intentionally minimal: only what the +// engine needs (open with upgrade, batched read/write transactions). +// ignore_for_file: public_member_api_docs + +import 'dart:async'; +import 'dart:js_interop'; + +import 'package:web/web.dart' as web; + +import '../common.dart'; + +/// Completes with the request result, or errors with an [ObjectBoxException]. +Future idbRequest(web.IDBRequest request) { + final completer = Completer.sync(); + request.onsuccess = ((web.Event event) { + completer.complete(request.result as T); + }).toJS; + request.onerror = ((web.Event event) { + completer.completeError(ObjectBoxException( + 'IndexedDB request failed: ${request.error?.message ?? 'unknown'}')); + }).toJS; + return completer.future; +} + +/// Completes when the transaction is complete, errors on abort/error. +Future idbTransactionDone(web.IDBTransaction transaction) { + final completer = Completer.sync(); + transaction.oncomplete = ((web.Event event) { + completer.complete(); + }).toJS; + transaction.onerror = ((web.Event event) { + if (!completer.isCompleted) { + completer.completeError(ObjectBoxException( + 'IndexedDB transaction failed: ${transaction.error?.message ?? 'unknown'}')); + } + }).toJS; + transaction.onabort = ((web.Event event) { + if (!completer.isCompleted) { + completer.completeError( + ObjectBoxException('IndexedDB transaction was aborted')); + } + }).toJS; + return completer.future; +} + +/// Opens [name], creating/upgrading object stores so that all [storeNames] +/// exist. Uses the "open without version, then reopen with version+1 if +/// stores are missing" dance so it works with any pre-existing version. +Future idbOpen(String name, List storeNames) async { + final factory = web.window.indexedDB; + + Future open(int? version) { + final request = + version == null ? factory.open(name) : factory.open(name, version); + request.onupgradeneeded = ((web.IDBVersionChangeEvent event) { + final db = request.result as web.IDBDatabase; + for (final storeName in storeNames) { + if (!db.objectStoreNames.contains(storeName)) { + db.createObjectStore(storeName); + } + } + }).toJS; + return idbRequest(request).then((_) { + final db = request.result as web.IDBDatabase; + return db; + }); + } + + var db = await open(null); + final missing = + storeNames.any((storeName) => !db.objectStoreNames.contains(storeName)); + if (missing) { + final newVersion = db.version + 1; + db.close(); + db = await open(newVersion); + } + return db; +} + +/// Reads all (key, value) pairs of an object store. +Future> idbReadAll( + web.IDBDatabase db, String storeName) async { + final transaction = db.transaction(storeName.toJS, 'readonly'); + final store = transaction.objectStore(storeName); + final keys = await idbRequest(store.getAllKeys()); + final values = await idbRequest(store.getAll()); + final keysDart = keys.toDart; + final valuesDart = values.toDart; + final result = <(int, JSAny?)>[]; + for (var i = 0; i < keysDart.length; i++) { + result.add(((keysDart[i] as JSNumber).toDartInt, valuesDart[i])); + } + return result; +} + +/// Like [idbReadAll] but for stores with string keys. +Future> idbReadAllStringKeys( + web.IDBDatabase db, String storeName) async { + final transaction = db.transaction(storeName.toJS, 'readonly'); + final store = transaction.objectStore(storeName); + final keys = await idbRequest(store.getAllKeys()); + final values = await idbRequest(store.getAll()); + final keysDart = keys.toDart; + final valuesDart = values.toDart; + final result = <(String, JSAny?)>[]; + for (var i = 0; i < keysDart.length; i++) { + result.add(((keysDart[i] as JSString).toDart, valuesDart[i])); + } + return result; +} diff --git a/objectbox/lib/src/web/model.dart b/objectbox/lib/src/web/model.dart index 8b1378917..f70e94ad9 100644 --- a/objectbox/lib/src/web/model.dart +++ b/objectbox/lib/src/web/model.dart @@ -1 +1,22 @@ +// Web (stub) implementation of the model builder: mirrors the public API of +// `../native/model.dart` so the package compiles for the web platform, but +// throws `UnsupportedError` at runtime. See tracking issue #185. +// ignore_for_file: public_member_api_docs +import 'package:meta/meta.dart'; + +import '../modelinfo/index.dart'; +import 'unsupported.dart'; + +@internal +class Model { + Model(ModelInfo model) { + throwUnsupportedOnWeb(); + } + + void addEntity(ModelEntity entity) => throwUnsupportedOnWeb(); + + void addProperty(ModelProperty prop) => throwUnsupportedOnWeb(); + + void addRelation(ModelRelation rel) => throwUnsupportedOnWeb(); +} diff --git a/objectbox/lib/src/web/query.dart b/objectbox/lib/src/web/query.dart index 8b1378917..debfefa5a 100644 --- a/objectbox/lib/src/web/query.dart +++ b/objectbox/lib/src/web/query.dart @@ -1 +1,1405 @@ +/// Web implementation of queries (phase 3 of web support, #185): a pure-Dart +/// evaluator over the Condition tree, running against the records of the web +/// engine (see engine.dart). Property values are read generically from the +/// stored FlatBuffers via fb_reader.dart. +/// +/// Semantics follow the native implementation: string conditions default to +/// the store's queriesCaseSensitiveDefault, null property values only match +/// isNull, ordering defaults to ascending by id, string ordering is +/// case-insensitive unless Order.caseSensitive is set, and nearest-neighbor +/// (HNSW) conditions are evaluated as an exact brute-force scan ordered by +/// score. Not supported on web: Order.unsigned beyond 2^53 precision and Geo +/// vector distance. +/// +/// Note for maintainers: this file imports the web sibling 'store.dart' +/// directly (the analyzer resolves conditional facades to the native variant) +/// and silences analyzer-only type mismatches at shared-code boundaries with +/// `// ignore: argument_type_not_assignable` - at web compile time the types +/// are identical. +// ignore_for_file: public_member_api_docs +library; +import 'dart:async'; +import 'dart:math' as math; +import 'dart:typed_data'; + +import '../common.dart'; +import '../modelinfo/index.dart'; +import '../vector_search_results.dart'; +import 'engine.dart'; +import 'fb_reader.dart'; +import 'store.dart'; + +/// Groups query order flags. +class Order { + static final descending = 1; + + static final caseSensitive = 2; + + static final unsigned = 4; + + static final nullsLast = 8; + + static final nullsAsZero = 16; +} + +class QueryProperty { + final ModelProperty _model; + + QueryProperty(ModelProperty model) : _model = model; + + Condition isNull({String? alias}) => + _PropCondition(_model, _Op.isNull, alias); + + Condition notNull({String? alias}) => + _PropCondition(_model, _Op.notNull, alias); +} + +class QueryStringProperty extends QueryProperty { + QueryStringProperty(super.model); + + Condition _cond( + _Op op, String p, bool? caseSensitive, String? alias) => + _PropCondition(_model, op, alias, + value: p, caseSensitive: caseSensitive); + + Condition equals(String p, {bool? caseSensitive, String? alias}) => + _cond(_Op.eq, p, caseSensitive, alias); + + Condition notEquals(String p, + {bool? caseSensitive, String? alias}) => + _cond(_Op.notEq, p, caseSensitive, alias); + + Condition endsWith(String p, {bool? caseSensitive, String? alias}) => + _cond(_Op.endsWith, p, caseSensitive, alias); + + Condition startsWith(String p, + {bool? caseSensitive, String? alias}) => + _cond(_Op.startsWith, p, caseSensitive, alias); + + Condition contains(String p, {bool? caseSensitive, String? alias}) => + _cond(_Op.contains, p, caseSensitive, alias); + + Condition oneOf(List list, + {bool? caseSensitive, String? alias}) => + _PropCondition(_model, _Op.oneOf, alias, + list: List.of(list), caseSensitive: caseSensitive); + + Condition greaterThan(String p, + {bool? caseSensitive, String? alias}) => + _cond(_Op.gt, p, caseSensitive, alias); + + Condition greaterOrEqual(String p, + {bool? caseSensitive, String? alias}) => + _cond(_Op.goe, p, caseSensitive, alias); + + Condition lessThan(String p, {bool? caseSensitive, String? alias}) => + _cond(_Op.lt, p, caseSensitive, alias); + + Condition lessOrEqual(String p, + {bool? caseSensitive, String? alias}) => + _cond(_Op.loe, p, caseSensitive, alias); +} + +class QueryByteVectorProperty + extends QueryProperty { + QueryByteVectorProperty(super.model); + + Condition _cond(_Op op, List val, String? alias) => + _PropCondition(_model, op, alias, value: val); + + Condition equals(List val, {String? alias}) => + _cond(_Op.eq, val, alias); + + Condition greaterThan(List val, {String? alias}) => + _cond(_Op.gt, val, alias); + + Condition greaterOrEqual(List val, {String? alias}) => + _cond(_Op.goe, val, alias); + + Condition lessThan(List val, {String? alias}) => + _cond(_Op.lt, val, alias); + + Condition lessOrEqual(List val, {String? alias}) => + _cond(_Op.loe, val, alias); +} + +class QueryIntegerProperty extends QueryProperty { + QueryIntegerProperty(super.model); + + Condition _cond(_Op op, int p, String? alias) => + _PropCondition(_model, op, alias, value: p); + + Condition equals(int p, {String? alias}) => _cond(_Op.eq, p, alias); + + Condition notEquals(int p, {String? alias}) => + _cond(_Op.notEq, p, alias); + + Condition greaterThan(int p, {String? alias}) => + _cond(_Op.gt, p, alias); + + Condition greaterOrEqual(int p, {String? alias}) => + _cond(_Op.goe, p, alias); + + Condition lessThan(int p, {String? alias}) => + _cond(_Op.lt, p, alias); + + Condition lessOrEqual(int p, {String? alias}) => + _cond(_Op.loe, p, alias); + + Condition operator <(int p) => lessThan(p); + + Condition operator >(int p) => greaterThan(p); + + /// Finds objects with property value between and including the first and second value. + Condition between(int p1, int p2, {String? alias}) => + _PropCondition(_model, _Op.between, alias, + value: p1, value2: p2); + + Condition oneOf(List list, {String? alias}) => + _PropCondition(_model, _Op.oneOf, alias, + list: List.of(list)); + + Condition notOneOf(List list, {String? alias}) => + _PropCondition(_model, _Op.notOneOf, alias, + list: List.of(list)); +} + +class QueryDateProperty extends QueryIntegerProperty { + QueryDateProperty(super.model); + + int _ms(DateTime value) => value.millisecondsSinceEpoch; + + Condition equalsDate(DateTime value, {String? alias}) => + equals(_ms(value), alias: alias); + + Condition notEqualsDate(DateTime value, {String? alias}) => + notEquals(_ms(value), alias: alias); + + Condition greaterThanDate(DateTime value, {String? alias}) => + greaterThan(_ms(value), alias: alias); + + Condition greaterOrEqualDate(DateTime value, {String? alias}) => + greaterOrEqual(_ms(value), alias: alias); + + Condition lessThanDate(DateTime value, {String? alias}) => + lessThan(_ms(value), alias: alias); + + Condition lessOrEqualDate(DateTime value, {String? alias}) => + lessOrEqual(_ms(value), alias: alias); + + Condition betweenDate(DateTime value1, DateTime value2, + {String? alias}) => + between(_ms(value1), _ms(value2), alias: alias); + + Condition oneOfDate(List values, {String? alias}) => + oneOf(values.map(_ms).toList(), alias: alias); + + Condition notOneOfDate(List values, {String? alias}) => + notOneOf(values.map(_ms).toList(), alias: alias); +} + +class QueryDateNanoProperty extends QueryIntegerProperty { + QueryDateNanoProperty(super.model); + + int _ns(DateTime value) => value.microsecondsSinceEpoch * 1000; + + Condition equalsDate(DateTime value, {String? alias}) => + equals(_ns(value), alias: alias); + + Condition notEqualsDate(DateTime value, {String? alias}) => + notEquals(_ns(value), alias: alias); + + Condition greaterThanDate(DateTime value, {String? alias}) => + greaterThan(_ns(value), alias: alias); + + Condition greaterOrEqualDate(DateTime value, {String? alias}) => + greaterOrEqual(_ns(value), alias: alias); + + Condition lessThanDate(DateTime value, {String? alias}) => + lessThan(_ns(value), alias: alias); + + Condition lessOrEqualDate(DateTime value, {String? alias}) => + lessOrEqual(_ns(value), alias: alias); + + Condition betweenDate(DateTime value1, DateTime value2, + {String? alias}) => + between(_ns(value1), _ns(value2), alias: alias); + + Condition oneOfDate(List values, {String? alias}) => + oneOf(values.map(_ns).toList(), alias: alias); + + Condition notOneOfDate(List values, {String? alias}) => + notOneOf(values.map(_ns).toList(), alias: alias); +} + +class QueryIntegerVectorProperty extends QueryProperty { + QueryIntegerVectorProperty(super.model); + + Condition _cond(_Op op, int p, String? alias) => + _PropCondition(_model, op, alias, value: p); + + Condition equals(int p, {String? alias}) => _cond(_Op.eq, p, alias); + + Condition greaterThan(int p, {String? alias}) => + _cond(_Op.gt, p, alias); + + Condition greaterOrEqual(int p, {String? alias}) => + _cond(_Op.goe, p, alias); + + Condition lessThan(int p, {String? alias}) => + _cond(_Op.lt, p, alias); + + Condition lessOrEqual(int p, {String? alias}) => + _cond(_Op.loe, p, alias); + + Condition operator <(int p) => lessThan(p); + + Condition operator >(int p) => greaterThan(p); +} + +class QueryDoubleProperty extends QueryProperty { + QueryDoubleProperty(super.model); + + Condition _cond(_Op op, double p, String? alias) => + _PropCondition(_model, op, alias, value: p); + + /// Finds objects with property value between and including the first and second value. + Condition between(double p1, double p2, {String? alias}) => + _PropCondition(_model, _Op.between, alias, + value: p1, value2: p2); + + Condition greaterThan(double p, {String? alias}) => + _cond(_Op.gt, p, alias); + + Condition greaterOrEqual(double p, {String? alias}) => + _cond(_Op.goe, p, alias); + + Condition lessThan(double p, {String? alias}) => + _cond(_Op.lt, p, alias); + + Condition lessOrEqual(double p, {String? alias}) => + _cond(_Op.loe, p, alias); + + Condition operator <(double p) => lessThan(p); + + Condition operator >(double p) => greaterThan(p); +} + +class QueryDoubleVectorProperty + extends QueryProperty { + QueryDoubleVectorProperty(super.model); + + Condition _cond(_Op op, double p, String? alias) => + _PropCondition(_model, op, alias, value: p); + + Condition greaterThan(double p, {String? alias}) => + _cond(_Op.gt, p, alias); + + Condition greaterOrEqual(double p, {String? alias}) => + _cond(_Op.goe, p, alias); + + Condition lessThan(double p, {String? alias}) => + _cond(_Op.lt, p, alias); + + Condition lessOrEqual(double p, {String? alias}) => + _cond(_Op.loe, p, alias); + + Condition operator <(double p) => lessThan(p); + + Condition operator >(double p) => greaterThan(p); +} + +class QueryHnswProperty extends QueryDoubleVectorProperty { + QueryHnswProperty(super.model); + + Condition nearestNeighborsF32( + List queryVector, int maxResultCount, {String? alias}) => + _NearestNeighborsCondition( + _model, List.of(queryVector), maxResultCount, alias); +} + +class QueryBooleanProperty extends QueryProperty { + QueryBooleanProperty(super.model); + + // ignore: avoid_positional_boolean_parameters + Condition equals(bool p, {String? alias}) => + _PropCondition(_model, _Op.eq, alias, value: p); + + // ignore: avoid_positional_boolean_parameters + Condition notEquals(bool p, {String? alias}) => + _PropCondition(_model, _Op.notEq, alias, value: p); +} + +class QueryStringVectorProperty + extends QueryProperty> { + QueryStringVectorProperty(super.model); + + Condition containsElement(String value, + {bool? caseSensitive, String? alias}) => + _PropCondition(_model, _Op.containsElement, alias, + value: value, caseSensitive: caseSensitive); +} + +class QueryRelationToOne extends QueryIntegerProperty { + QueryRelationToOne(super.model); +} + +class QueryRelationToMany { + final ModelRelation _model; + + QueryRelationToMany(ModelRelation model) : _model = model; +} + +class QueryBacklinkToMany { + final QueryRelationToOne _relProp; + + QueryBacklinkToMany(QueryRelationToOne relProp) + : _relProp = relProp; + + Condition relationCount(int relationCount, {String? alias}) => + _RelationCountCondition(_relProp._model, relationCount, alias); +} + +// ---------------------------------------------------------------- conditions + +enum _Op { + eq, + notEq, + contains, + startsWith, + endsWith, + gt, + goe, + lt, + loe, + oneOf, + notOneOf, + between, + isNull, + notNull, + containsElement, +} + +class _EvalContext { + final WebStoreEngine engine; + final EntityData data; + + _EvalContext(this.engine, this.data); + + bool get caseSensitiveDefault => + engine.configuration.queriesCaseSensitiveDefault; + + _EvalContext withData(EntityData other) => _EvalContext(engine, other); +} + +/// A [Query] condition base class. +abstract class Condition { + final String? _alias; + + Condition(this._alias); + + // using & because && is not overridable + Condition operator &(Condition rh) => and(rh); + + Condition and(Condition rh) => andAll([rh]); + + Condition andAll(List> rh) => + _ConditionGroupAll([this, ...rh]); + + // using | because || is not overridable + Condition operator |(Condition rh) => or(rh); + + Condition or(Condition rh) => orAny([rh]); + + Condition orAny(List> rh) => + _ConditionGroupAny([this, ...rh]); + + bool _matches(_EvalContext ctx, int id, ByteData record); + + void _collect( + List<_PropCondition> props, List<_NearestNeighborsCondition> nn) {} + + String _describe(); +} + +class _PropCondition extends Condition { + final ModelProperty _property; + final _Op _op; + final bool? _caseSensitive; + Object? _value; + Object? _value2; + List? _list; + + _PropCondition(this._property, this._op, String? alias, + {Object? value, Object? value2, List? list, bool? caseSensitive}) + : _caseSensitive = caseSensitive, + _value = value, + _value2 = value2, + _list = list, + super(alias); + + @override + void _collect( + List<_PropCondition> props, List<_NearestNeighborsCondition> nn) => + props.add(this); + + @override + bool _matches(_EvalContext ctx, int id, ByteData record) { + final actual = readProperty(_property, record); + switch (_op) { + case _Op.isNull: + return actual == null; + case _Op.notNull: + return actual != null; + default: + break; + } + if (actual == null) return false; + + final sensitive = _caseSensitive ?? ctx.caseSensitiveDefault; + + // String vector containsElement. + if (_op == _Op.containsElement) { + final elements = (actual as List).cast(); + final needle = + sensitive ? _value as String : (_value as String).toLowerCase(); + return elements.any((e) => (sensitive ? e : e.toLowerCase()) == needle); + } + + // Scalar vectors (integer/double vector conditions): any element matches. + if (actual is List && actual is! Uint8List && _value is num) { + return actual.any((e) => + e is num && _compareNum(e, _value as num, _op, _value2 as num?)); + } + + if (actual is String) { + var a = actual; + var v = _value as String?; + if (!sensitive) { + a = a.toLowerCase(); + v = v?.toLowerCase(); + } + switch (_op) { + case _Op.eq: + return a == v; + case _Op.notEq: + return a != v; + case _Op.contains: + return a.contains(v!); + case _Op.startsWith: + return a.startsWith(v!); + case _Op.endsWith: + return a.endsWith(v!); + case _Op.gt: + return a.compareTo(v!) > 0; + case _Op.goe: + return a.compareTo(v!) >= 0; + case _Op.lt: + return a.compareTo(v!) < 0; + case _Op.loe: + return a.compareTo(v!) <= 0; + case _Op.oneOf: + return _list! + .map((e) => sensitive ? e as String : (e as String).toLowerCase()) + .contains(a); + default: + throw UnsupportedError('$_op on String'); + } + } + + if (actual is bool) { + switch (_op) { + case _Op.eq: + return actual == _value; + case _Op.notEq: + return actual != _value; + default: + throw UnsupportedError('$_op on bool'); + } + } + + if (actual is Uint8List) { + final cmp = _compareBytes(actual, (_value as List).cast()); + switch (_op) { + case _Op.eq: + return cmp == 0; + case _Op.gt: + return cmp > 0; + case _Op.goe: + return cmp >= 0; + case _Op.lt: + return cmp < 0; + case _Op.loe: + return cmp <= 0; + default: + throw UnsupportedError('$_op on byte vector'); + } + } + + if (actual is num) { + switch (_op) { + case _Op.oneOf: + return _list!.contains(actual); + case _Op.notOneOf: + return !_list!.contains(actual); + default: + return _compareNum(actual, _value as num, _op, _value2 as num?); + } + } + + throw UnsupportedError('$_op on ${actual.runtimeType}'); + } + + static bool _compareNum(num a, num v, _Op op, num? v2) { + switch (op) { + case _Op.eq: + return a == v; + case _Op.notEq: + return a != v; + case _Op.gt: + return a > v; + case _Op.goe: + return a >= v; + case _Op.lt: + return a < v; + case _Op.loe: + return a <= v; + case _Op.between: + return a >= v && a <= v2!; + default: + throw UnsupportedError('$op on num'); + } + } + + static int _compareBytes(Uint8List a, List b) { + final len = math.min(a.length, b.length); + for (var i = 0; i < len; i++) { + final d = a[i].compareTo(b[i]); + if (d != 0) return d; + } + return a.length.compareTo(b.length); + } + + @override + String _describe() => '${_property.name} $_op ' + '${_list ?? (_value2 == null ? _value : '[$_value, $_value2]')}' + '${_alias == null ? '' : ' (alias: $_alias)'}'; +} + +class _NearestNeighborsCondition extends Condition { + final ModelProperty _property; + List _queryVector; + int _maxResultCount; + + _NearestNeighborsCondition( + this._property, this._queryVector, this._maxResultCount, String? alias) + : super(alias); + + @override + void _collect( + List<_PropCondition> props, List<_NearestNeighborsCondition> nn) => + nn.add(this); + + // The nearest-neighbor condition does not filter by itself; scoring and + // result capping happen in Query. As a plain filter it matches objects + // that have a vector at all. + @override + bool _matches(_EvalContext ctx, int id, ByteData record) => + readProperty(_property, record) != null; + + double? _score(ByteData record) { + final value = readProperty(_property, record); + if (value == null) return null; + final vector = (value as List).cast(); + final distanceType = + _property.hnswParams?.distanceType ?? OBXVectorDistanceType.Euclidean; + return _distance(vector, _queryVector, distanceType); + } + + static double _distance(List a, List b, int type) { + final len = math.min(a.length, b.length); + switch (type) { + case OBXVectorDistanceType.Cosine: + case OBXVectorDistanceType.DotProduct: + case OBXVectorDistanceType.DotProductNonNormalized: + var dot = 0.0, normA = 0.0, normB = 0.0; + for (var i = 0; i < len; i++) { + dot += a[i] * b[i]; + normA += a[i] * a[i]; + normB += b[i] * b[i]; + } + if (type == OBXVectorDistanceType.DotProduct) { + // For normalized vectors the dot product equals cosine similarity. + return 1.0 - dot; + } + if (type == OBXVectorDistanceType.DotProductNonNormalized) { + final norm = math.sqrt(normA * normB); + return norm == 0 ? 2.0 : 1.0 - dot / norm; + } + final norm = math.sqrt(normA) * math.sqrt(normB); + return norm == 0 ? 2.0 : 1.0 - dot / norm; + case OBXVectorDistanceType.Geo: + throw UnsupportedError( + 'Geo vector distance is not supported on the web platform'); + case OBXVectorDistanceType.Euclidean: + default: + // Like the native default: Euclidean squared. + var sum = 0.0; + for (var i = 0; i < len; i++) { + final d = a[i] - b[i]; + sum += d * d; + } + return sum; + } + } + + @override + String _describe() => + '${_property.name} nearestNeighbors(dim: ${_queryVector.length}, ' + 'max: $_maxResultCount)${_alias == null ? '' : ' (alias: $_alias)'}'; +} + +class _RelationCountCondition extends Condition { + final ModelProperty _toOneProperty; + final int _count; + + _RelationCountCondition(this._toOneProperty, this._count, String? alias) + : super(alias); + + @override + bool _matches(_EvalContext ctx, int id, ByteData record) { + final sourceData = _entityOwning(ctx.engine, _toOneProperty); + return ctx.engine + .toOneBacklinkSources(sourceData, _toOneProperty.id.id, id) + .length == + _count; + } + + @override + String _describe() => 'relationCount(${_toOneProperty.name}) == $_count'; +} + +class _ConditionGroup extends Condition { + final List> _conditions; + final bool _all; + + // ignore: avoid_positional_boolean_parameters + _ConditionGroup(this._conditions, this._all) : super(null); + + @override + bool _matches(_EvalContext ctx, int id, ByteData record) => _all + ? _conditions.every((c) => c._matches(ctx, id, record)) + : _conditions.any((c) => c._matches(ctx, id, record)); + + @override + void _collect( + List<_PropCondition> props, List<_NearestNeighborsCondition> nn) { + for (final condition in _conditions) { + condition._collect(props, nn); + } + } + + @override + String _describe() => + '(${_conditions.map((c) => c._describe()).join(_all ? ' AND ' : ' OR ')})'; +} + +class _ConditionGroupAll extends _ConditionGroup { + _ConditionGroupAll(List> conditions) + : super(conditions, true); +} + +class _ConditionGroupAny extends _ConditionGroup { + _ConditionGroupAny(List> conditions) + : super(conditions, false); +} + +EntityData _entityOwning(WebStoreEngine engine, ModelProperty property) { + for (final data in engine.entities.values) { + if (data.model.properties.any((p) => identical(p, property))) return data; + } + throw ArgumentError('Property ${property.name} is not part of the model'); +} + +// ------------------------------------------------------------------- linking + +class _LinkSpec { + /// For ToOne links (forward or backlink): the relation property. + final ModelProperty? property; + + /// For standalone ToMany links: the relation id. + final int? relationId; + + /// True: source -> target (link/linkMany), false: backlink direction. + final bool forward; + + /// The entity on the other side of the link. + final EntityData otherData; + + final Condition? condition; + final List<_LinkSpec> children = []; + + _LinkSpec( + {this.property, + this.relationId, + required this.forward, + required this.otherData, + this.condition}); + + bool _matches(_EvalContext ctx, int id, ByteData record) { + final engine = ctx.engine; + final List otherIds; + if (property != null) { + if (forward) { + final targetId = readIntProperty(property!, record); + otherIds = targetId == 0 ? const [] : [targetId]; + } else { + otherIds = engine.toOneBacklinkSources(otherData, property!.id.id, id); + } + } else { + otherIds = forward + ? engine.relTargets(relationId!, id) + : engine.relBacklinkSources(relationId!, id); + } + final otherCtx = ctx.withData(otherData); + for (final otherId in otherIds) { + final otherBytes = otherData.records[otherId]; + if (otherBytes == null) continue; + final otherRecord = ByteData.view(otherBytes.buffer, + otherBytes.offsetInBytes, otherBytes.lengthInBytes); + if (condition != null && + !condition!._matches(otherCtx, otherId, otherRecord)) { + continue; + } + if (children + .any((child) => !child._matches(otherCtx, otherId, otherRecord))) { + continue; + } + return true; + } + return false; + } +} + +class _OrderSpec { + final ModelProperty property; + final int flags; + + _OrderSpec(this.property, this.flags); +} + +// -------------------------------------------------------------------- query + +/// A repeatable Query returning the latest matching Objects. +class Query { + final Store _store; + final WebStoreEngine _engine; + final EntityData _data; + final EntityDefinition _entity; + final Condition? _condition; + final List<_OrderSpec> _orders; + final List<_LinkSpec> _links; + int _offset = 0; + int _limit = 0; + + Query._(this._store, this._engine, this._data, this._entity, this._condition, + this._orders, this._links); + + int get entityId => _data.model.id.id; + + set offset(int offset) => _offset = offset; + + set limit(int limit) => _limit = limit; + + _EvalContext get _ctx => _EvalContext(_engine, _data); + + _NearestNeighborsCondition? get _nnCondition { + final props = <_PropCondition>[]; + final nn = <_NearestNeighborsCondition>[]; + _condition?._collect(props, nn); + if (nn.length > 1) { + throw UnsupportedError( + 'Only a single nearestNeighbors condition is supported per query'); + } + return nn.isEmpty ? null : nn.first; + } + + /// Ids of all matching objects with conditions, links, nearest-neighbor + /// scoring, ordering, offset and limit applied. [scores] is filled when a + /// nearest-neighbor condition is present (then results are score-ordered). + List _matchingIds({Map? scores}) { + _engine.checkOpen(); + final ctx = _ctx; + final nn = _nnCondition; + var candidates = []; + _data.records.forEach((id, bytes) { + final record = + ByteData.view(bytes.buffer, bytes.offsetInBytes, bytes.lengthInBytes); + if (_condition != null && !_condition._matches(ctx, id, record)) { + return; + } + if (_links.any((link) => !link._matches(ctx, id, record))) return; + candidates.add(id); + }); + + if (nn != null) { + final withScores = <(int, double)>[]; + for (final id in candidates) { + final bytes = _data.records[id]!; + final score = nn._score(ByteData.view( + bytes.buffer, bytes.offsetInBytes, bytes.lengthInBytes)); + if (score != null) withScores.add((id, score)); + } + withScores.sort((a, b) => a.$2.compareTo(b.$2)); + final capped = withScores.take(nn._maxResultCount).toList(); + if (scores != null) { + for (final (id, score) in capped) { + scores[id] = score; + } + } + candidates = [for (final (id, _) in capped) id]; + } else if (_orders.isNotEmpty) { + candidates.sort(_comparator()); + } + + if (_offset > 0) { + candidates = + candidates.length > _offset ? candidates.sublist(_offset) : []; + } + if (_limit > 0 && candidates.length > _limit) { + candidates = candidates.sublist(0, _limit); + } + return candidates; + } + + Comparator _comparator() => (int a, int b) { + for (final order in _orders) { + final va = _readOrderValue(order, a); + final vb = _readOrderValue(order, b); + var result = _compareValues(va, vb, order.flags); + if ((order.flags & 1) != 0) result = -result; // Order.descending + if (result != 0) return result; + } + return a.compareTo(b); + }; + + Object? _readOrderValue(_OrderSpec order, int id) { + final bytes = _data.records[id]!; + var value = readProperty(order.property, + ByteData.view(bytes.buffer, bytes.offsetInBytes, bytes.lengthInBytes)); + if (value == null && (order.flags & 16) != 0) value = 0; // nullsAsZero + return value; + } + + static int _compareValues(Object? a, Object? b, int flags) { + if (a == null && b == null) return 0; + // Nulls first by default, last with Order.nullsLast. (Applied before the + // descending inversion, like the native implementation.) + final nullsLast = (flags & 8) != 0; + if (a == null) return nullsLast ? 1 : -1; + if (b == null) return nullsLast ? -1 : 1; + if (a is String && b is String) { + // Case-insensitive unless Order.caseSensitive. + if ((flags & 2) == 0) { + final result = a.toLowerCase().compareTo(b.toLowerCase()); + if (result != 0) return result; + } + return a.compareTo(b); + } + if (a is bool && b is bool) return (a ? 1 : 0).compareTo(b ? 1 : 0); + if (a is num && b is num) { + if ((flags & 4) != 0) { + // Order.unsigned: negative values sort after positive ones. + final ua = a < 0 ? a + 18446744073709551616.0 : a; + final ub = b < 0 ? b + 18446744073709551616.0 : b; + return ua.compareTo(ub); + } + return a.compareTo(b); + } + return 0; + } + + int count() => _matchingIds().length; + + int remove() { + final ids = _matchingIds(); + return _engine.runInTx(() { + var removed = 0; + for (final id in ids) { + if (_engine.removeRecord(_data, id)) removed++; + } + return removed; + }); + } + + Future removeAsync() => Future.microtask(remove); + + void close() { + // Nothing to release on web. + } + + T _objectFromBytes(Uint8List bytes) => _entity.objectFromFB( + // ignore: argument_type_not_assignable + _store, + ByteData.view(bytes.buffer, bytes.offsetInBytes, bytes.lengthInBytes)); + + T? _get(int id) { + final bytes = _data.records[id]; + return bytes == null ? null : _objectFromBytes(bytes); + } + + T? findFirst() { + final ids = _matchingIds(); + return ids.isEmpty ? null : _get(ids.first); + } + + Future findFirstAsync() => Future.microtask(findFirst); + + T? findUnique() { + final ids = _matchingIds(); + if (ids.length > 1) { + throw NonUniqueResultException( + 'Query findUnique() matched more than one object'); + } + return ids.isEmpty ? null : _get(ids.first); + } + + Future findUniqueAsync() => Future.microtask(findUnique); + + List findIds() => _matchingIds(); + + Future> findIdsAsync() => Future.microtask(findIds); + + List find() => _matchingIds().map(_get).whereType().toList(); + + Future> findAsync() => Future.microtask(find); + + Map _requireScores() { + if (_nnCondition == null) { + throw StateError( + 'This query does not use a nearestNeighborsF32 condition, ' + 'so there are no scores'); + } + return {}; + } + + List findIdsWithScores() { + final scores = _requireScores(); + final ids = _matchingIds(scores: scores); + return [for (final id in ids) IdWithScore(id, scores[id]!)]; + } + + Future> findIdsWithScoresAsync() => + Future.microtask(findIdsWithScores); + + List> findWithScores() { + final scores = _requireScores(); + final ids = _matchingIds(scores: scores); + return [ + for (final id in ids) + if (_get(id) case final T object) + ObjectWithScore(object, scores[id]!) + ]; + } + + Future>> findWithScoresAsync() => + Future.microtask(findWithScores); + + Stream stream() => Stream.fromIterable(find()); + + /// For internal testing purposes. + String describe() => 'Query for entity ${_data.model.name} with condition: ' + '${_condition?._describe() ?? '(none)'}' + '${_links.isEmpty ? '' : ' with ${_links.length} link(s)'}'; + + /// For internal testing purposes. + String describeParameters() { + final props = <_PropCondition>[]; + final nn = <_NearestNeighborsCondition>[]; + _condition?._collect(props, nn); + return [...props, ...nn].map((c) => c._describe()).join('\n'); + } + + PropertyQuery property(QueryProperty prop) => + PropertyQuery._( + this, prop._model, _engine.configuration.queriesCaseSensitiveDefault); +} + +// ------------------------------------------------------------------ builder + +/// Query builder allows creating reusable queries. +class QueryBuilder { + final Store? _store; + final EntityDefinition? _entity; + final WebStoreEngine _engine; + final Condition? _condition; + final List<_OrderSpec> _orders = []; + final List<_LinkSpec> _links = []; + + /// When this is a sub-builder created by link/backlink, conditions and + /// nested links are attached to this spec; build() is only available on + /// the root builder. + final _LinkSpec? _linkSpec; + + factory QueryBuilder( + Store store, EntityDefinition entity, Condition? qc) => + QueryBuilder._(store, entity, InternalStoreAccess.engine(store), qc); + + QueryBuilder._(this._store, this._entity, this._engine, this._condition) + : _linkSpec = null; + + QueryBuilder._sub(this._engine, this._linkSpec) + : _store = null, + _entity = null, + _condition = null; + + EntityData get _data => _linkSpec != null + ? _linkSpec.otherData + : _engine.entities[_entity!.model.id.id]!; + + Query build() { + if (_linkSpec != null) { + throw StateError( + 'build() is only available on the root query builder, not on a ' + 'linked builder'); + } + return Query._(_store!, _engine, _data, _entity!, _condition, + List.of(_orders), List.of(_links)); + } + + Stream> watch({bool triggerImmediately = false}) { + final query = build(); + final entityType = _entity!.type(); + final source = _engine.changes.stream + .where((types) => types.contains(entityType)) + .map((_) => query); + if (!triggerImmediately) return source; + late StreamController> controller; + StreamSubscription>? subscription; + controller = StreamController>( + onListen: () { + controller.add(query); + subscription = source.listen(controller.add, + onError: controller.addError, onDone: controller.close); + }, + onCancel: () => subscription?.cancel()); + return controller.stream; + } + + QueryBuilder order(QueryProperty p, {int flags = 0}) { + if (_linkSpec != null) { + throw StateError('order() is only available on the root query builder'); + } + _orders.add(_OrderSpec(p._model, flags)); + // Fluent API matching the native implementation. + // ignore: avoid_returning_this + return this; + } + + _LinkSpec _addLink(_LinkSpec spec) { + if (_linkSpec != null) { + _linkSpec.children.add(spec); + } else { + _links.add(spec); + } + return spec; + } + + EntityData _dataOf(Type type) { + final data = _engine.entitiesByType[type]; + if (data == null) { + throw ArgumentError('Unknown entity type $type in link'); + } + return data; + } + + // Note: in the native implementation the following link methods live on a + // private base class `_QueryBuilder` which is also their return type. As a + // private type cannot be mirrored here, the methods are flattened into this + // class and return [QueryBuilder]; the returned (sub-)builder supports the + // same chained link calls, but not build()/watch()/order(). + + QueryBuilder link( + QueryRelationToOne rel, + [Condition? qc]) => + QueryBuilder._sub( + _engine, + _addLink(_LinkSpec( + property: rel._model, + forward: true, + otherData: _dataOf(TargetEntityT), + condition: qc))); + + QueryBuilder backlink( + QueryRelationToOne rel, + [Condition? qc]) => + QueryBuilder._sub( + _engine, + _addLink(_LinkSpec( + property: rel._model, + forward: false, + otherData: _dataOf(SourceEntityT), + condition: qc))); + + QueryBuilder linkMany( + QueryRelationToMany rel, + [Condition? qc]) => + QueryBuilder._sub( + _engine, + _addLink(_LinkSpec( + relationId: rel._model.id.id, + forward: true, + otherData: _dataOf(TargetEntityT), + condition: qc))); + + QueryBuilder backlinkMany( + QueryRelationToMany rel, + [Condition? qc]) => + QueryBuilder._sub( + _engine, + _addLink(_LinkSpec( + relationId: rel._model.id.id, + forward: false, + otherData: _dataOf(SourceEntityT), + condition: qc))); +} + +// ------------------------------------------------------------------- params + +/// Adds capabilities to set query parameters +extension QuerySetParam on Query { + QueryParam param( + QueryProperty prop, + {String? alias}) { + final props = <_PropCondition>[]; + final nn = <_NearestNeighborsCondition>[]; + _condition?._collect(props, nn); + for (final link in _links) { + link.condition?._collect(props, nn); + } + final matchingProps = props + .where((c) => + identical(c._property, prop._model) && + (alias == null || c._alias == alias)) + .toList(); + final matchingNn = nn + .where((c) => + identical(c._property, prop._model) && + (alias == null || c._alias == alias)) + .toList(); + if (matchingProps.isEmpty && matchingNn.isEmpty) { + throw ArgumentError( + 'No query condition found for property "${prop._model.name}"' + '${alias == null ? '' : ' with alias "$alias"'}'); + } + return QueryParam._(matchingProps, matchingNn); + } +} + +/// QueryParam +class QueryParam { + final List<_PropCondition> _conditions; + final List<_NearestNeighborsCondition> _nnConditions; + + QueryParam._(this._conditions, this._nnConditions); + + void _setValue(Object? value) { + for (final condition in _conditions) { + condition._value = value; + } + } + + void _setValues(List values) { + for (final condition in _conditions) { + condition._list = values; + } + } + + void _setTwoValues(Object? a, Object? b) { + for (final condition in _conditions) { + condition._value = a; + condition._value2 = b; + } + } +} + +/// QueryParam for string properties +extension QueryParamString on QueryParam { + set value(String value) => _setValue(value); + + set values(List values) => _setValues(List.of(values)); +} + +/// QueryParam for byte vector properties +extension QueryParamBytes on QueryParam> { + set value(List value) => _setValue(value); +} + +/// QueryParam for int properties +extension QueryParamInt on QueryParam { + set value(int value) => _setValue(value); + + set values(List values) => _setValues(List.of(values)); + + /// set values for condition consisting of two values + void twoValues(int a, int b) => _setTwoValues(a, b); +} + +/// QueryParam for double properties +extension QueryParamDouble on QueryParam { + set value(double value) => _setValue(value); + + /// set values for condition consisting of two values + void twoValues(double a, double b) => _setTwoValues(a, b); + + /// Set values for the nearest neighbor condition. + void nearestNeighborsF32(List queryVector, int maxResultCount) { + if (_nnConditions.isEmpty) { + throw ArgumentError('No nearestNeighborsF32 condition in this query'); + } + for (final condition in _nnConditions) { + condition._queryVector = List.of(queryVector); + condition._maxResultCount = maxResultCount; + } + } +} + +/// QueryParam for boolean properties +extension QueryParamBool on QueryParam { + set value(bool value) => _setValue(value); +} + +// ----------------------------------------------------------- property query + +/// Property query base. +class PropertyQuery { + final Query _query; + final ModelProperty _property; + bool _distinct = false; + bool _caseSensitive; + + PropertyQuery._(this._query, this._property, this._caseSensitive); + + /// Close the property query, freeing its resources + void close() {} + + /// Property values of all objects matching the query conditions (offset and + /// limit are not applied, like in the native implementation). Nulls are + /// excluded unless [replaceNullWith] is provided. + List _values({Object? replaceNullWith}) { + // Property queries ignore offset/limit; run over all condition matches. + final query = _query; + final saveOffset = query._offset, saveLimit = query._limit; + query._offset = 0; + query._limit = 0; + final List ids; + try { + ids = query._matchingIds(); + } finally { + query._offset = saveOffset; + query._limit = saveLimit; + } + final result = []; + for (final id in ids) { + final bytes = query._data.records[id]!; + final value = readProperty( + _property, + ByteData.view( + bytes.buffer, bytes.offsetInBytes, bytes.lengthInBytes)); + if (value == null) { + if (replaceNullWith != null) result.add(replaceNullWith); + continue; + } + result.add(value); + } + if (_distinct) { + final seen = {}; + result.retainWhere((v) => + seen.add(v is String && !_caseSensitive ? v.toLowerCase() : v)); + } + return result; + } + + List _numValues() => _values().cast(); +} + +/// "Property query" for an integer field. Created by [Query.property()]. +extension IntegerPropertyQuery on PropertyQuery { + double average() { + final values = _numValues(); + return values.isEmpty + ? 0 + : values.fold(0, (a, b) => a + b) / values.length; + } + + int count() => _values().length; + + bool get distinct => _distinct; + + set distinct(bool d) => _distinct = d; + + int min() => + _numValues() + .fold(null, (m, v) => m == null || v < m ? v as int : m) ?? + 0; + + int max() => + _numValues() + .fold(null, (m, v) => m == null || v > m ? v as int : m) ?? + 0; + + int sum() => _numValues().fold(0, (a, b) => a + (b as int)); + + List find({int? replaceNullWith}) => + _values(replaceNullWith: replaceNullWith).cast(); +} + +/// "Property query" for a double field. Created by [Query.property()]. +extension DoublePropertyQuery on PropertyQuery { + double average() { + final values = _numValues(); + return values.isEmpty + ? 0 + : values.fold(0, (a, b) => a + b) / values.length; + } + + int count() => _values().length; + + bool get distinct => _distinct; + + set distinct(bool d) => _distinct = d; + + double min() => + _numValues().fold( + null, (m, v) => m == null || v < m ? v.toDouble() : m) ?? + 0; + + double max() => + _numValues().fold( + null, (m, v) => m == null || v > m ? v.toDouble() : m) ?? + 0; + + double sum() => _numValues().fold(0, (a, b) => a + b); + + List find({double? replaceNullWith}) => + _values(replaceNullWith: replaceNullWith).cast(); +} + +/// "Property query" for a string field. Created by [Query.property()]. +extension StringPropertyQuery on PropertyQuery { + /// Use case-sensitive comparison when querying [distinct] values. + set caseSensitive(bool caseSensitive) => _caseSensitive = caseSensitive; + + /// Get status of the case-sensitive configuration. + bool get caseSensitive => _caseSensitive; + + bool get distinct => _distinct; + + set distinct(bool d) => _distinct = d; + + int count() => _values().length; + + List find({String? replaceNullWith}) => + _values(replaceNullWith: replaceNullWith).cast(); +} diff --git a/objectbox/lib/src/web/store.dart b/objectbox/lib/src/web/store.dart index 8b1378917..4c04c3cad 100644 --- a/objectbox/lib/src/web/store.dart +++ b/objectbox/lib/src/web/store.dart @@ -1 +1,284 @@ +// Web implementation of the store, backed by WebStoreEngine (in-memory, +// persisted to IndexedDB). See engine.dart for the design and semantics. +// +// Note for maintainers: web files import sibling web files directly (e.g. +// 'box.dart', not '../box.dart') - the analyzer resolves the conditional +// facades to the native variant, so referencing web-only members through a +// facade would not analyze. +// ignore_for_file: public_member_api_docs +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:web/web.dart' as web; + +import '../common.dart'; +import '../modelinfo/index.dart'; +import '../store_config.dart'; +import '../sync.dart'; +import '../transaction.dart' show TxMode; +import 'box.dart'; +import 'engine.dart'; +import 'transaction.dart'; +import 'unsupported.dart'; + +export '../store_config.dart'; + +/// Represents an ObjectBox database on the web platform: an in-memory store +/// persisted to IndexedDB. Await [ready] before use (generated `openStore()` +/// does this for Flutter apps). +class Store { + static const String defaultDirectoryPath = 'objectbox'; + + static const String inMemoryPrefix = 'memory:'; + + static bool debugLogs = false; + + /// Open engines by directory path (also the IndexedDB database name). + static final Map _openEngines = {}; + + /// Teardown futures of recently closed engines, so a re-open of the same + /// path waits for the previous IndexedDB connection to flush and close. + static final Map> _pendingCloses = {}; + + final WebStoreEngine _engine; + final Map _boxes = {}; + bool _handleClosed = false; + + String get directoryPath => _engine.directoryPath; + + /// A future that completes when the store has loaded its persisted data + /// and is ready for use. + Future get ready => _engine.ready; + + Store(ModelDefinition modelDefinition, + {String? directory, + int? maxDBSizeInKB, + int? maxDataSizeInKB, + int? fileMode, + int? maxReaders, + int? debugFlags, + bool queriesCaseSensitiveDefault = true, + String? macosApplicationGroup}) + // Note: sizes, file mode, readers and debug flags have no meaning on + // web and are ignored. + : _engine = _createEngine(modelDefinition, + directory ?? defaultDirectoryPath, queriesCaseSensitiveDefault); + + static WebStoreEngine _createEngine(ModelDefinition modelDefinition, + String path, bool queriesCaseSensitiveDefault) { + if (_openEngines.containsKey(path)) { + throw ObjectBoxException( + 'Cannot open store: another store is still open using the same path' + ' "$path". Use Store.attach or close the other store first.'); + } + final engine = WebStoreEngine(modelDefinition, path, + queriesCaseSensitiveDefault: queriesCaseSensitiveDefault, + awaitBeforeOpen: _pendingCloses[path]); + _openEngines[path] = engine; + return engine; + } + + Store._fromEngine(this._engine); + + Store.fromReference(ModelDefinition modelDefinition, ByteData reference, + {bool queriesCaseSensitiveDefault = true}) + : _engine = throwUnsupportedOnWeb(); + + /// Attaches to an already open store (same JS thread only on web). + factory Store.attach(ModelDefinition modelDefinition, String? directoryPath, + {bool queriesCaseSensitiveDefault = true}) { + final path = directoryPath ?? defaultDirectoryPath; + final engine = _openEngines[path]; + if (engine == null) { + throw ObjectBoxException( + 'Cannot attach to store: no store is open for path "$path"'); + } + engine.refCount++; + return Store._fromEngine(engine); + } + + static String databaseVersion() => 'ObjectBox web (IndexedDB backed)'; + + static bool isOpen(String? directoryPath) => + _openEngines.containsKey(directoryPath ?? defaultDirectoryPath); + + /// Approximate in-memory size of the stored records in bytes. + static int dbFileSize(String? directoryPath) { + final engine = _openEngines[directoryPath ?? defaultDirectoryPath]; + if (engine == null) return 0; + var size = 0; + for (final data in engine.entities.values) { + for (final bytes in data.records.values) { + size += bytes.lengthInBytes; + } + } + return size; + } + + /// Deletes the IndexedDB database. The store must be closed; the deletion + /// is asynchronous (fire-and-forget, matching the synchronous native API). + static void removeDbFiles(String? directoryPath) { + final path = directoryPath ?? defaultDirectoryPath; + if (_openEngines.containsKey(path)) { + throw ObjectBoxException( + 'Cannot remove database files while the store is open'); + } + if (path.startsWith(inMemoryPrefix)) return; + final pending = _pendingCloses[path]; + if (pending != null) { + pending.then((_) => web.window.indexedDB.deleteDatabase(path)); + } else { + web.window.indexedDB.deleteDatabase(path); + } + } + + ByteData get reference => throwUnsupportedOnWeb(); + + bool isClosed() => _handleClosed || _engine.isClosed; + + void close() { + if (_handleClosed) return; + _handleClosed = true; + _boxes.clear(); + _engine.refCount--; + if (_engine.refCount > 0) return; + final path = _engine.directoryPath; + _openEngines.remove(path); + _engine.notifyCloseListeners(); + final closing = _engine.close(); + _pendingCloses[path] = closing; + closing.whenComplete(() { + if (identical(_pendingCloses[path], closing)) { + _pendingCloses.remove(path); + } + }); + } + + Box box() { + _checkOpen(); + return (_boxes[T] ??= InternalBoxAccess.create( + this, InternalStoreAccess.entityDef(this))) as Box; + } + + R runInTransaction(TxMode mode, R Function() fn) { + _checkOpen(); + return _engine.runInTx(fn); + } + + /// Runs [callback] within a transaction. Unlike on native platforms there + /// are no isolates on web: the callback runs on the same thread. + Future runInTransactionAsync( + TxMode mode, TxAsyncCallback callback, P param) => + Future.microtask( + () => runInTransaction(mode, () => callback(this, param))); + + /// Runs [callback] asynchronously. Unlike on native platforms there are no + /// isolates on web: the callback runs on the same thread. + Future runAsync(RunAsyncCallback callback, P param) => + Future.microtask(() async => await callback(this, param)); + + /// There is no sync client on web (ObjectBox Sync is not available). + SyncClient? syncClient() => null; + + /// On web, writes are persisted asynchronously (see engine.dart); this + /// schedules the queue but cannot synchronously wait for it. Await + /// [InternalStoreAccess.queueCompletion] (internal) or rely on the + /// write-behind queue, which flushes within a microtask of every write. + bool awaitQueueCompletion() => true; + + bool awaitQueueSubmitted() => true; + + void _checkOpen() { + if (_handleClosed) throw StateError('Store is closed'); + _engine.checkOpen(); + } +} + +/// Internal store API, mirroring the native StoreInternal extension. +extension StoreInternal on Store { + static Store attachByConfiguration(StoreConfiguration configuration) { + final engine = Store._openEngines[configuration.directoryPath]; + if (engine == null) { + throw ObjectBoxException( + 'Cannot attach to store: no store is open for path' + ' "${configuration.directoryPath}"'); + } + engine.refCount++; + return Store._fromEngine(engine); + } + + StoreConfiguration configuration() => _engine.configuration; + + void checkOpen() => _checkOpen(); +} + +/// Internal only. +class InternalStoreAccess { + static Store createMinimal(int ptrAddress, + {bool queriesCaseSensitiveDefault = true}) => + throwUnsupportedOnWeb(); + + /// The web engine backing [store]. Web-internal only. + static WebStoreEngine engine(Store store) => store._engine; + + /// Completes when all currently queued writes are persisted to IndexedDB. + static Future queueCompletion(Store store) => + store._engine.awaitQueueCompletion(); + + static EntityDefinition entityDef(Store store) { + final definition = store._engine.modelDefinition.bindings[T]; + if (definition == null) { + throw ArgumentError('Unknown entity type $T - is the model up to date?'); + } + return definition as EntityDefinition; + } + + static R runInTransaction( + Store store, TxMode mode, R Function(Transaction) fn) { + final tx = Transaction(store, mode); + try { + final result = fn(tx); + tx.successAndClose(); + return result; + } catch (e) { + tx.abortAndClose(); + rethrow; + } + } + + static Map entityTypeById(Store store) => { + for (final entry in store._engine.modelDefinition.bindings.entries) + entry.value.model.id.id: entry.key + }; + + static void addCloseListener( + Store store, dynamic key, void Function() listener) => + store._engine.closeListeners[key] = listener; + + static void removeCloseListener(Store store, dynamic key) => + store._engine.closeListeners.remove(key); + + static bool queryCS(Store store) => + store._engine.configuration.queriesCaseSensitiveDefault; +} + +/// Data change streams, mirroring the native ObservableStore extension. +extension ObservableStore on Store { + /// A stream that emits whenever objects of [EntityT] change (are put or + /// removed). Query re-execution (like on native) is not yet available on + /// web, so this emits void events. + Stream watch() => _engine.changes.stream + .where((types) => types.contains(EntityT)) + .map((_) {}); + + /// A stream that emits the list of entity types affected by each committed + /// change. + Stream> get entityChanges => _engine.changes.stream; +} + +/// Signature for the callback passed to [Store.runAsync]. +typedef RunAsyncCallback = FutureOr Function(Store store, P parameter); + +/// Signature for callback passed to [Store.runInTransactionAsync]. +typedef TxAsyncCallback = R Function(Store store, P parameter); diff --git a/objectbox/lib/src/web/sync.dart b/objectbox/lib/src/web/sync.dart index 8b1378917..73a205819 100644 --- a/objectbox/lib/src/web/sync.dart +++ b/objectbox/lib/src/web/sync.dart @@ -1 +1,241 @@ +// Web stub for the ObjectBox Sync API: mirrors the public API of +// `../native/sync.dart` so that code referencing Sync compiles for web +// (dart2js and dart2wasm). Sync requires the native ObjectBox library, which +// is not available on web, so APIs throw. See tracking issue #185. +// ignore_for_file: public_member_api_docs, unused_element, unused_field +import 'dart:convert' show utf8; +import 'dart:typed_data'; + +import '../store.dart'; +import 'unsupported.dart'; + +/// Credential type values, matching OBXSyncCredentialsType in the native +/// bindings (objectbox_c.dart). +abstract class _CredentialsType { + static const int none = 1; + static const int googleAuth = 3; + static const int sharedSecretSipped = 4; + static const int userPassword = 6; + static const int jwtId = 7; + static const int jwtAccess = 8; + static const int jwtRefresh = 9; + static const int jwtCustom = 10; +} + +class SyncCredentials { + final int _type; + + SyncCredentials._(this._type); + + static SyncCredentials none() => _SyncCredentialsNone._(); + + static SyncCredentials sharedSecretUint8List(Uint8List data) => + SyncCredentialsSecret._(_CredentialsType.sharedSecretSipped, data); + + static SyncCredentials sharedSecretString(String data) => + SyncCredentialsSecret._encode(_CredentialsType.sharedSecretSipped, data); + + static SyncCredentials googleAuthUint8List(Uint8List data) => + SyncCredentialsSecret._(_CredentialsType.googleAuth, data); + + static SyncCredentials googleAuthString(String data) => + SyncCredentialsSecret._encode(_CredentialsType.googleAuth, data); + + static SyncCredentials userAndPassword(String user, String password) => + _SyncCredentialsUserPassword._( + _CredentialsType.userPassword, + user, + password, + ); + + static SyncCredentials jwtIdToken(String jwtIdToken) => + SyncCredentialsSecret._encode(_CredentialsType.jwtId, jwtIdToken); + + static SyncCredentials jwtAccessToken(String jwtAccessToken) => + SyncCredentialsSecret._encode(_CredentialsType.jwtAccess, jwtAccessToken); + + static SyncCredentials jwtRefreshToken(String jwtRefreshToken) => + SyncCredentialsSecret._encode( + _CredentialsType.jwtRefresh, + jwtRefreshToken, + ); + + static SyncCredentials jwtCustomToken(String jwtCustomToken) => + SyncCredentialsSecret._encode(_CredentialsType.jwtCustom, jwtCustomToken); +} + +class _SyncCredentialsNone extends SyncCredentials { + _SyncCredentialsNone._() : super._(_CredentialsType.none); +} + +/// Do not export, internal use only. +class SyncCredentialsSecret extends SyncCredentials { + /// UTF-8 encoded string. + final Uint8List data; + + SyncCredentialsSecret._(super.type, this.data) : super._(); + + SyncCredentialsSecret._encode(super.type, String data) + : data = Uint8List.fromList(utf8.encode(data)), + super._(); +} + +class _SyncCredentialsUserPassword extends SyncCredentials { + final String _user; + final String _password; + + _SyncCredentialsUserPassword._(super._type, this._user, this._password) + : super._(); +} + +enum SyncState { + unknown, + created, + started, + connected, + loggedIn, + disconnected, + stopped, + dead, +} + +enum SyncRequestUpdatesMode { manual, auto, autoNoPushes } + +enum SyncConnectionEvent { connected, disconnected } + +enum SyncLoginEvent { loggedIn, credentialsRejected, unknownError } + +class SyncChange { + final int entityId; + + final Type entity; + + final List puts; + + final List removals; + + SyncChange._(this.entityId, this.entity, this.puts, this.removals); +} + +class SyncClient { + SyncClient( + Store store, + List serverUrls, + List credentials, { + Map? filterVariables, + List? certificatePaths, + int? flags, + }) { + throwUnsupportedOnWeb(); + } + + void close() => throwUnsupportedOnWeb(); + + bool isClosed() => throwUnsupportedOnWeb(); + + static int protocolVersion() => throwUnsupportedOnWeb(); + + int protocolVersionServer() => throwUnsupportedOnWeb(); + + SyncState state() => throwUnsupportedOnWeb(); + + void putFilterVariable(String name, String value) => throwUnsupportedOnWeb(); + + void removeFilterVariable(String name) => throwUnsupportedOnWeb(); + + void removeAllFilterVariables() => throwUnsupportedOnWeb(); + + void applyFilterVariables() => throwUnsupportedOnWeb(); + + void setCredentials(SyncCredentials creds) => throwUnsupportedOnWeb(); + + void setMultipleCredentials(List credentials) => + throwUnsupportedOnWeb(); + + void setRequestUpdatesMode(SyncRequestUpdatesMode mode) => + throwUnsupportedOnWeb(); + + void start() => throwUnsupportedOnWeb(); + + void stop() => throwUnsupportedOnWeb(); + + bool triggerReconnect() => throwUnsupportedOnWeb(); + + bool requestUpdates({required bool subscribeForFuturePushes}) => + throwUnsupportedOnWeb(); + + bool cancelUpdates() => throwUnsupportedOnWeb(); + + int outgoingMessageCount({int limit = 0}) => throwUnsupportedOnWeb(); + + Stream get connectionEvents => throwUnsupportedOnWeb(); + + Stream get loginEvents => throwUnsupportedOnWeb(); + + Stream get completionEvents => throwUnsupportedOnWeb(); + + Stream> get changeEvents => throwUnsupportedOnWeb(); +} + +// Note: because in Dart can't have two classes exported with the same name, +// this class doubles as the annotation class (compare annotations.dart) and +// configuration class for Sync. +class Sync { + final bool sharedGlobalIds; + + const Sync({this.sharedGlobalIds = false}); + + /// Sync requires the native ObjectBox library, which is never available on + /// web, so this honestly answers `false` instead of throwing. + static bool isAvailable() => false; + + static int syncClockTimestamp(int syncClockValue) => throwUnsupportedOnWeb(); + + static int syncClockTimestampCorrected(int syncClockValue) => + throwUnsupportedOnWeb(); + + @Deprecated('Use the SyncClient constructor instead') + static SyncClient client( + Store store, + String serverUrl, + SyncCredentials credentials, { + Map? filterVariables, + List? certificatePaths, + int? flags, + }) => + throwUnsupportedOnWeb(); + + @Deprecated('Use the SyncClient constructor instead') + static SyncClient clientMultiCredentials( + Store store, + String serverUrl, + List credentials, { + Map? filterVariables, + List? certificatePaths, + int? flags, + }) => + throwUnsupportedOnWeb(); + + @Deprecated('Use the SyncClient constructor instead') + static SyncClient clientMultiUrls( + Store store, + List serverUrls, + SyncCredentials credentials, { + Map? filterVariables, + List? certificatePaths, + int? flags, + }) => + throwUnsupportedOnWeb(); + + @Deprecated('Use the SyncClient constructor instead') + static SyncClient clientMultiCredentialsMultiUrls( + Store store, + List serverUrls, + List credentials, { + Map? filterVariables, + List? certificatePaths, + int? flags, + }) => + throwUnsupportedOnWeb(); +} diff --git a/objectbox/lib/src/web/transaction.dart b/objectbox/lib/src/web/transaction.dart index 8b1378917..c9db7af1b 100644 --- a/objectbox/lib/src/web/transaction.dart +++ b/objectbox/lib/src/web/transaction.dart @@ -1 +1,32 @@ +// Web implementation of the internal Transaction, backed by the web engine's +// undo log. Mirrors the native protocol used by shared relation code: +// construct to begin, then successAndClose()/abortAndClose() exactly once. +// ignore_for_file: public_member_api_docs +import 'package:meta/meta.dart'; + +import '../transaction.dart' show TxMode; +import 'store.dart'; + +@internal +class Transaction { + final Store _store; + final TxMode mode; + bool _closed = false; + + Transaction(this._store, this.mode) { + InternalStoreAccess.engine(_store).beginTx(); + } + + void successAndClose() { + if (_closed) return; + _closed = true; + InternalStoreAccess.engine(_store).commitTx(); + } + + void abortAndClose() { + if (_closed) return; + _closed = true; + InternalStoreAccess.engine(_store).abortTx(); + } +} diff --git a/objectbox/lib/src/web/unsupported.dart b/objectbox/lib/src/web/unsupported.dart new file mode 100644 index 000000000..73f39cf26 --- /dev/null +++ b/objectbox/lib/src/web/unsupported.dart @@ -0,0 +1,7 @@ +/// Helper for the web platform stubs: every API that requires the native +/// ObjectBox library throws via this function until the web implementation +/// is available. +Never throwUnsupportedOnWeb() => throw UnsupportedError( + 'ObjectBox for web is not yet available: this API requires the native ' + 'ObjectBox library. Track progress at ' + 'https://github.com/objectbox/objectbox-dart/issues/185.'); diff --git a/objectbox/pubspec.yaml b/objectbox/pubspec.yaml index f8ed3cd0a..d8b1581e9 100644 --- a/objectbox/pubspec.yaml +++ b/objectbox/pubspec.yaml @@ -8,7 +8,8 @@ version: 5.3.2 environment: # minimum Dart SDK (also see generator and flutter_libs) - sdk: '>=2.17.0 <4.0.0' + # Note: 3.4 is required for dart:js_interop + package:web (web support). + sdk: '>=3.4.0 <4.0.0' dependencies: collection: ^1.15.0 @@ -17,6 +18,8 @@ dependencies: ffi: ^2.0.2 meta: ^1.3.0 path: ^1.8.0 + # Used by the web implementation only (IndexedDB via dart:js_interop). + web: '>=1.0.0 <2.0.0' dev_dependencies: ffigen: ^7.2.11 # v8 requires Dart 3, not requiring it, yet. diff --git a/web_test/README.md b/web_test/README.md new file mode 100644 index 000000000..b94e1ad9d --- /dev/null +++ b/web_test/README.md @@ -0,0 +1,17 @@ +# web_test + +Internal test package (not published) for the web implementation of ObjectBox +(`objectbox/lib/src/web/`): Store/Box CRUD, relations, transactions, +IndexedDB persistence and queries, running in a real browser. + +## Running + +```bash +dart pub get +dart run build_runner build +dart test -p chrome # dart2js +dart test -p chrome -c dart2wasm # WasmGC +``` + +Unlike `objectbox_test`, no native ObjectBox library is required: everything +runs in the browser against the IndexedDB-backed web engine. diff --git a/web_test/lib/models.dart b/web_test/lib/models.dart new file mode 100644 index 000000000..2c5752565 --- /dev/null +++ b/web_test/lib/models.dart @@ -0,0 +1,52 @@ +import 'package:objectbox/objectbox.dart'; + +@Entity() +class Person { + @Id() + int id = 0; + + @Index() + String name; + + @Unique() + String? email; + + int age; + double height; + bool active; + + @Property(type: PropertyType.date) + DateTime? birthday; + + List? tags; + + @HnswIndex(dimensions: 3) + @Property(type: PropertyType.floatVector) + List? embedding; + + final home = ToOne(); + final friends = ToMany(); + + Person( + {required this.name, + this.email, + this.age = 0, + this.height = 0, + this.active = true, + this.birthday, + this.tags, + this.embedding}); +} + +@Entity() +class House { + @Id() + int id = 0; + + String address; + + @Backlink('home') + final residents = ToMany(); + + House(this.address); +} diff --git a/web_test/lib/objectbox-model.json b/web_test/lib/objectbox-model.json new file mode 100644 index 000000000..31e525d29 --- /dev/null +++ b/web_test/lib/objectbox-model.json @@ -0,0 +1,111 @@ +{ + "_note1": "KEEP THIS FILE! Check it into a version control system (VCS) like git.", + "_note2": "ObjectBox manages crucial IDs for your object model. See docs for details.", + "_note3": "If you have VCS merge conflicts, you must resolve them according to ObjectBox docs.", + "entities": [ + { + "id": "1:8065201451255602221", + "lastPropertyId": "2:1461706767684152032", + "name": "House", + "properties": [ + { + "id": "1:5543085474876956288", + "name": "id", + "type": 6, + "flags": 1 + }, + { + "id": "2:1461706767684152032", + "name": "address", + "type": 9 + } + ], + "relations": [] + }, + { + "id": "2:1509584679383411412", + "lastPropertyId": "10:3149125948054504812", + "name": "Person", + "properties": [ + { + "id": "1:2019134487129476046", + "name": "id", + "type": 6, + "flags": 1 + }, + { + "id": "2:8159696557035257933", + "name": "name", + "indexId": "1:8054062858152216646", + "type": 9, + "flags": 2048 + }, + { + "id": "3:3747064935215419753", + "name": "email", + "indexId": "2:735137336050569932", + "type": 9, + "flags": 2080 + }, + { + "id": "4:9197722628477667401", + "name": "age", + "type": 6 + }, + { + "id": "5:1440792887867558826", + "name": "height", + "type": 8 + }, + { + "id": "6:8727853392050474493", + "name": "active", + "type": 1 + }, + { + "id": "7:1653777079098463846", + "name": "birthday", + "type": 10 + }, + { + "id": "8:8307979961781226714", + "name": "tags", + "type": 30 + }, + { + "id": "9:2382513394400378656", + "name": "embedding", + "indexId": "3:8113836271815613202", + "type": 28, + "flags": 8 + }, + { + "id": "10:3149125948054504812", + "name": "homeId", + "indexId": "4:7769754211749733029", + "type": 11, + "flags": 520, + "relationTarget": "House" + } + ], + "relations": [ + { + "id": "1:5224649451248911090", + "name": "friends", + "targetId": "2:1509584679383411412" + } + ] + } + ], + "lastEntityId": "2:1509584679383411412", + "lastIndexId": "4:7769754211749733029", + "lastRelationId": "1:5224649451248911090", + "lastSequenceId": "0:0", + "modelVersion": 5, + "modelVersionParserMinimum": 5, + "retiredEntityUids": [], + "retiredIndexUids": [], + "retiredPropertyUids": [], + "retiredRelationUids": [], + "version": 1 +} \ No newline at end of file diff --git a/web_test/pubspec.yaml b/web_test/pubspec.yaml new file mode 100644 index 000000000..46abbe9af --- /dev/null +++ b/web_test/pubspec.yaml @@ -0,0 +1,20 @@ +name: web_test +description: Browser tests for the web implementation of objectbox (see lib/src/web in the objectbox package). Run with `dart test -p chrome` (and `-c dart2wasm`). +publish_to: none + +environment: + sdk: ^3.7.0 + +dependencies: + objectbox: + path: ../objectbox + +dev_dependencies: + build_runner: ^2.4.0 + objectbox_generator: + path: ../generator + test: ^1.25.0 + +dependency_overrides: + objectbox: + path: ../objectbox diff --git a/web_test/test/web_engine_test.dart b/web_test/test/web_engine_test.dart new file mode 100644 index 000000000..7e967065c --- /dev/null +++ b/web_test/test/web_engine_test.dart @@ -0,0 +1,252 @@ +@TestOn('browser') +library; + +import 'dart:async'; + +import 'package:test/test.dart'; +import 'package:web_test/models.dart'; +import 'package:web_test/objectbox.g.dart'; + +var _dbCounter = 0; + +void main() { + group('in-memory engine', () { + late Store store; + late Box box; + late Box houses; + + setUp(() async { + store = openStore(directory: 'memory:t${_dbCounter++}'); + await store.ready; + box = store.box(); + houses = store.box(); + }); + + tearDown(() => store.close()); + + test('put/get roundtrip preserves all property types', () { + final person = Person( + name: 'Ada', + email: 'ada@web.dev', + age: 36, + height: 1.7025, + active: true, + birthday: DateTime.fromMillisecondsSinceEpoch(478915200000), + tags: ['math', 'web'], + embedding: [1, 2, 3]); + final id = box.put(person); + expect(id, 1); + expect(person.id, 1); + + final read = box.get(1)!; + expect(read.name, 'Ada'); + expect(read.email, 'ada@web.dev'); + expect(read.age, 36); + expect(read.height, 1.7025); + expect(read.active, isTrue); + expect(read.birthday!.millisecondsSinceEpoch, 478915200000); + expect(read.tags, ['math', 'web']); + expect(read.embedding, [1.0, 2.0, 3.0]); + }); + + test('ids are assigned sequentially and survive updates', () { + final a = box.put(Person(name: 'a')); + final b = box.put(Person(name: 'b')); + expect([a, b], [1, 2]); + final bObj = box.get(b)!..name = 'b2'; + expect(box.put(bObj), b); + expect(box.get(b)!.name, 'b2'); + expect(box.put(Person(name: 'c')), 3); + }); + + test('PutMode insert/update are enforced', () { + final id = box.put(Person(name: 'x')); + expect( + () => box.put(box.get(id)!, mode: PutMode.insert), throwsA(anything)); + final fresh = Person(name: 'y')..id = 999; + expect(() => box.put(fresh, mode: PutMode.update), throwsA(anything)); + expect(box.count(), 1); + }); + + test('unique constraint is enforced', () { + box.put(Person(name: 'a', email: 'same@x.io')); + expect(() => box.put(Person(name: 'b', email: 'same@x.io')), + throwsA(predicate((e) => '$e'.contains('Unique')))); + // updating the same object keeps its unique value + final a = box.getAll().first..name = 'a2'; + expect(() => box.put(a), returnsNormally); + // after removal the value is free again + box.remove(a.id); + expect(() => box.put(Person(name: 'c', email: 'same@x.io')), + returnsNormally); + }); + + test('getAll/count/contains/remove family', () { + final ids = box.putMany( + [Person(name: 'a'), Person(name: 'b'), Person(name: 'c')]); + expect(ids, [1, 2, 3]); + expect(box.count(), 3); + expect(box.count(limit: 2), 2); + expect(box.isEmpty(), isFalse); + expect(box.contains(2), isTrue); + expect(box.containsMany([1, 3]), isTrue); + expect(box.containsMany([1, 4]), isFalse); + expect(box.getAll().map((p) => p.name), ['a', 'b', 'c']); + expect(box.getMany([3, 99, 1]).map((p) => p?.name), ['c', null, 'a']); + expect(box.remove(2), isTrue); + expect(box.remove(2), isFalse); + expect(box.removeMany([1, 99]), 1); + expect(box.removeAll(), 1); + expect(box.isEmpty(), isTrue); + // ids are not reused after removal + expect(box.put(Person(name: 'd')), 4); + }); + + test('async variants work', () async { + final id = await box.putAsync(Person(name: 'async')); + expect((await box.getAsync(id))!.name, 'async'); + expect((await box.getAllAsync()).length, 1); + expect(await box.removeAsync(id), isTrue); + }); + + test('ToOne target is put automatically and lazily loaded', () { + final person = Person(name: 'resident'); + person.home.target = House('Web St. 185'); + box.put(person); + expect(person.home.targetId, 1); + expect(houses.count(), 1); + + final read = box.get(person.id)!; + expect(read.home.target!.address, 'Web St. 185'); + }); + + test('ToOne backlink resolves', () { + final house = House('Backlink Ave.'); + final p1 = Person(name: 'p1')..home.target = house; + box.put(p1); + final p2 = Person(name: 'p2')..home.targetId = house.id; + box.put(p2); + + final read = houses.get(house.id)!; + expect(read.residents.map((p) => p.name).toSet(), {'p1', 'p2'}); + }); + + test('ToMany relation put, read and remove', () { + final a = Person(name: 'a'); + final b = Person(name: 'b'); + final c = Person(name: 'c'); + box.putMany([b, c]); + a.friends.addAll([b, c]); + box.put(a); + + var read = box.get(a.id)!; + expect(read.friends.map((p) => p.name).toSet(), {'b', 'c'}); + + read.friends.removeWhere((p) => p.name == 'b'); + read.friends.applyToDb(); + read = box.get(a.id)!; + expect(read.friends.map((p) => p.name).toSet(), {'c'}); + + // removing the target cleans up the relation + box.remove(c.id); + read = box.get(a.id)!; + expect(read.friends, isEmpty); + }); + + test('runInTransaction rolls back on error', () { + box.put(Person(name: 'keep', email: 'keep@x.io')); + expect( + () => store.runInTransaction(TxMode.write, () { + box.put(Person(name: 'gone1')); + box.put(Person(name: 'gone2')); + throw StateError('boom'); + }), + throwsStateError); + expect(box.count(), 1); + expect(box.getAll().single.name, 'keep'); + // the id sequence was rolled back too + expect(box.put(Person(name: 'next')), 2); + }); + + test('failed put inside relations transaction rolls back cleanly', () { + box.put(Person(name: 'a', email: 'dup@x.io')); + final person = Person(name: 'b', email: 'dup@x.io'); + person.home.target = House('never stored'); + expect(() => box.put(person), throwsA(anything)); + // the house put through the ToOne was rolled back with the transaction + expect(houses.isEmpty(), isTrue); + expect(box.count(), 1); + }); + + test('watch and entityChanges emit on commit', () async { + final events = >[]; + final sub = store.entityChanges.listen(events.add); + final personEvents = []; + final sub2 = store.watch().listen(personEvents.add); + + box.put(Person(name: 'w')); + houses.put(House('h')); + await Future.delayed(Duration.zero); + + expect(events.length, 2); + expect(events[0], [Person]); + expect(events[1], [House]); + expect(personEvents.length, 1); + await sub.cancel(); + await sub2.cancel(); + }); + + test('store registry: double open throws, attach shares data', () { + expect(() => openStore(directory: store.directoryPath), + throwsA(predicate((e) => '$e'.contains('still open')))); + expect(Store.isOpen(store.directoryPath), isTrue); + final attached = + Store.attach(getObjectBoxModel(), store.directoryPath); + box.put(Person(name: 'shared')); + expect(attached.box().count(), 1); + attached.close(); + // closing the attached handle must not close the underlying engine + expect(box.count(), 1); + }); + }); + + group('IndexedDB persistence', () { + test('data, relations and id sequence survive close and reopen', () async { + final dir = 'idb-test-${DateTime.now().millisecondsSinceEpoch}'; + + var store = openStore(directory: dir); + await store.ready; + var box = store.box(); + final ada = Person( + name: 'Ada', email: 'ada@x.io', tags: ['persisted'], age: 36); + ada.home.target = House('IDB Lane 1'); + final friend = Person(name: 'Friend'); + box.put(friend); + ada.friends.add(friend); + box.put(ada); + box.remove(box.put(Person(name: 'temp'))); // consume an id + store.close(); + + store = openStore(directory: dir); + await store.ready; + box = store.box(); + expect(box.count(), 2); + final readAda = + box.getAll().singleWhere((person) => person.name == 'Ada'); + expect(readAda.email, 'ada@x.io'); + expect(readAda.tags, ['persisted']); + expect(readAda.home.target!.address, 'IDB Lane 1'); + expect(readAda.friends.map((p) => p.name), ['Friend']); + // unique index was rebuilt from persisted data + expect(() => box.put(Person(name: 'clone', email: 'ada@x.io')), + throwsA(predicate((e) => '$e'.contains('Unique')))); + // the id sequence continues after the consumed id + expect(box.put(Person(name: 'new')), 4); + + store.close(); + // allow the connection to close, then clean up + await Future.delayed(const Duration(milliseconds: 50)); + Store.removeDbFiles(dir); + }); + }); +} diff --git a/web_test/test/web_query_test.dart b/web_test/test/web_query_test.dart new file mode 100644 index 000000000..0dd0d6ba1 --- /dev/null +++ b/web_test/test/web_query_test.dart @@ -0,0 +1,320 @@ +@TestOn('browser') +library; + +import 'dart:async'; + +import 'package:test/test.dart'; +import 'package:web_test/models.dart'; +import 'package:web_test/objectbox.g.dart'; + +var _dbCounter = 0; + +void main() { + late Store store; + late Box box; + late Box houses; + + setUp(() async { + store = openStore(directory: 'memory:q${_dbCounter++}'); + await store.ready; + box = store.box(); + houses = store.box(); + box.putMany([ + Person(name: 'Ada', email: 'ada@x.io', age: 36, height: 1.70), + Person(name: 'Grace', email: 'grace@x.io', age: 85, height: 1.65), + Person(name: 'alan', age: 41, height: 1.80, tags: ['logic', 'cs']), + Person( + name: 'Barbara', + age: 36, + height: 1.60, + active: false, + tags: ['cs'], + birthday: DateTime.fromMillisecondsSinceEpoch(100000)), + ]); + }); + + tearDown(() => store.close()); + + List names(Query q) => + q.find().map((p) => p.name).toList(); + + test('string conditions with case sensitivity', () { + expect(names(box.query(Person_.name.equals('Ada')).build()), ['Ada']); + expect(names(box.query(Person_.name.equals('ada')).build()), isEmpty); + expect( + names(box + .query(Person_.name.equals('ada', caseSensitive: false)) + .build()), + ['Ada']); + expect( + names(box + .query(Person_.name.startsWith('a', caseSensitive: false)) + .build()), + ['Ada', 'alan']); + expect(names(box.query(Person_.name.startsWith('a')).build()), ['alan']); + expect( + names(box + .query(Person_.name.contains('ra', caseSensitive: true)) + .build()), + ['Grace', 'Barbara']); + expect( + names(box.query(Person_.name.oneOf(['Ada', 'Grace'])).build()), + ['Ada', 'Grace']); + expect(names(box.query(Person_.name.notEquals('Ada')).build()), + ['Grace', 'alan', 'Barbara']); + }); + + test('integer and double conditions', () { + expect(names(box.query(Person_.age.equals(36)).build()), + ['Ada', 'Barbara']); + expect(names(box.query(Person_.age.between(40, 90)).build()), + ['Grace', 'alan']); + expect(names(box.query(Person_.age.oneOf([41, 85])).build()), + ['Grace', 'alan']); + expect(names(box.query(Person_.age.notOneOf([36])).build()), + ['Grace', 'alan']); + expect(names(box.query(Person_.age > 40).build()), ['Grace', 'alan']); + expect(names(box.query(Person_.height.between(1.58, 1.66)).build()), + ['Grace', 'Barbara']); + expect(names(box.query(Person_.height.lessThan(1.66)).build()), + ['Grace', 'Barbara']); + }); + + test('bool, date and null conditions', () { + expect(names(box.query(Person_.active.equals(false)).build()), + ['Barbara']); + expect(names(box.query(Person_.email.isNull()).build()), + ['alan', 'Barbara']); + expect(names(box.query(Person_.email.notNull()).build()), + ['Ada', 'Grace']); + expect( + names(box + .query(Person_.birthday + .equalsDate(DateTime.fromMillisecondsSinceEpoch(100000))) + .build()), + ['Barbara']); + // null birthday never matches a value condition + expect( + names(box + .query(Person_.birthday + .lessThanDate(DateTime.fromMillisecondsSinceEpoch(1))) + .build()), + isEmpty); + }); + + test('string vector containsElement', () { + expect(names(box.query(Person_.tags.containsElement('cs')).build()), + ['alan', 'Barbara']); + expect(names(box.query(Person_.tags.containsElement('logic')).build()), + ['alan']); + }); + + test('and/or composition', () { + expect( + names(box + .query(Person_.age.equals(36) & Person_.active.equals(true)) + .build()), + ['Ada']); + expect( + names(box + .query(Person_.name.equals('Ada') | Person_.name.equals('alan')) + .build()), + ['Ada', 'alan']); + expect( + names(box + .query(Person_.age + .equals(36) + .andAll([Person_.active.equals(true)]).or( + Person_.name.equals('Grace'))) + .build()), + ['Ada', 'Grace']); + }); + + test('order, offset, limit', () { + var q = box.query().order(Person_.age).build(); + expect(names(q), ['Ada', 'Barbara', 'alan', 'Grace']); + + q = box.query().order(Person_.age, flags: Order.descending).build(); + expect(names(q), ['Grace', 'alan', 'Ada', 'Barbara']); + + // string order is case-insensitive by default + q = box.query().order(Person_.name).build(); + expect(names(q), ['Ada', 'alan', 'Barbara', 'Grace']); + q = box.query().order(Person_.name, flags: Order.caseSensitive).build(); + expect(names(q), ['Ada', 'Barbara', 'Grace', 'alan']); + + // nulls (email) first by default, last with nullsLast + q = box.query().order(Person_.email).build(); + expect(names(q).sublist(0, 2), ['alan', 'Barbara']); + q = box.query().order(Person_.email, flags: Order.nullsLast).build(); + expect(names(q).sublist(2), ['alan', 'Barbara']); + + final paged = box.query().order(Person_.age).build() + ..offset = 1 + ..limit = 2; + expect(names(paged), ['Barbara', 'alan']); + }); + + test('findFirst/findUnique/findIds/count/remove/stream', () async { + expect(box.query(Person_.age.equals(36)).build().count(), 2); + expect( + box + .query(Person_.name.equals('Ada')) + .build() + .findUnique()! + .email, + 'ada@x.io'); + expect(() => box.query(Person_.age.equals(36)).build().findUnique(), + throwsA(predicate((e) => '$e'.contains('more than one')))); + expect(box.query(Person_.age.equals(36)).build().findFirst()!.name, 'Ada'); + expect(box.query(Person_.age.equals(36)).build().findIds(), [1, 4]); + expect(await box.query(Person_.age.equals(36)).build().stream().length, 2); + + final removed = box.query(Person_.age.equals(36)).build().remove(); + expect(removed, 2); + expect(box.count(), 2); + }); + + test('property queries: aggregate, distinct, find', () { + final q = box.query().build(); + expect(q.property(Person_.age).min(), 36); + expect(q.property(Person_.age).max(), 85); + expect(q.property(Person_.age).sum(), 36 + 85 + 41 + 36); + expect(q.property(Person_.age).average(), (36 + 85 + 41 + 36) / 4); + expect(q.property(Person_.age).count(), 4); + expect((q.property(Person_.age)..distinct = true).count(), 3); + expect(q.property(Person_.name).find(), + ['Ada', 'Grace', 'alan', 'Barbara']); + // nulls are skipped, or replaced when requested + expect(q.property(Person_.email).count(), 2); + expect(q.property(Person_.email).find(replaceNullWith: '-'), + ['ada@x.io', 'grace@x.io', '-', '-']); + // property queries ignore offset/limit + final limited = box.query().build()..limit = 1; + expect(limited.property(Person_.age).count(), 4); + }); + + test('query parameters via property and alias', () { + final q = box + .query(Person_.name.equals('none', alias: 'n') & + Person_.age.greaterThan(0)) + .build(); + expect(q.count(), 0); + q.param(Person_.name, alias: 'n').value = 'Grace'; + expect(names(q), ['Grace']); + q.param(Person_.age).value = 50; + expect(names(q), ['Grace']); + q.param(Person_.name, alias: 'n').value = 'Ada'; + expect(q.count(), 0); // Ada is not > 50 + expect(() => q.param(Person_.height), throwsArgumentError); + }); + + test('links: toOne, backlink, toMany, backlinkMany, relationCount', () { + final h1 = House('Baker Street'); + final h2 = House('Main Road'); + final ada = box.get(1)!..home.target = h1; + final grace = box.get(2)!..home.target = h2; + box.putMany([ada, grace]); + final alan = box.get(3)!; + alan.friends.addAll([ada, grace]); + box.put(alan); + final barbara = box.get(4)!; + barbara.friends.add(ada); + box.put(barbara); + + // toOne link with condition on the target + var q = (box.query()..link(Person_.home, House_.address.startsWith('Baker'))) + .build(); + expect(names(q), ['Ada']); + + // backlink: houses whose residents include someone aged > 50 + var hq = + (houses.query()..backlink(Person_.home, Person_.age.greaterThan(50))) + .build(); + expect(hq.find().map((h) => h.address), ['Main Road']); + + // toMany link: persons with a friend named Grace + q = (box.query()..linkMany(Person_.friends, Person_.name.equals('Grace'))) + .build(); + expect(names(q), ['alan']); + + // backlinkMany: persons who are a friend of Barbara + q = (box.query() + ..backlinkMany(Person_.friends, Person_.name.equals('Barbara'))) + .build(); + expect(names(q), ['Ada']); + + // relationCount on the ToOne backlink: houses with exactly one resident + hq = houses.query(House_.residents.relationCount(1)).build(); + expect(hq.find().map((h) => h.address).toSet(), + {'Baker Street', 'Main Road'}); + hq = houses.query(House_.residents.relationCount(0)).build(); + expect(hq.count(), 0); + }); + + test('nearest neighbors (brute force) with scores', () { + box.removeAll(); + box.putMany([ + Person(name: 'origin', embedding: [0, 0, 0]), + Person(name: 'near', embedding: [0.1, 0, 0]), + Person(name: 'far', embedding: [3, 4, 0]), + Person(name: 'no-vector'), + ]); + + final q = box + .query(Person_.embedding.nearestNeighborsF32([0, 0, 0], 2)) + .build(); + expect(names(q), ['origin', 'near']); + + final scored = q.findWithScores(); + expect(scored.first.object.name, 'origin'); + expect(scored.first.score, 0); + expect(scored[1].object.name, 'near'); + expect(scored[1].score, closeTo(0.01, 1e-9)); + + final ids = q.findIdsWithScores(); + expect(ids.length, 2); + expect(ids.first.score, 0); + + // combined with a filter condition + final q2 = box + .query(Person_.embedding.nearestNeighborsF32([0, 0, 0], 3) & + Person_.name.notEquals('origin')) + .build(); + expect(names(q2), ['near', 'far']); + + // scores require a nearest-neighbor query + expect(() => box.query().build().findWithScores(), throwsStateError); + + // updating the query vector via param + q.param(Person_.embedding).nearestNeighborsF32([3, 4, 0], 1); + expect(names(q), ['far']); + }); + + test('watch emits on relevant changes', () async { + final results = []; + final sub = box + .query(Person_.age.greaterThan(50)) + .watch(triggerImmediately: true) + .listen((query) => results.add(query.count())); + await Future.delayed(Duration.zero); + expect(results, [1]); // Grace + + box.put(Person(name: 'Old', age: 99)); + await Future.delayed(Duration.zero); + expect(results, [1, 2]); + + houses.put(House('unrelated')); // different entity: no emission + await Future.delayed(Duration.zero); + expect(results, [1, 2]); + await sub.cancel(); + }); + + test('describe', () { + final q = box.query(Person_.age.equals(36)).build(); + expect(q.describe(), contains('Person')); + expect(q.describeParameters(), contains('age')); + expect(q.entityId, greaterThan(0)); + q.close(); + }); +} diff --git a/web_test/test/web_unsupported_api_test.dart b/web_test/test/web_unsupported_api_test.dart new file mode 100644 index 000000000..33a5b57a4 --- /dev/null +++ b/web_test/test/web_unsupported_api_test.dart @@ -0,0 +1,28 @@ +@TestOn('browser') +library; + +import 'package:test/test.dart'; +import 'package:web_test/models.dart'; +import 'package:web_test/objectbox.g.dart'; + +void main() { + test('generated model + query property statics load without native lib', () { + expect(Person_.name, isNotNull); + expect(Person_.embedding, isNotNull); + expect(House_.address, isNotNull); + expect(getObjectBoxModel(), isNotNull); + }); + + test('remaining unsupported APIs throw', () async { + final store = openStore(directory: 'memory:stub-test'); + await store.ready; + expect(() => store.reference, throwsUnsupportedError); + store.close(); + }); + + test('availability checks are false, not throwing', () { + expect(Admin.isAvailable(), isFalse); + expect(Sync.isAvailable(), isFalse); + expect(Store.isOpen('never-opened'), isFalse); + }); +}