diff --git a/sjsonnet/src-js/sjsonnet/SjsonnetMain.scala b/sjsonnet/src-js/sjsonnet/SjsonnetMain.scala index 82c44516..484e821c 100644 --- a/sjsonnet/src-js/sjsonnet/SjsonnetMain.scala +++ b/sjsonnet/src-js/sjsonnet/SjsonnetMain.scala @@ -12,7 +12,8 @@ object SjsonnetMain { extVars: js.Any, tlaVars: js.Any, wd0: String, - importer: js.Function2[String, String, js.Array[String]]): js.Any = { + importer: js.Function2[String, String, js.Array[String]], + preserveOrder: Boolean = false): js.Any = { val interp = new Interpreter( mutable.Map.empty, ujson.WebJson.transform(extVars, ujson.Value).obj.toMap, @@ -23,7 +24,8 @@ object SjsonnetMain { case null => None case arr => Some((JsVirtualPath(arr(0)), arr(1))) } - } + }, + preserveOrder ) interp.interpret0(text, JsVirtualPath("(memory)"), ujson.WebJson.Builder) match{ case Left(msg) => throw new js.JavaScriptException(msg) diff --git a/sjsonnet/src-jvm/sjsonnet/Cli.scala b/sjsonnet/src-jvm/sjsonnet/Cli.scala index 8cddc802..958f7b30 100644 --- a/sjsonnet/src-jvm/sjsonnet/Cli.scala +++ b/sjsonnet/src-jvm/sjsonnet/Cli.scala @@ -20,7 +20,8 @@ object Cli{ expectString: Boolean = false, varBinding: Map[String, ujson.Value] = Map(), tlaBinding: Map[String, ujson.Value] = Map(), - indent: Int = 3) + indent: Int = 3, + preserveOrder: Boolean = false) def genericSignature(wd: os.Path) = Seq( @@ -127,7 +128,13 @@ object Cli{ case Array(x, v) => c.copy(tlaBinding = c.tlaBinding ++ Seq(x -> ujson.read(os.read(os.Path(v, wd))))) } - ) + ), + Arg[Config, Unit]( + "preserve-order", Some('p'), + "Preserves order of keys in the resulting JSON", + (c, v) => c.copy(preserveOrder = true) + ), + ) def showArg(arg: Arg[_, _]) = " " + arg.shortName.fold("")("-" + _ + ", ") + "--" + arg.name diff --git a/sjsonnet/src-jvm/sjsonnet/SjsonnetMain.scala b/sjsonnet/src-jvm/sjsonnet/SjsonnetMain.scala index 7451117a..5596a4d9 100644 --- a/sjsonnet/src-jvm/sjsonnet/SjsonnetMain.scala +++ b/sjsonnet/src-jvm/sjsonnet/SjsonnetMain.scala @@ -101,7 +101,8 @@ object SjsonnetMain { importer = resolveImport( config.jpaths.map(os.Path(_, wd)).map(OsPath(_)), allowedInputs - ) + ), + config.preserveOrder ) def writeFile(f: os.RelPath, contents: String): Either[String, Unit] = { diff --git a/sjsonnet/src/sjsonnet/Evaluator.scala b/sjsonnet/src/sjsonnet/Evaluator.scala index 1b6ab5ba..09737275 100644 --- a/sjsonnet/src/sjsonnet/Evaluator.scala +++ b/sjsonnet/src/sjsonnet/Evaluator.scala @@ -19,7 +19,8 @@ import scala.collection.mutable class Evaluator(parseCache: collection.mutable.Map[String, fastparse.Parsed[(Expr, Map[String, Int])]], val extVars: Map[String, ujson.Value], val wd: Path, - importer: (Path, String) => Option[(Path, String)]) extends EvalScope{ + importer: (Path, String) => Option[(Path, String)], + override val preserveOrder: Boolean = false) extends EvalScope{ implicit def evalScope: EvalScope = this val loadedFileContents = mutable.Map.empty[Path, String] @@ -435,7 +436,7 @@ class Evaluator(parseCache: collection.mutable.Map[String, fastparse.Parsed[(Exp ).toArray lazy val newSelf: Val.Obj = { - val builder = Map.newBuilder[String, Val.Obj.Member] + val builder = mutable.LinkedHashMap.newBuilder[String, Val.Obj.Member] value.foreach { case Member.Field(offset, fieldName, plus, None, sep, rhs) => visitFieldName(fieldName, offset).map(_ -> Val.Obj.Member(plus, sep, (self: Val.Obj, sup: Option[Val.Obj], _, _) => { @@ -461,7 +462,7 @@ class Evaluator(parseCache: collection.mutable.Map[String, fastparse.Parsed[(Exp ) lazy val newSelf: Val.Obj = { - val builder = Map.newBuilder[String, Val.Obj.Member] + val builder = mutable.LinkedHashMap.newBuilder[String, Val.Obj.Member] for(s <- visitComp(first :: rest.toList, Seq(compScope))){ lazy val newScope: ValScope = s.extend( newBindings, diff --git a/sjsonnet/src/sjsonnet/Interpreter.scala b/sjsonnet/src/sjsonnet/Interpreter.scala index 44424e2d..bf9d2270 100644 --- a/sjsonnet/src/sjsonnet/Interpreter.scala +++ b/sjsonnet/src/sjsonnet/Interpreter.scala @@ -13,13 +13,15 @@ class Interpreter(parseCache: collection.mutable.Map[String, fastparse.Parsed[(E extVars: Map[String, ujson.Value], tlaVars: Map[String, ujson.Value], wd: Path, - importer: (Path, String) => Option[(Path, String)]) { + importer: (Path, String) => Option[(Path, String)], + preserveOrder: Boolean = false) { val evaluator = new Evaluator( parseCache, extVars, wd, - importer + importer, + preserveOrder ) def interpret(txt: String, path: Path): Either[String, ujson.Value] = { diff --git a/sjsonnet/src/sjsonnet/Materializer.scala b/sjsonnet/src/sjsonnet/Materializer.scala index 4758face..ab35db17 100644 --- a/sjsonnet/src/sjsonnet/Materializer.scala +++ b/sjsonnet/src/sjsonnet/Materializer.scala @@ -4,6 +4,8 @@ import sjsonnet.Expr.{FieldName, Member, ObjBody} import sjsonnet.Expr.Member.Visibility import upickle.core.Visitor +import scala.collection.mutable + /** * Serializes the given [[Val]] out to the given [[upickle.core.Visitor]], * which can transform it into [[ujson.Value]]s or directly serialize it @@ -36,12 +38,13 @@ object Materializer { case obj: Val.Obj => obj.triggerAllAsserts(obj) - val keys = obj.getVisibleKeys().toArray.sortBy(_._1) + val keysUnsorted = obj.getVisibleKeys().toArray + val keys = if (!evaluator.preserveOrder) keysUnsorted.sortBy(_._1) else keysUnsorted val objVisitor = visitor.visitObject(keys.length , -1) for(t <- keys) { val (k, hidden) = t - if (!hidden){ + if (!hidden){ objVisitor.visitKeyValue(objVisitor.visitKey(-1).visitString(k, -1)) objVisitor.visitValue( apply0( @@ -73,7 +76,7 @@ object Materializer { case ujson.Str(s) => Val.Str(s) case ujson.Arr(xs) => Val.Arr(xs.map(x => Val.Lazy(reverse(x))).toArray[Val.Lazy]) case ujson.Obj(xs) => - val builder = Map.newBuilder[String, Val.Obj.Member] + val builder = mutable.LinkedHashMap.newBuilder[String, Val.Obj.Member] for(x <- xs){ val v = Val.Obj.Member(false, Visibility.Normal, (_: Val.Obj, _: Option[Val.Obj], _, _) => reverse(x._2) diff --git a/sjsonnet/src/sjsonnet/Std.scala b/sjsonnet/src/sjsonnet/Std.scala index 889c7ff1..5655c6c7 100644 --- a/sjsonnet/src/sjsonnet/Std.scala +++ b/sjsonnet/src/sjsonnet/Std.scala @@ -8,7 +8,7 @@ import java.util.zip.GZIPOutputStream import sjsonnet.Expr.Member.Visibility import sjsonnet.Expr.{BinaryOp, False, Params} -import scala.collection.mutable.ArrayBuffer +import scala.collection.mutable import scala.collection.compat._ import sjsonnet.Std.builtinWithDefaults import ujson.Value @@ -53,22 +53,26 @@ object Std { v1.getVisibleKeys().get(v2).isDefined }, builtin("objectFields", "o"){ (ev, fs, v1: Val.Obj) => - Val.Arr( - v1.getVisibleKeys() - .collect{case (k, false) => k} - .toSeq - .sorted - .map(k => Val.Lazy(Val.Str(k))) - ) + val keys = v1.getVisibleKeys() + .collect{case (k, false) => k} + .toSeq + val maybeSorted = if(ev.preserveOrder) { + keys + } else { + keys.sorted + } + Val.Arr(maybeSorted.map(k => Val.Lazy(Val.Str(k)))) }, builtin("objectFieldsAll", "o"){ (ev, fs, v1: Val.Obj) => - Val.Arr( - v1.getVisibleKeys() - .collect{case (k, _) => k} - .toSeq - .sorted - .map(k => Val.Lazy(Val.Str(k))) - ) + val keys = v1.getVisibleKeys() + .collect{case (k, _) => k} + .toSeq + val maybeSorted = if(ev.preserveOrder) { + keys + } else { + keys.sorted + } + Val.Arr(maybeSorted.map(k => Val.Lazy(Val.Str(k)))) }, builtin("type", "x"){ (ev, fs, v1: Val) => v1 match{ @@ -250,6 +254,7 @@ object Std { builtin("mapWithKey", "func", "obj"){ (ev, fs, func: Applyer, obj: Val.Obj) => val allKeys = obj.getVisibleKeys() new Val.Obj( + mutable.LinkedHashMap() ++ allKeys.map{ k => k._1 -> (Val.Obj.Member(false, Visibility.Normal, (self: Val.Obj, sup: Option[Val.Obj], _, _) => func.apply( @@ -257,7 +262,7 @@ object Std { Val.Lazy(obj.value(k._1, -1)(fs,ev)) ) )) - }.toMap, + }, _ => (), None ) @@ -289,7 +294,7 @@ object Std { builtin("findSubstr", "pat", "str") { (ev, fs, pat: String, str: String) => if (pat.length == 0) Val.Arr(Seq()) else { - val indices = ArrayBuffer[Int]() + val indices = mutable.ArrayBuffer[Int]() var matchIndex = str.indexOf(pat) while (0 <= matchIndex && matchIndex < str.length) { indices.append(matchIndex) @@ -561,14 +566,14 @@ object Std { } else { val keyFFunc = keyF.asInstanceOf[Val.Func] val keyFApplyer = Applyer(keyFFunc, ev, null) - val appliedX = keyFApplyer.apply(v) + val appliedX = Materializer(keyFApplyer.apply(v))(ev) if (b.exists(value => { val appliedValue = keyFApplyer.apply(value) - appliedValue == appliedX + Materializer(appliedValue)(ev) == appliedX }) && !out.exists(value => { val mValue = keyFApplyer.apply(value) - mValue == appliedX + Materializer(mValue)(ev) == appliedX })) { out.append(v) } @@ -608,14 +613,14 @@ object Std { } else { val keyFFunc = keyF.asInstanceOf[Val.Func] val keyFApplyer = Applyer(keyFFunc, ev, null) - val appliedX = keyFApplyer.apply(v) + val appliedX = Materializer(keyFApplyer.apply(v))(ev) if (!b.exists(value => { val appliedValue = keyFApplyer.apply(value) - appliedValue == appliedX + Materializer(appliedValue)(ev) == appliedX }) && !out.exists(value => { val mValue = keyFApplyer.apply(value) - mValue == appliedX + Materializer(mValue)(ev) == appliedX })) { out.append(v) } @@ -639,7 +644,7 @@ object Std { val appliedX = keyFApplyer.apply(x) arr.exists(value => { val appliedValue = keyFApplyer.apply(value) - appliedValue == appliedX + Materializer(appliedValue)(ev) == Materializer(appliedX)(ev) }) } }, @@ -675,10 +680,10 @@ object Std { val transformedValue: Seq[Val.Lazy] = values.map(v => Val.Lazy(recursiveTransform(v))).toSeq Val.Arr(transformedValue) case ujson.Obj(valueMap) => - val transformedValue = valueMap + val transformedValue = mutable.LinkedHashMap() ++ valueMap .mapValues { v => Val.Obj.Member(false, Expr.Member.Visibility.Normal, (_, _, _, _) => recursiveTransform(v)) - }.toMap + } new Val.Obj(transformedValue , (x: Val.Obj) => (), None) } } @@ -702,7 +707,7 @@ object Std { v = rec(o.value(k, -1)(fs, ev)) if filter(v) }yield (k, Val.Obj.Member(false, Visibility.Normal, (_, _, _, _) => v)) - new Val.Obj(bindings.toMap, _ => (), None) + new Val.Obj(mutable.LinkedHashMap() ++ bindings, _ => (), None) case a: Val.Arr => Val.Arr(a.value.map(x => rec(x.force)).filter(filter).map(Val.Lazy(_))) case _ => x @@ -737,6 +742,7 @@ object Std { ) ) val Std = new Val.Obj( + mutable.LinkedHashMap() ++ functions .map{ case (k, v) => @@ -748,8 +754,7 @@ object Std { (self: Val.Obj, sup: Option[Val.Obj], _, _) => v ) ) - } - .toMap ++ Seq( + } ++ Seq( ( "thisFile", Val.Obj.Member( @@ -891,19 +896,19 @@ object Std { if (keyF == Val.False) { throw new Error.Delegate("Unable to sort array of objects without key function") } else { + val objs = vs.map(_.force.cast[Val.Obj]) + val keyFFunc = keyF.asInstanceOf[Val.Func] val keyFApplyer = Applyer(keyFFunc, ev, null) - vs.map(_.force.cast[Val.Obj]).sortWith((o1, o2) => { - val o1Key = keyFApplyer.apply(Val.Lazy(o1)) - val o2Key = keyFApplyer.apply(Val.Lazy(o2)) - val o1KeyExpr = Materializer.toExpr(Materializer.apply(o1Key)(ev)) - val o2KeyExpr = Materializer.toExpr(Materializer.apply(o2Key)(ev)) - - val comparisonExpr = Expr.BinaryOp(0, o1KeyExpr, BinaryOp.`<`, o2KeyExpr) - val exprResult = ev.visitExpr(comparisonExpr)(scope(0), new FileScope(null, Map.empty)) - val res = Materializer.apply(exprResult)(ev).asInstanceOf[ujson.Bool] - res.value - }).map(Val.Lazy(_)) + val keys = objs.map((v) => keyFApplyer(Val.Lazy(v))) + + if (keys.forall(_.isInstanceOf[Val.Str])){ + objs.sortBy((v) => keyFApplyer(Val.Lazy(v)).cast[Val.Str].value).map(Val.Lazy(_)) + } else if (keys.forall(_.isInstanceOf[Val.Num])) { + objs.sortBy((v) => keyFApplyer(Val.Lazy(v)).cast[Val.Num].value).map(Val.Lazy(_)) + } else { + throw new Error.Delegate("Cannot sort with key values that are " + keys(0).prettyName + "s") + } } }else { ??? diff --git a/sjsonnet/src/sjsonnet/Val.scala b/sjsonnet/src/sjsonnet/Val.scala index 7bd30343..5b58d107 100644 --- a/sjsonnet/src/sjsonnet/Val.scala +++ b/sjsonnet/src/sjsonnet/Val.scala @@ -73,7 +73,7 @@ object Val{ } - final class Obj(value0: Map[String, Obj.Member], + final class Obj(value0: mutable.Map[String, Obj.Member], triggerAsserts: Val.Obj => Unit, `super`: Option[Obj]) extends Val{ @@ -102,7 +102,7 @@ object Val{ } def getVisibleKeys() = { - val mapping = collection.mutable.Map.empty[String, Boolean] + val mapping = mutable.LinkedHashMap.empty[String, Boolean] foreachVisibleKey{ (k, sep) => (mapping.get(k), sep) match{ case (None, Visibility.Hidden) => mapping(k) = true @@ -314,6 +314,8 @@ trait EvalScope extends EvalErrorScope{ def materialize(v: Val): ujson.Value val emptyMaterializeFileScope = new FileScope(wd / "(materialize)", Map()) + + val preserveOrder: Boolean = false } object ValScope{ def empty(size: Int) = new ValScope(None, None, None, new Array(size)) diff --git a/sjsonnet/test/src/sjsonnet/PreserveOrderTests.scala b/sjsonnet/test/src/sjsonnet/PreserveOrderTests.scala new file mode 100644 index 00000000..1192a90d --- /dev/null +++ b/sjsonnet/test/src/sjsonnet/PreserveOrderTests.scala @@ -0,0 +1,350 @@ +package sjsonnet + +import utest._ + +object PreserveOrderTests extends TestSuite { + def eval(s: String, preserveOrder: Boolean) = { + new Interpreter( + SjsonnetMain.createParseCache(), + Map(), + Map(), + DummyPath(), + (_, _) => None, + preserveOrder + ).interpret(s, DummyPath("(memory)")) match { + case Right(x) => x + case Left(e) => throw new Exception(e) + } + } + + def tests = Tests { + test("preserveOrder") { + eval( + """{ + "z": "z", + "a": "a", + }""", true).toString() ==> """{"z":"z","a":"a"}""" + + eval( + """[ + { + "a": "a" + }, + { + "z": "z", + "a": "a" + } + ][1]""", true).toString() ==> """{"z":"z","a":"a"}""" + + eval( + """[{b: null}, + { + "z": null, + "a": "2", + "d": {}, + "b": "3" + }, + []]""", true).toString() ==> """[{"b":null},{"z":null,"a":"2","d":{},"b":"3"},[]]""" + + eval( + """{ + "z": "z", + "a": [5, { + "s": "s", + c: "c" + }], + }""", true).toString() ==> """{"z":"z","a":[5,{"s":"s","c":"c"}]}""" + + eval( + """{ + "z": "z", + "a": "a", + }""", false).toString() ==> """{"a":"a","z":"z"}""" + + } + + test("preserveOrderCombined") { + eval( + """{ + "z": "z", + "a": "a", + } + { + "a": "a1", + "z": "z1", + "b": "b" + }""", true).toString() ==> """{"z":"z1","a":"a1","b":"b"}""" + + eval( + """{ + "z": "z", + "a": "a", + } + { + "c": "c", // new, should be after z and a + "a": "a1", // z and a should maintain previous order + "z": "z1", + "b": "b", // new, should be after c + "q": "q" // new, should be after b + }""", true).toString() ==> """{"z":"z1","a":"a1","c":"c","b":"b","q":"q"}""" + } + + test("preserveOrderHidden") { + eval( + """{ + "z": "z", + "a": "a", + "b": "b" + } + { + "b": "b2", + "a":: "hidden" + }""", true).toString() ==> """{"z":"z","b":"b2"}""" + } + + test("preserveOrderUnhidden") { + eval( + """{ + "z": "z", + "a": "a", + "b": "b" + } + { + "b": "b2", + "a":: "hidden" + } + { + "a"::: "unhidden", + "b": "b3" + }""", true).toString() ==> """{"z":"z","a":"unhidden","b":"b3"}""" + } + + test("preserveOrderMergePatch") { + eval( + """std.mergePatch({ + "z": "z", + "a": "a", + "b": "b" + }, { + "a": null + })""", true).toString() ==> """{"z":"z","b":"b"}""" + + eval( + """std.mergePatch({ + "z": "z", + "a": "a", + "b": "b" + }, { + "b": "b2", + "a": null + })""", true).toString() ==> """{"z":"z","b":"b2"}""" + + eval( + """std.mergePatch({ + "z": "z", + "a": "a", + "b": "b" + }, { + "b": "b2", + "z": "z2" + })""", true).toString() ==> """{"z":"z2","a":"a","b":"b2"}""" + } + + test("preserveOrderObjectFields") { + eval( + """std.objectFields({ + "z": "z", + "a": "a", + "b": "b" + })""", true).toString() ==> """["z","a","b"]""" + } + + test("preserveOrderObjectFieldsAll") { + eval( + """std.objectFieldsAll({ + "z": "z", + "a": "a", + "b": "b" + } + { + "c": "c", + "b": "b2", + "a":: "hidden", + })""", true).toString() ==> """["z","a","b","c"]""" + } + + test("preserveOrderMapWithKey") { + eval( + """std.mapWithKey(function(k, v) k + v, + { + "z": "1", + "a": "2", + "b": "3" + })""", true).toString() ==> """{"z":"z1","a":"a2","b":"b3"}""" + } + + test("preserveOrderPrune") { + eval( + """std.prune([{b: null}, + { + "z": null, + "a": "2", + "d": {}, + "b": "3" + }, + []])""", true).toString() ==> """[{"a":"2","b":"3"}]""" + } + + test("preserveOrderManifestIni") { + eval( + """std.manifestIni({ + main: { b: "1", a: 2, c: true, e: null, d: [1, {"2": 2}, [3]], f: {"hello": "world"} }, + sections: {} + })""", true) ==> + ujson.Str("b = 1\na = 2\nc = true\ne = null\nd = 1\nd = {\"2\": 2}\nd = [3]\nf = {\"hello\": \"world\"}\n") + } + + test("preserveOrderPython") { + eval( + """std.manifestPython({ + "z": "z", + "a": "a", + "b": true + })""", true) ==> ujson.Str("""{"z": "z", "a": "a", "b": True}""") + + eval( + """std.manifestPythonVars({ + "z": "z", + "a": "a", + "b": true + })""", true) ==> + ujson.Str( + """z = "z" + |a = "a" + |b = True + |""".stripMargin) + } + + test("preserveOrderJsonEx") { + eval( + """std.manifestJsonEx({ + "z": "z", + "a": "a", + "b": true + }, " ")""", true) ==> + ujson.Str( + """{ + | "z": "z", + | "a": "a", + | "b": true + |}""".stripMargin) + } + + test("preserveOrderYaml") { + eval( + """std.manifestYamlDoc({ + "z": "z", + "a": [1, 2], + "b": { + "s": "s", + "c": "c" + } + })""", true) ==> + ujson.Str( + """"z": "z" + |"a": + |- 1 + |- 2 + |"b": + | "s": "s" + | "c": "c"""".stripMargin) + + eval( + """std.manifestYamlStream([5, { + "z": "z", + "a": [1, 2], + "b": { + "s": "s", + "c": "c" + } + }])""", true) ==> + ujson.Str( + """--- + |5 + |--- + |"z": "z" + |"a": + |- 1 + |- 2 + |"b": + | "s": "s" + | "c": "c" + |... + |""".stripMargin) + } + + test("preserveOrderXml") { + eval( + """std.manifestXmlJsonml([ + 'a', { c: 'c', b: 'b' } + ])""", true) ==> + ujson.Str("""""") + } + + test("preserveOrderToString") { + eval( + """std.toString({ + "z": "1", + "a": "2", + "b": "3" + })""", true) ==> + ujson.Str("""{"z": "1", "a": "2", "b": "3"}""") + } + + test("preserveOrderMemberConcat") { + eval( + """{b: "b", a: "a"} + {a+: {d: 1, c: 2}, s: 4}""", true).toString ==> + """{"b":"b","a":"a{\"d\": 1, \"c\": 2}","s":4}""" + } + + test("preserveOrderError") { + try { + eval( + """local x = { b: 1, a: 2, c: self.a + self.b }; + error x""", true) + assert(false) + } catch { + case e: Exception => + assert(e.getMessage().startsWith("""sjsonnet.Error: {"b": 1, "a": 2, "c": 3}""")) + } + } + + test("preserveOrderPreservesEquality") { + eval("""{a: 1, b: 2} == {b: 2, a: 1}""", true).toString ==> "true" + } + + test("preserveOrderSet") { + eval( + """std.set([{a: 1, b: 2}, {a:3}, {b: 2, a: 1}], + keyF=function(v) v.a)""", true).toString ==> """[{"a":1,"b":2},{"a":3}]""" + } + + test("preserveOrderPreservesSetMembership") { + eval("""std.setMember({a: 1, b: 2}, [{b: 2, a: 1}])""", true).toString ==> "true" + + eval("""std.setMember({q: {a: 1, b: 2}}, [{q: {b: 2, a: 1}}], keyF=function(v) v.q)""", true).toString ==> "true" + } + + test("preserveOrderSetIntersection") { + eval( + """std.setInter([{a: 1, b: 2}], [{b: 2, a: 1}], + keyF=function(v) v.a)""", true).toString ==> """[{"a":1,"b":2}]""" + } + + test("preserveOrderSetUnion") { + eval( + """std.setUnion([{a: 1, b: 2}, {a:3}], [{b: 2, a: 1}], + keyF=function(v) v.a)""", true).toString ==> """[{"a":1,"b":2},{"a":3}]""" + } + + test("preserveOrderSetDiff") { + eval( + """std.setDiff([{a: 1, b: 2}, {a:3}], [{b: 2, a: 1}], + keyF=function(v) v.a)""", true).toString ==> """[{"a":3}]""" + } + } +} \ No newline at end of file