diff --git a/src/Dictionary/getIn.lua b/src/Dictionary/getIn.lua new file mode 100644 index 0000000..d205287 --- /dev/null +++ b/src/Dictionary/getIn.lua @@ -0,0 +1,15 @@ +return function(dictionary, keyPath, default) + local dictionaryType = type(dictionary) + assert(dictionaryType == "table", "expected a table for first argument, got " .. dictionaryType) + assert(type(keyPath) == "table", string.format("Invalid keyPath: expected array: %s", tostring(keyPath))) + + local node = dictionary + for _, path in ipairs(keyPath) do + node = node[path] + if not node then + return default + end + end + + return node +end diff --git a/src/Dictionary/init.lua b/src/Dictionary/init.lua index a823644..1ef8e90 100644 --- a/src/Dictionary/init.lua +++ b/src/Dictionary/init.lua @@ -11,18 +11,22 @@ local Dictionary = { flatten = require(script.flatten), flip = require(script.flip), get = require(script.get), + getIn = require(script.getIn), has = require(script.has), includes = require(script.includes), join = require(script.join), joinDeep = require(script.joinDeep), keys = require(script.keys), map = require(script.map), + removeIn = require(script.removeIn), removeKey = require(script.removeKey), removeValue = require(script.removeValue), set = require(script.set), + setIn = require(script.setIn), some = require(script.some), update = require(script.update), + updateIn = require(script.updateIn), values = require(script.values), } -return Dictionary \ No newline at end of file +return Dictionary diff --git a/src/Dictionary/removeIn.lua b/src/Dictionary/removeIn.lua new file mode 100644 index 0000000..5715b07 --- /dev/null +++ b/src/Dictionary/removeIn.lua @@ -0,0 +1,7 @@ +local updateIn = require(script.Parent.updateIn) + +return function(dictionary, keyPath) + return updateIn(dictionary, keyPath, function() + return nil + end, nil) +end diff --git a/src/Dictionary/setIn.lua b/src/Dictionary/setIn.lua new file mode 100644 index 0000000..6260085 --- /dev/null +++ b/src/Dictionary/setIn.lua @@ -0,0 +1,9 @@ +local updateIn = require(script.Parent.updateIn) + +return function(dictionary, keyPath, value) + local result = updateIn(dictionary, keyPath, function() + return value + end, {}) + + return result +end diff --git a/src/Dictionary/updateIn.lua b/src/Dictionary/updateIn.lua new file mode 100644 index 0000000..7ae6604 --- /dev/null +++ b/src/Dictionary/updateIn.lua @@ -0,0 +1,65 @@ +local removeKey = require(script.Parent.removeKey) +local set = require(script.Parent.set) +local map = require(script.Parent.map) +local slice = require(script.Parent.Parent.List.slice) + +local function quoteString(value) + return string.format("%q", value) +end + +local function throw(existing, keyPath, i) + error(string.format( + "Cannot update within non-table value in path [%s] = %s", + table.concat(map(slice(keyPath, 1, i - 1), quoteString), ", "), + tostring(existing) + )) +end + +local function updateInDeeply(existing, keyPath, notSetValue, updater, i) + local wasNotSet = existing == nil + if i > #keyPath then + local existingValue = wasNotSet and notSetValue or existing + local newValue = updater(existingValue) + return newValue == existingValue and existing or newValue + end + + if (not wasNotSet and type(existing) ~= "table") then + throw(existing, keyPath, i) + end + + local key = keyPath[i] + local nextExisting + if wasNotSet then + nextExisting = notSetValue and notSetValue[key] or nil + else + nextExisting = existing[key] + end + + local nextUpdated = updateInDeeply(nextExisting, keyPath, notSetValue, updater, i + 1) + + if nextUpdated == nil then + if existing or notSetValue then + return removeKey(existing or notSetValue, key) + end + else + if existing or notSetValue then + return set(existing or notSetValue, key, nextUpdated) + else + throw(existing, keyPath, i) + end + end + + return nil +end + +return function(dictionary, keyPath, updater, notSetValue) + local dictionaryType = type(dictionary) + assert(dictionaryType == "table", "expected a table for first argument, got " .. dictionaryType) + assert(type(keyPath) == "table", string.format("Invalid keyPath: expected array: %s", tostring(keyPath))) + + local updatedValue = updateInDeeply( + dictionary, keyPath, notSetValue, updater, 1 + ) + + return updatedValue or notSetValue +end diff --git a/tests/Llama/Dictionary/getIn.spec.lua b/tests/Llama/Dictionary/getIn.spec.lua new file mode 100644 index 0000000..291c671 --- /dev/null +++ b/tests/Llama/Dictionary/getIn.spec.lua @@ -0,0 +1,46 @@ +return function() + local ReplicatedStorage = game:GetService("ReplicatedStorage") + local lib = ReplicatedStorage.lib + local Llama = require(lib.Llama) + local getIn = Llama.Dictionary.getIn + + describe("GIVEN a dictionary with layers x, y, and z", function() + local dictionary = { x = { y = { z = 123 }}} + + it("SHOULD return 123 when given {x, y, z}", function() + local result = getIn(dictionary, {"x", "y", "z"}) + expect(result).to.equal(123) + end) + + it("SHOULD return the given default when given {x, q, p}", function() + local result = getIn(dictionary, {"x", "q", "p"}, "someDefaultValue") + expect(result).to.equal("someDefaultValue") + end) + + it("SHOULD return nil if path does not match and there is no default given", function() + expect(getIn(dictionary, {"a", "b", "c"})).to.equal(nil) + expect(getIn(dictionary, {"x", "b", "c"})).to.equal(nil) + expect(getIn(dictionary, {"x", "y", "c"})).to.equal(nil) + end) + + it("SHOULD return not found if path encounters non-data-structure", function() + local m = { a = { b = { c = nil }}} + local keyPath = { "a", "b", "c", "x" } + expect(getIn(m, keyPath)).to.equal(nil) + expect(getIn(m, keyPath, "default")).to.equal("default") + end) + end) + + local function itShouldThrowAsKeyPath(given) + return function() + it("SHOULD throw", function() + expect(function() + getIn({}, given) + end).to.throw() + end) + end + end + describe("GIVEN a number as keyPath", itShouldThrowAsKeyPath(10)) + describe("GIVEN string as keyPath", itShouldThrowAsKeyPath("abc")) + describe("GIVEN nil as keyPath", itShouldThrowAsKeyPath(nil)) +end diff --git a/tests/Llama/Dictionary/removeIn.spec.lua b/tests/Llama/Dictionary/removeIn.spec.lua new file mode 100644 index 0000000..cd86fdd --- /dev/null +++ b/tests/Llama/Dictionary/removeIn.spec.lua @@ -0,0 +1,26 @@ +return function() + local ReplicatedStorage = game:GetService("ReplicatedStorage") + + local lib = ReplicatedStorage.lib + local Llama = require(lib.Llama) + + local Dictionary = Llama.Dictionary + local removeIn = Dictionary.removeIn + local equalsDeep = Dictionary.equalsDeep + + it("provides shorthand for updateIn to remove a single value", function() + local m = { a = { b = { c = "X", d = "Y" } } } + local result = removeIn(m, { "a", "b", "c" }) + expect(equalsDeep(result, { a = { b = { d = "Y" }} })).to.equal(true) + end) + + it("does not create empty maps for an unset path", function() + local result = removeIn({}, { "a", "b", "c" }) + expect(equalsDeep(result, {})).to.equal(true) + end) + + it("removes itself when removing empty path", function() + local m = {} + expect(removeIn({}, {})).to.never.be.ok() + end) +end diff --git a/tests/Llama/Dictionary/setIn.spec.lua b/tests/Llama/Dictionary/setIn.spec.lua new file mode 100644 index 0000000..2688636 --- /dev/null +++ b/tests/Llama/Dictionary/setIn.spec.lua @@ -0,0 +1,25 @@ +return function() + local ReplicatedStorage = game:GetService("ReplicatedStorage") + + local lib = ReplicatedStorage.lib + local Llama = require(lib.Llama) + + local Dictionary = Llama.Dictionary + local setIn = Dictionary.setIn + local equalsDeep = Dictionary.equalsDeep + + it("provides shorthand for updateIn to set a single value", function() + local m = setIn({}, { "a", "b", "c" }, "X"); + expect(equalsDeep(m, { a = { b = { c = "X" }} })).to.equal(true) + end) + + it("returns value when setting empty path", function() + local m = {} + expect(setIn({}, {}, "X")).to.equal("X"); + end) + + it("can setIn nil", function() + local m = setIn({}, { "a", "b", "c" }, nil); + expect(equalsDeep(m, { a = { b = { c = nil }} })).to.equal(true) + end) +end diff --git a/tests/Llama/Dictionary/updateIn.spec.lua b/tests/Llama/Dictionary/updateIn.spec.lua new file mode 100644 index 0000000..121ac6c --- /dev/null +++ b/tests/Llama/Dictionary/updateIn.spec.lua @@ -0,0 +1,111 @@ +return function() + local ReplicatedStorage = game:GetService("ReplicatedStorage") + + local lib = ReplicatedStorage.lib + local Llama = require(lib.Llama) + + local Dictionary = Llama.Dictionary + local updateIn = Dictionary.updateIn + local equalsDeep = Dictionary.equalsDeep + + it("deep edit", function() + local m = { a = { b = { c = 10 }}} + local result = updateIn(m, {"a", "b", "c"}, function(value) + return value * 2 + end) + + expect(equalsDeep(result, { a = { b = { c = 20 }}})).to.equal(true) + end) + + it("deep edit throws if non-editable path", function() + local deep = { key = { bar = { item = 10 }} } + expect(function() + updateIn(deep, {"key", "foo", "item"}, function() + return "newValue" + end) + end).to.throw("Cannot update within non-table value in path") + end) + + it("shallow remove", function() + local m = { a = 123 } + local result = updateIn(m, {"a"}, function() + return nil + end) + expect(equalsDeep(result, {})).to.equal(true) + end) + + it("deep remove", function() + local removeKey = Dictionary.removeKey + + local m = { a = { b = { c = 10 } } } + local result = updateIn(m, {"a", "b"}, function(map) + return removeKey(map, "c") + end) + expect(equalsDeep(result, { a = { b = {} }})).to.equal(true) + end) + + it("deep set", function() + local set = Dictionary.set + + local m = { a = { b = { c = 10 } } } + local result = updateIn(m, {"a", "b"}, function(map) + return set(map, "d", 20) + end) + expect(equalsDeep(result, { a = { b = { c = 10, d = 20 } }})).to.equal(true) + end) + + it("deep push", function() + local push = Llama.List.push + local m = { a = { b = { 1, 2, 3 }}} + local result = updateIn(m, {"a", "b"}, function(list) + return push(list, 4) + end) + expect(equalsDeep(result, { a = { b = { 1, 2, 3, 4 } }})).to.equal(true) + end) + + it("deep map", function() + local map = Llama.List.map + local m = { a = { b = { 1, 2, 3 }}} + local result = updateIn(m, {"a", "b"}, function(list) + return map(list, function(value) + return value * 10 + end) + end) + expect(equalsDeep(result, { a = { b = { 10, 20, 30 } }})).to.equal(true) + end) + + it("creates new maps if path contains gaps", function() + local set = Llama.Dictionary.set + local m = { a = { b = { c = 10 }}} + local result = updateIn(m, { "a", "x", "y" }, function(map) + return set(map, "z", 20) + end, {}) + expect(equalsDeep(result, { a = { b = { c = 10 }, x = { y = { z = 20 }} }})).to.equal(true) + end) + + it("throws if path cannot be set", function() + local m = { a = { b = { c = 10 } } } + expect(function() + updateIn(m, { "a", "b", "c", "d" }, function(v) + return 20 + end) + end).to.throw("Cannot update within non-table value in path") + end) + + it("update with notSetValue when non-existing key", function() + local m = { a = { b = { c = 10 } } } + local result = updateIn(m, {"x"}, function(map) + return map + 1 + end, 100) + expect(equalsDeep(result, { x = 101, a = { b = { c = 10 } }})).to.equal(true) + end) + + it("updates self for empty path", function() + local set = Llama.Dictionary.set + local m = { a = 1, b = 2, c = 3 } + local result = updateIn(m, {}, function(map) + return set(map, "b", 20) + end) + expect(equalsDeep(result, { a = 1, b = 20, c = 3 })).to.equal(true) + end) +end