blob: 85e1350eed767c3a119bbfb0fdf37f95f6810d3e [file] [log] [blame] [edit]
// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:collection';
import 'dart:convert';
import 'dart:typed_data';
/// Map with lazily evaluated values.
///
/// This is the efficient way to accumulate data into a [JsonBuffer]. Nested
/// maps should also be `LazyMap`.
class LazyMap with MapMixin<String, Object?> implements Map<String, Object?> {
final List<String> _keys;
final Object? Function(String) lookup;
LazyMap(Iterable<String> keys, this.lookup) : _keys = keys.toList();
const LazyMap.empty()
: _keys = const [],
lookup = _emptyLookup;
/// So we can create a const empty map.
static Object? _emptyLookup(_) => null;
@override
Iterable<String> get keys => _keys;
@override
Object? operator [](Object? key) {
if (key is! String) {
throw ArgumentError('Only String keys are allowed, got: $key');
}
return lookup(key);
}
@override
void operator []=(String key, Object? value) =>
throw UnsupportedError('LazyMap is immutable.');
@override
void clear() => throw UnsupportedError('LazyMap is immutable.');
@override
Object? remove(Object? key) =>
throw UnsupportedError('LazyMap is immutable.');
}
/// Bytebuffer-backed JSON data.
///
/// Data can be accumulated directly into the buffer which is then immediately
/// ready to write to the wire. On the receiving side, the data can be
/// accessed directly as a `Map<String, Object?>` without copying out of the
/// buffer.
///
/// The constructor takes a [Map], but passing in a normal `Map` _does_
/// require copying. Instead, instantiate with a [LazyMap]. The lazy map will
/// then be evaluated into the buffer. Nested maps in the `LazyMap` must _also_
/// use `LazyMap` otherwise it will still be necessary to copy those.
//
// TODO(davidmorgan): check if it's worth adding a `LazyList` for the same
// reason.
// TODO(davidmorgan): this is just a proof of concept to make sure `dart_model`
// is compatible with fast JSON. Write up a design for discussion, complete
// the implementation.
class JsonBuffer {
// TODO(davidmorgan): _seenStrings and _decodedStrings are some simple
// optimizations; complete the design and implementation.
final Map<String, _Pointer> _seenStrings = {};
final Map<_Pointer, String> _decodedStrings = {};
/// The JSON data.
// TODO(davidmorgan): what's a good initial size?
// TODO(davidmorgan): maybe use `BytesBuilder`?
Uint8List _buffer = Uint8List(32);
/// The next free location to write to in the buffer.
int _nextFree = 0;
/// Instantiates a buffer holding [map].
JsonBuffer(Map<String, Object?> map) {
_addMap(map);
}
/// Immutable `Map` view of the JSON data.
late final Map<String, Object?> asMap = _JsonBufferMap._(this, 0);
/// Instantiates using previously-serialized data.
///
/// The buffer is _not_ copied, unpredictable behavior will result if it is
/// mutated.
JsonBuffer.deserialize(this._buffer) : _nextFree = _buffer.length;
/// The JSON data.
///
/// The buffer is _not_ copied, unpredictable behavior will result if it is
/// mutated.
Uint8List serialize() => Uint8List.sublistView(_buffer, 0, _nextFree);
/// Adds a `Map` to the buffer.
void _addMap(Map<String, Object?> map) {
List<String> keys;
Object? Function(String) lookup;
if (map is LazyMap) {
keys = map._keys;
lookup = map.lookup;
} else {
keys = map.keys.toList();
lookup = map.lookup;
}
_evaluateAndAddMap(keys, lookup);
}
/// Adds a `Map` to the buffer.
///
/// The `Map` is represented as a `List` of keys and a lookup function, to
/// allow values to be written directly rather than copied.
void _evaluateAndAddMap(List<String> keys, Object? Function(String) lookup) {
// Maps are stored as:
//
// [size, pointer to key 1, pointer to value 1, pointer to key 2,
// pointer to value 2, ...]
//
// The size is immediately known, so reserve space for the map, allowing
// full keys and values to be appended afterwards in any order.
final length = keys.length;
final start = _nextFree;
_reserve(length * _intSize * 2 + _intSize);
_writeInt(start, _intSize, length);
// Now iterate computing values. The keys and values are appended to the
// buffer, and pointers to them written into the space that was reserved.
for (var i = 0; i != length; ++i) {
final key = keys[i];
_writeInt(start + _intSize + i * _intSize * 2, _intSize, _addString(key));
final value = lookup(key);
_writeInt(start + _intSize + i * _intSize * 2 + _intSize, _intSize,
_addValue(value));
}
}
/// Reserves [bytes] number of bytes.
///
/// Increases `_nextFree` accordingly. Expands the buffer if necessary.
void _reserve(int bytes) {
_nextFree += bytes;
while (_nextFree > _buffer.length) {
// TODO(davidmorgan): pass desired size to avoid multiple copies.
_expand();
}
}
/// Copies into a new buffer that's twice as big.
void _expand() {
final oldBuffer = _buffer;
_buffer = Uint8List(_buffer.length * 2);
_buffer.setRange(0, oldBuffer.length, oldBuffer);
}
/// Adds a value of unknown type.
///
/// So, writes the type then the value.
_Pointer _addValue(Object? value) {
final start = _nextFree;
if (value is String) {
_reserve(_typeSize);
_buffer[start] = Type.string.index;
// Strings are stored with an extra indirection to allow deduping.
_reserve(_intSize);
_writeInt(start + 1, _intSize, _addString(value));
return start;
} else if (value is bool) {
_reserve(_typeSize);
_buffer[start] = Type.bool.index;
_addBool(value);
return start;
} else if (value is Map<String, Object?>) {
_reserve(_typeSize);
_buffer[start] = Type.map.index;
_addMap(value);
return start;
} else {
throw UnsupportedError('Unsupported value type: ${value.runtimeType}');
}
}
/// Adds a `String`, returns a [_Pointer] to it.
///
/// If the `String` has been seen before the previously stored value is
/// reused and its `_Pointer` returned.
_Pointer _addString(String value) {
final maybeResult = _seenStrings[value];
if (maybeResult != null) return maybeResult;
final start = _nextFree;
// TODO(davidmorgan): it might be faster to write directly into the buffer.
final bytes = utf8.encode(value);
final length = bytes.length;
_reserve(_intSize + length);
_writeInt(start, _intSize, length);
_buffer.setRange(start + _intSize, start + _intSize + length, bytes);
_seenStrings[value] = start;
return start;
}
/// Adds a `bool`.
_Pointer _addBool(bool value) {
final start = _nextFree;
_reserve(1);
_buffer[start] = value ? 1 : 0;
return start;
}
/// Adds an integer.
///
/// TODO(davidomorgan): variable size ints don't easily work with the need to
/// know map sizes in advance. Picking a fixed max size seems like an
/// unwanted limitation. Do better!
void _writeInt(_Pointer pointer, int intSize, int value) {
if (intSize == 1) {
_buffer[pointer] = value;
} else if (intSize == 2) {
_buffer[pointer] = value & 0xff;
_buffer[pointer + 1] = (value >> 8) & 0xff;
} else if (intSize == 3) {
_buffer[pointer] = value & 0xff;
_buffer[pointer + 1] = (value >> 8) & 0xff;
_buffer[pointer + 2] = (value >> 16) & 0xff;
} else if (intSize == 4) {
_buffer[pointer] = value & 0xff;
_buffer[pointer + 1] = (value >> 8) & 0xff;
_buffer[pointer + 2] = (value >> 16) & 0xff;
_buffer[pointer + 3] = (value >> 24) & 0xff;
} else {
throw UnsupportedError('Integer size: $intSize');
}
}
/// Reads the integer at [_Pointer].
int _readInt(_Pointer pointer, int intSize) {
if (intSize == 1) {
return _buffer[pointer];
} else if (intSize == 2) {
return _buffer[pointer] + (_buffer[pointer + 1] << 8);
} else if (intSize == 3) {
return _buffer[pointer] +
(_buffer[pointer + 1] << 8) +
(_buffer[pointer + 2] << 16);
} else if (intSize == 4) {
return _buffer[pointer] +
(_buffer[pointer + 1] << 8) +
(_buffer[pointer + 2] << 16) +
(_buffer[pointer + 3] << 24);
} else {
throw UnsupportedError('Integer size: $intSize');
}
}
/// Reads the value of unknown type at [_Pointer].
Object? _readValue(_Pointer pointer) {
final type = Type.values[_buffer[pointer]];
switch (type) {
case Type.string:
return _readString(_readPointer(pointer + _typeSize));
case Type.bool:
return _readBool(pointer + _typeSize);
case Type.map:
return _JsonBufferMap._(this, pointer + _typeSize);
}
}
/// Reads the `_Pointer` at [_Pointer].
_Pointer _readPointer(_Pointer pointer) {
return _readInt(pointer, _intSize);
}
/// Reads the `String` at [_Pointer].
String _readString(_Pointer pointer) {
final maybeResult = _decodedStrings[pointer];
if (maybeResult != null) return maybeResult;
final length = _readInt(pointer, _intSize);
return _decodedStrings[pointer] ??= utf8.decode(
_buffer.sublist(pointer + _intSize, pointer + _intSize + length));
}
/// Reads the `bool` at [_Pointer].
bool _readBool(_Pointer pointer) {
final value = _buffer[pointer];
if (value == 1) return true;
if (value == 0) return false;
throw StateError('Unexpected bool value: $value');
}
@override
String toString() => _buffer.toString();
}
/// Immutable `Map` view into a [JsonBuffer].
class _JsonBufferMap
with MapMixin<String, Object?>
implements Map<String, Object?> {
final JsonBuffer _buffer;
final _Pointer _pointer;
_JsonBufferMap._(this._buffer, this._pointer);
@override
Object? operator [](Object? key) {
final iterator = entries.iterator as _JsonBufferMapEntryIterator;
// TODO(davidmorgan): for small maps this is probably already efficient
// enough. Do something better for large maps, for example sorting keys
// and binary search?
while (iterator.moveNext()) {
if (iterator.current.key == key) return iterator.current.value;
}
return null;
}
@override
late Iterable<String> keys =
_JsonBufferMapEntryIterable(_buffer, _pointer, readValues: false)
.map((e) => e.key);
@override
late Iterable<Object?> values =
_JsonBufferMapEntryIterable(_buffer, _pointer, readKeys: false)
.map((e) => e.value);
@override
late Iterable<MapEntry<String, Object?>> entries =
_JsonBufferMapEntryIterable(_buffer, _pointer);
@override
void operator []=(String key, Object? value) {
throw UnsupportedError('JsonBufferMap is readonly.');
}
@override
void clear() {
throw UnsupportedError('JsonBufferMap is readonly.');
}
@override
Object? remove(Object? key) {
throw UnsupportedError('JsonBufferMap is readonly.');
}
}
/// `Iterable` that reads a `Map` in a [JsonBuffer].
class _JsonBufferMapEntryIterable
with IterableMixin<MapEntry<String, Object?>>
implements Iterable<MapEntry<String, Object?>> {
final JsonBuffer _buffer;
final _Pointer _pointer;
final bool readKeys;
final bool readValues;
_JsonBufferMapEntryIterable(this._buffer, this._pointer,
{this.readKeys = true, this.readValues = true});
@override
Iterator<MapEntry<String, Object?>> get iterator =>
_JsonBufferMapEntryIterator(_buffer, _pointer,
readKeys: readKeys, readValues: readValues);
}
/// `Iterator` that reads a `Map` in a [JsonBuffer].
///
/// TODO(davidmorgan): refactor away from the awkward `readKeys`/`readValues`.
class _JsonBufferMapEntryIterator
implements Iterator<MapEntry<String, Object?>> {
final JsonBuffer _buffer;
_Pointer _pointer;
final _Pointer _last;
final bool readKeys;
final bool readValues;
_JsonBufferMapEntryIterator(this._buffer, _Pointer pointer,
{this.readKeys = true, this.readValues = true})
: _last = pointer +
_intSize +
_buffer._readInt(pointer, _intSize) * 2 * _intSize,
_pointer = pointer - _intSize;
@override
MapEntry<String, Object?> get current => MapEntry(
readKeys ? _buffer._readString(_buffer._readPointer(_pointer)) : '',
readValues
? _buffer._readValue(_buffer._readPointer(_pointer + _intSize))
: null);
@override
bool moveNext() {
if (_pointer == _last) return false;
if (_pointer > _last) throw StateError('Moved past _last!');
_pointer += _intSize * 2;
return _pointer != _last;
}
}
/// Pointer into a [JsonBuffer].
typedef _Pointer = int;
/// Type of a value in a [JsonBuffer].
enum Type {
string,
bool,
map,
}
/// Bytes needed by [Type].
final _typeSize = 1;
/// Bytes for each int.
final _intSize = 4;
extension _MapExtensions<K, V> on Map<K, V> {
V? lookup(Object? key) => this[key];
}