Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions wurst/data/IntMap.wurst
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package IntMap
import NoWurst
import ArrayList
import Table

/**
* O(1) integer-keyed map with compiler-specialized value storage.
*
* JASS hashtables use integer child keys, so fixing the key type avoids an artificial generic
* hash adapter while `V:` keeps strings, reals, booleans, handles, tuples, and class references
* in typed arrays. Removal is unordered. The map does not own stored values.
*/
public class IntMap<V:>
private let keys = new ArrayList<int>()
private let values = new ArrayList<V>()
private let indexByKey = new Table()

/** Returns whether a value exists under the given key. */
function has(int key) returns boolean
return indexByKey.loadInt(key) != 0

/** Inserts or replaces a value. Existing key order is retained. */
function put(int key, V value)
let stored = indexByKey.loadInt(key)
if stored != 0
values.set(stored - 1, value)
return

keys.add(key)
values.add(value)
indexByKey.saveInt(key, keys.size())

/** Returns the stored value, or the type's null/default value when absent. */
function get(int key) returns V
let stored = indexByKey.loadInt(key)
if stored == 0
return null
return values.get(stored - 1)

/** Removes a key/value pair and returns whether it was present. */
function remove(int key) returns boolean
let stored = indexByKey.loadInt(key)
if stored == 0
return false

removeAt(stored - 1)
return true

/** Retrieves a value and removes its key/value pair. */
function getAndRemove(int key) returns V
let stored = indexByKey.loadInt(key)
if stored == 0
return null

let value = values.get(stored - 1)
removeAt(stored - 1)
return value

/** Removes every entry while retaining allocated list capacity. */
function clear()
keys.clear()
values.clear()
indexByKey.flush()

/** Returns the number of entries. */
function size() returns int
return keys.size()

/** Returns whether the map is empty. */
function isEmpty() returns boolean
return keys.isEmpty()

/** Returns a key by dense index. Removal may change this order. */
function keyAt(int index) returns int
return keys.get(index)

/** Returns a value by dense index. Removal may change this order. */
function valueAt(int index) returns V
return values.get(index)

private function removeAt(int index)
let removedKey = keys.get(index)
let lastIndex = keys.size() - 1

if index != lastIndex
let movedKey = keys.get(lastIndex)
indexByKey.saveInt(movedKey, index + 1)

keys.removeAtUnordered(index)
values.removeAtUnordered(index)
indexByKey.removeInt(removedKey)

ondestroy
destroy keys
destroy values
destroy indexByKey
70 changes: 70 additions & 0 deletions wurst/data/IntMapTests.wurst
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package IntMapTests
import IntMap

class IntMapTestValue
int value

construct(int value)
this.value = value

tuple IntMapTestTuple(int number, string label)

@Test
function storesSpecializedPrimitiveValues()
let strings = new IntMap<string>()
strings.put(1, "one")
strings.put(2, "two")

strings.get(1).assertEquals("one")
strings.get(2).assertEquals("two")
(strings.get(3) == null).assertTrue()
destroy strings

@Test
function insertsReplacesAndRemovesValues()
let map = new IntMap<IntMapTestValue>()
let first = new IntMapTestValue(1)
let replacement = new IntMapTestValue(2)
let moved = new IntMapTestValue(3)

map.put(10, first)
map.put(20, moved)
map.put(10, replacement)
map.size().assertEquals(2)
(map.get(10) == replacement).assertTrue()

map.remove(10).assertTrue()
map.remove(10).assertFalse()
map.size().assertEquals(1)
(map.get(20) == moved).assertTrue()
map.keyAt(0).assertEquals(20)
(map.valueAt(0) == moved).assertTrue()

destroy first
destroy replacement
destroy moved
destroy map

@Test
function clearsAndReusesStorage()
let map = new IntMap<boolean>()
map.put(1, true)
map.put(2, false)
map.clear()

map.isEmpty().assertTrue()
map.has(1).assertFalse()
map.put(3, true)
map.getAndRemove(3).assertTrue()
map.isEmpty().assertTrue()
destroy map

@Test
function storesSpecializedTupleValues()
let map = new IntMap<IntMapTestTuple>()
map.put(7, IntMapTestTuple(42, "answer"))

let loaded = map.get(7)
loaded.number.assertEquals(42)
loaded.label.assertEquals("answer")
destroy map
27 changes: 27 additions & 0 deletions wurst/file/SerializableTests.wurst
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,33 @@ function schemaMigrationCanRenameFields()
destroy data
destroy writer

@Test
function readerMutationsReplaceAndMoveFieldOwnership()
let writer = new FieldSerializationWriter(1)
int oldValue = 10
int existingValue = 20
writer.write("old", oldValue)
writer.write("existing", existingValue)
let data = writer.finish()
let reader = new FieldSerializationReader(data)
int replacementValue = 11
int defaultValue = 0

reader.set("old", replacementValue)
reader.read("old", defaultValue).assertEquals(11)

reader.renameField("old", "moved")
reader.hasField("old").assertFalse()
reader.read("moved", defaultValue).assertEquals(11)

reader.renameField("moved", "existing")
reader.hasField("moved").assertFalse()
reader.read("existing", defaultValue).assertEquals(20)

destroy reader
destroy data
destroy writer

@Test
function integrityKeyRejectsEditedOrForeignDataWithoutMutation()
let writer = new FieldSerializationWriter(1, 12345)
Expand Down
86 changes: 50 additions & 36 deletions wurst/file/StructuredSerializationCore.wurst
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package StructuredSerializationCore
import ChunkedString
import HashMap
import IntMap
import ErrorHandling
import Bitwise

Expand Down Expand Up @@ -329,10 +329,20 @@ class SerializationCursor
ondestroy
destroy hasher

class SerializedField
string token
ChunkedString payload

construct(string token, ChunkedString payload)
this.token = token
this.payload = payload

ondestroy
destroy payload

/** Parsed field table. Unknown fields and wire tokens are retained and safely skippable. */
public class FieldSerializationReader
private let tokenMap = new HashMap<int, string>()
private let payloadMap = new IterableMap<int, ChunkedString>()
private let fields = new IntMap<SerializedField>()
private int schemaVersion = 0
private int integrityKey
private boolean valid = false
Expand All @@ -358,12 +368,11 @@ public class FieldSerializationReader
let token = cursor.read(1)
let length = cursor.readVarUInt()
let payload = cursor.readChunked(length)
if token.length() != 1 or tokenMap.has(fieldId)
if token.length() != 1 or fields.has(fieldId)
cursor.valid = false
destroy payload
else
tokenMap.put(fieldId, token)
payloadMap.put(fieldId, payload)
fields.put(fieldId, new SerializedField(token, payload))

if cursor.valid and cursor.pointer == cursor.bodyEnd
let expected = input.getUnsafeSubString(cursor.bodyEnd, input.length())
Expand All @@ -380,25 +389,25 @@ public class FieldSerializationReader
return integrityKey

function hasField(string name) returns boolean
return valid and payloadMap.has(serializationFieldId(name))
return valid and fields.has(serializationFieldId(name))

function hasRaw(string name, string token) returns boolean
let fieldId = serializationFieldId(name)
return valid and tokenMap.has(fieldId) and tokenMap.get(fieldId) == token
let field = fields.get(serializationFieldId(name))
return valid and field != null and field.token == token

function readRaw(string name, string token, string oldValue) returns string
let fieldId = serializationFieldId(name)
return valid and tokenMap.has(fieldId) and tokenMap.get(fieldId) == token ? payloadMap.get(fieldId).getUnsafeString() : oldValue
let field = fields.get(serializationFieldId(name))
return valid and field != null and field.token == token ? field.payload.getUnsafeString() : oldValue

/**
Returns an independent chunked copy of a raw payload, or `null` when the field/token is absent.
The caller owns and must destroy the returned value.
*/
function readRawChunked(string name, string token) returns ChunkedString
let fieldId = serializationFieldId(name)
if not valid or not tokenMap.has(fieldId) or tokenMap.get(fieldId) != token
let field = fields.get(serializationFieldId(name))
if not valid or field == null or field.token != token
return null
return copyChunkedString(payloadMap.get(fieldId))
return copyChunkedString(field.payload)

function read(string name, int oldValue) returns int
let payload = readRaw(name, INT_TOKEN, "")
Expand All @@ -420,10 +429,13 @@ public class FieldSerializationReader
if not valid or token.length() != 1
return
let fieldId = serializationFieldId(name)
if payloadMap.has(fieldId)
destroy payloadMap.get(fieldId)
tokenMap.put(fieldId, token)
payloadMap.put(fieldId, new ChunkedString()..append(payload))
let field = fields.get(fieldId)
if field == null
fields.put(fieldId, new SerializedField(token, new ChunkedString()..append(payload)))
else
destroy field.payload
field.token = token
field.payload = new ChunkedString()..append(payload)

function set(string name, int value)
setRaw(name, INT_TOKEN, encodeFixedInt(value))
Expand All @@ -439,10 +451,10 @@ public class FieldSerializationReader

function removeField(string name)
let fieldId = serializationFieldId(name)
if payloadMap.has(fieldId)
destroy payloadMap.get(fieldId)
tokenMap.remove(fieldId)
payloadMap.remove(fieldId)
let field = fields.get(fieldId)
if field != null
destroy field
fields.remove(fieldId)

/** Mutates an existing opted-in child object when a valid nested envelope is present. */
function readInto(string name, FieldSerializable oldValue) returns boolean
Expand All @@ -455,10 +467,10 @@ public class FieldSerializationReader

/** Returns a nested reader borrowed from this reader, or an invalid reader when absent. */
function readObject(string name) returns FieldSerializationReader
let fieldId = serializationFieldId(name)
if not valid or not tokenMap.has(fieldId) or tokenMap.get(fieldId) != OBJECT_TOKEN
let field = fields.get(serializationFieldId(name))
if not valid or field == null or field.token != OBJECT_TOKEN
return new FieldSerializationReader()
return new FieldSerializationReader(payloadMap.get(fieldId), integrityKey)
return new FieldSerializationReader(field.payload, integrityKey)

/**
Moves a persisted value from an old field name to a new one. Call this from a migration before
Expand All @@ -469,19 +481,21 @@ public class FieldSerializationReader
let newId = serializationFieldId(newName)
if oldId == newId
return
if tokenMap.has(oldId) and not tokenMap.has(newId)
tokenMap.put(newId, tokenMap.get(oldId))
payloadMap.put(newId, payloadMap.get(oldId))
else if payloadMap.has(oldId)
destroy payloadMap.get(oldId)
tokenMap.remove(oldId)
payloadMap.remove(oldId)

let oldField = fields.get(oldId)
if oldField == null
return

fields.remove(oldId)
if fields.has(newId)
destroy oldField
else
fields.put(newId, oldField)

ondestroy
for fieldId in payloadMap
destroy payloadMap.get(fieldId)
destroy tokenMap
destroy payloadMap
for i = 0 to fields.size() - 1
destroy fields.valueAt(i)
destroy fields

/** Type-directed field readers used by the compiler-expanded mapper. */
public function int.readSerializedField(FieldSerializationReader reader, string name) returns int
Expand Down