Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP

Loading…

Clojure tuples #94

Merged
merged 15 commits into from

2 participants

@schleyfox

These commits address #25 and add some improved semantics to the Clojure DSL.

Stream/component/task info on a tuple is now accessible from the meta data.

Tuples now implement ILookup, IPersistentMap, Indexed, Seqable, IFn, and Map so that things like (nth tuple 1), (tuple :foo-field), (:foo-field tuple), etc. work.

Added an IndifferentAccessMap to allow tuple field names to be treated as either keywords or strings.

Tuples can now be "modified" like clojure maps with assoc/dissoc. This is useful for building up values to emit. This is kind of a naive implementation, but I think it works pretty well for the task at hand.

From the Clojure side, emit-bolt!/emit-bolt-direct! can now take its values as either a list or a map. If it is provided a map, then it will find the output fields for the stream and construct the values list from the map based on that (currently null for any fields not present in the map, which I think is sensible). We've been using a pattern like this on our project at NabeWise and I find it to be very powerful/convenient.

I apologize in advance that my java-fu is somewhat weak, so let me know if I need to fix any style or other issues.

@schleyfox

ok, I need to fix some lazy seq bugs. Working on running my project on this version.

@nathanmarz
Owner

I'm not totally sold yet on exposing the TopologyContext in the output collector. I'll have to think if there's a better way to get at that information.

I think that mk-tuple-values should be changed to a protocol. That should be faster and more flexible.

@schleyfox

Yeah, that was a dirty hack. I think the better approach would be to let output collectors take a list or a map, but that would require fairly far reaching changes across the system.

I also really need to switch out stringify keys in mk-tuple-values with a comprehension that doesn't mess with values that are maps.

@nathanmarz
Owner

Instead of exposing the TopologyContext in OutputCollector, how about we make the Clojure DSL "collector" be a map containing :output-collector and :context. Then the emit and ack functions can be modified appropriately to make use of that and we don't have to change the Java interfaces.

@nathanmarz
Owner

BTW, when you update a pull request leave a comment. Just adding commits doesn't send me a notification :(

@schleyfox

Okay, I made those changes. Everything seems to work well (at least in storm's test and my project).

schleyfox added some commits
@schleyfox schleyfox silly mistake in args hinting 8a64e43
@schleyfox schleyfox Merge branch 'master' into clojure_tuples
Conflicts:
	src/clj/backtype/storm/testing.clj
	src/jvm/backtype/storm/drpc/CoordinatedBolt.java
	src/jvm/backtype/storm/tuple/Tuple.java
526dcc0
@schleyfox

Merged to latest master

@nathanmarz nathanmarz merged commit 526dcc0 into from
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Commits on Jan 9, 2012
  1. @schleyfox

    Made Tuples operate better in Clojure

    schleyfox authored
    * Now implements ILookup, AFn, Seqable, Indexed, IMeta
    * Fake tuple generator for testing
  2. @schleyfox
  3. @schleyfox

    Tuple is now also a Map

    schleyfox authored
  4. @schleyfox

    Working emit with map from clojure

    schleyfox authored
    * Should probably refactor a bit and test more
  5. @schleyfox
Commits on Jan 10, 2012
  1. @schleyfox
  2. @schleyfox
Commits on Jan 14, 2012
  1. @schleyfox
  2. @schleyfox
Commits on Jan 17, 2012
  1. @schleyfox
  2. @schleyfox
  3. @schleyfox
  4. @schleyfox

    minor cleanup

    schleyfox authored
  5. @schleyfox
Commits on Feb 1, 2012
  1. @schleyfox

    Merge branch 'master' into clojure_tuples

    schleyfox authored
    Conflicts:
    	src/clj/backtype/storm/testing.clj
    	src/jvm/backtype/storm/drpc/CoordinatedBolt.java
    	src/jvm/backtype/storm/tuple/Tuple.java
This page is out of date. Refresh to see the latest.
View
63 src/clj/backtype/storm/clojure.clj
@@ -22,6 +22,13 @@
(hint collector 'backtype.storm.task.OutputCollector)]
)
+; Special case for clojure where we use a closure instead of the prepare
+; method
+(defmethod hinted-args 'prepare-fn [_ [conf context collector]]
+ [(hint conf 'java.util.Map)
+ (hint context 'backtype.storm.task.TopologyContext)
+ (hint collector 'java.util.Map)])
+
(defmethod hinted-args 'execute [_ [tuple]]
[(hint tuple 'backtype.storm.tuple.Tuple)]
)
@@ -34,6 +41,11 @@
[]
)
+(defmethod hinted-args 'open-fn [_ [conf context collector]]
+ [(hint conf 'java.util.Map)
+ (hint context 'backtype.storm.task.TopologyContext)
+ (hint collector 'java.util.Map)]
+ )
(defmethod hinted-args 'open [_ [conf context collector]]
[(hint conf 'java.util.Map)
(hint context 'backtype.storm.task.TopologyContext)
@@ -116,7 +128,7 @@
(let [[args & impl-body] impl
coll-sym (nth args 1)
args (vec (take 1 args))
- prepargs (hinted-args 'prepare [(gensym "conf") (gensym "context") coll-sym])]
+ prepargs (hinted-args 'prepare-fn [(gensym "conf") (gensym "context") coll-sym])]
`(fn ~prepargs (bolt (~'execute ~args ~@impl-body)))))
definer (if params
`(defn ~name [& args#]
@@ -148,7 +160,7 @@
(cons 'fn impl)
(let [[args & impl-body] impl
coll-sym (first args)
- prepargs (hinted-args 'open [(gensym "conf") (gensym "context") coll-sym])]
+ prepargs (hinted-args 'open-fn [(gensym "conf") (gensym "context") coll-sym])]
`(fn ~prepargs (spout (~'nextTuple [] ~@impl-body)))))
definer (if params
`(defn ~name [& args#]
@@ -167,31 +179,50 @@
~definer
))))
-(defnk emit-bolt! [^OutputCollector collector ^List values
+(defprotocol TupleValues
+ (tuple-values [values collector stream]))
+
+(extend-protocol TupleValues
+ java.util.Map
+ (tuple-values [this collector ^String stream]
+ (let [ fields (.. (:context collector) (getThisOutputFields stream) toList) ]
+ (vec (map (into
+ (empty this) (for [[k v] this]
+ [(if (keyword? k) (name k) k) v]))
+ fields))))
+ java.util.List
+ (tuple-values [this collector stream]
+ this))
+
+(defnk emit-bolt! [collector ^TupleValues values
:stream Utils/DEFAULT_STREAM_ID :anchor []]
- (let [^List anchor (collectify anchor)]
- (.emit collector stream anchor values)
+ (let [^List anchor (collectify anchor)
+ values (tuple-values values collector stream) ]
+ (.emit (:output-collector collector) stream anchor values)
))
-(defnk emit-direct-bolt! [^OutputCollector collector task ^List values
+(defnk emit-direct-bolt! [collector task ^TupleValues values
:stream Utils/DEFAULT_STREAM_ID :anchor []]
- (let [^List anchor (collectify anchor)]
- (.emitDirect collector task stream anchor values)
+ (let [^List anchor (collectify anchor)
+ values (tuple-values values collector stream) ]
+ (.emitDirect (:output-collector collector) task stream anchor values)
))
-(defn ack! [^OutputCollector collector ^Tuple tuple]
- (.ack collector tuple))
+(defn ack! [collector ^Tuple tuple]
+ (.ack (:output-collector collector) tuple))
-(defn fail! [^OutputCollector collector ^Tuple tuple]
- (.fail collector tuple))
+(defn fail! [collector ^Tuple tuple]
+ (.fail (:output-collector collector) tuple))
-(defnk emit-spout! [^SpoutOutputCollector collector ^List values
+(defnk emit-spout! [collector ^TupleValues values
:stream Utils/DEFAULT_STREAM_ID :id nil]
- (.emit collector stream values id))
+ (let [values (tuple-values values collector stream)]
+ (.emit (:output-collector collector) stream values id)))
-(defnk emit-direct-spout! [^SpoutOutputCollector collector task ^List values
+(defnk emit-direct-spout! [collector task ^TupleValues values
:stream Utils/DEFAULT_STREAM_ID :id nil]
- (.emitDirect collector task stream values id))
+ (let [values (tuple-values values collector stream)]
+ (.emitDirect (:output-collector collector) task stream values id)))
(defalias topology thrift/mk-topology)
(defalias bolt-spec thrift/mk-bolt-spec)
View
3  src/clj/backtype/storm/testing.clj
@@ -11,7 +11,8 @@
(:import [java.util.concurrent.atomic AtomicInteger])
(:import [java.util.concurrent ConcurrentHashMap])
(:import [backtype.storm.utils Time Utils RegisteredGlobalState])
- (:import [backtype.storm.tuple Fields])
+ (:import [backtype.storm.tuple Fields Tuple])
+ (:import [backtype.storm.task TopologyContext])
(:import [backtype.storm.generated GlobalStreamId Bolt])
(:import [backtype.storm.testing FeederSpout FixedTupleSpout FixedTuple
TupleCaptureBolt SpoutTracker BoltTracker NonRichBoltTracker
View
8 src/jvm/backtype/storm/clojure/ClojureBolt.java
@@ -11,6 +11,9 @@
import backtype.storm.tuple.Tuple;
import backtype.storm.utils.Utils;
import clojure.lang.IFn;
+import clojure.lang.PersistentArrayMap;
+import clojure.lang.Keyword;
+import clojure.lang.Symbol;
import clojure.lang.RT;
import java.util.ArrayList;
import java.util.List;
@@ -37,10 +40,13 @@ public void prepare(final Map stormConf, final TopologyContext context, final Ou
IFn hof = Utils.loadClojureFn(_fnSpec.get(0), _fnSpec.get(1));
try {
IFn preparer = (IFn) hof.applyTo(RT.seq(_params));
+ final Map<Keyword,Object> collectorMap = new PersistentArrayMap( new Object[] {
+ Keyword.intern(Symbol.create("output-collector")), collector,
+ Keyword.intern(Symbol.create("context")), context});
List<Object> args = new ArrayList<Object>() {{
add(stormConf);
add(context);
- add(collector);
+ add(collectorMap);
}};
_bolt = (IBolt) preparer.applyTo(RT.seq(args));
View
8 src/jvm/backtype/storm/clojure/ClojureSpout.java
@@ -9,6 +9,9 @@
import backtype.storm.tuple.Fields;
import backtype.storm.utils.Utils;
import clojure.lang.IFn;
+import clojure.lang.PersistentArrayMap;
+import clojure.lang.Keyword;
+import clojure.lang.Symbol;
import clojure.lang.RT;
import java.util.ArrayList;
import java.util.List;
@@ -35,10 +38,13 @@ public void open(final Map conf, final TopologyContext context, final SpoutOutpu
IFn hof = Utils.loadClojureFn(_fnSpec.get(0), _fnSpec.get(1));
try {
IFn preparer = (IFn) hof.applyTo(RT.seq(_params));
+ final Map<Keyword,Object> collectorMap = new PersistentArrayMap( new Object[] {
+ Keyword.intern(Symbol.create("output-collector")), collector,
+ Keyword.intern(Symbol.create("context")), context});
List<Object> args = new ArrayList<Object>() {{
add(conf);
add(context);
- add(collector);
+ add(collectorMap);
}};
_spout = (ISpout) preparer.applyTo(RT.seq(args));
View
2  src/jvm/backtype/storm/task/OutputCollectorImpl.java
@@ -30,7 +30,7 @@ public OutputCollectorImpl(TopologyContext context, IInternalOutputCollector col
_context = context;
_collector = collector;
}
-
+
public List<Integer> emit(String streamId, Collection<Tuple> anchors, List<Object> tuple) {
return _collector.emit(anchorTuple(anchors, streamId, tuple));
}
View
170 src/jvm/backtype/storm/tuple/IndifferentAccessMap.java
@@ -0,0 +1,170 @@
+package backtype.storm.tuple;
+
+
+import clojure.lang.ILookup;
+import clojure.lang.Seqable;
+import clojure.lang.Indexed;
+import clojure.lang.Counted;
+import clojure.lang.ISeq;
+import clojure.lang.ASeq;
+import clojure.lang.AFn;
+import clojure.lang.IPersistentMap;
+import clojure.lang.PersistentArrayMap;
+import clojure.lang.IMapEntry;
+import clojure.lang.IPersistentCollection;
+import clojure.lang.Obj;
+import clojure.lang.IMeta;
+import clojure.lang.Keyword;
+import clojure.lang.Symbol;
+import clojure.lang.MapEntry;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Collection;
+import java.util.Set;
+import clojure.lang.RT;
+
+public class IndifferentAccessMap extends AFn implements ILookup, IPersistentMap, Map {
+
+ protected IPersistentMap _map;
+
+ protected IndifferentAccessMap() {
+ }
+
+ public IndifferentAccessMap(IPersistentMap map) {
+ setMap(map);
+ }
+
+ public IPersistentMap getMap() {
+ return _map;
+ }
+
+ public IPersistentMap setMap(IPersistentMap map) {
+ _map = map;
+ return _map;
+ }
+
+ public int size() {
+ return ((Map) getMap()).size();
+ }
+
+ public int count() {
+ return size();
+ }
+
+ public ISeq seq() {
+ return getMap().seq();
+ }
+
+ @Override
+ public Object valAt(Object o) {
+ if(o instanceof Keyword) {
+ return valAt(((Keyword) o).getName());
+ }
+ return getMap().valAt(o);
+ }
+
+ @Override
+ public Object valAt(Object o, Object def) {
+ Object ret = valAt(o);
+ if(ret==null) ret = def;
+ return ret;
+ }
+
+ /* IFn */
+ @Override
+ public Object invoke(Object o) {
+ return valAt(o);
+ }
+
+ @Override
+ public Object invoke(Object o, Object notfound) {
+ return valAt(o, notfound);
+ }
+
+ /* IPersistentMap */
+ /* Naive implementation, but it might be good enough */
+ public IPersistentMap assoc(Object k, Object v) {
+ if(k instanceof Keyword) return assoc(((Keyword) k).getName(), v);
+
+ return new IndifferentAccessMap(getMap().assoc(k, v));
+ }
+
+ public IPersistentMap assocEx(Object k, Object v) throws Exception {
+ if(k instanceof Keyword) return assocEx(((Keyword) k).getName(), v);
+
+ return new IndifferentAccessMap(getMap().assocEx(k, v));
+ }
+
+ public IPersistentMap without(Object k) throws Exception {
+ if(k instanceof Keyword) return without(((Keyword) k).getName());
+
+ return new IndifferentAccessMap(getMap().without(k));
+ }
+
+ public boolean containsKey(Object k) {
+ if(k instanceof Keyword) return containsKey(((Keyword) k).getName());
+ return getMap().containsKey(k);
+ }
+
+ public IMapEntry entryAt(Object k) {
+ if(k instanceof Keyword) return entryAt(((Keyword) k).getName());
+
+ return getMap().entryAt(k);
+ }
+
+ public IPersistentCollection cons(Object o) {
+ return getMap().cons(o);
+ }
+
+ public IPersistentCollection empty() {
+ return new IndifferentAccessMap(PersistentArrayMap.EMPTY);
+ }
+
+ public boolean equiv(Object o) {
+ return getMap().equiv(o);
+ }
+
+ public Iterator iterator() {
+ return getMap().iterator();
+ }
+
+ /* Map */
+ public boolean containsValue(Object v) {
+ return ((Map) getMap()).containsValue(v);
+ }
+
+ public Set entrySet() {
+ return ((Map) getMap()).entrySet();
+ }
+
+ public Object get(Object k) {
+ return valAt(k);
+ }
+
+ public boolean isEmpty() {
+ return ((Map) getMap()).isEmpty();
+ }
+
+ public Set keySet() {
+ return ((Map) getMap()).keySet();
+ }
+
+ public Collection values() {
+ return ((Map) getMap()).values();
+ }
+
+ /* Not implemented */
+ public void clear() {
+ throw new UnsupportedOperationException();
+ }
+ public Object put(Object k, Object v) {
+ throw new UnsupportedOperationException();
+ }
+ public void putAll(Map m) {
+ throw new UnsupportedOperationException();
+ }
+ public Object remove(Object k) {
+ throw new UnsupportedOperationException();
+ }
+}
View
140 src/jvm/backtype/storm/tuple/Tuple.java
@@ -3,11 +3,28 @@
import backtype.storm.generated.GlobalStreamId;
import backtype.storm.task.TopologyContext;
import clojure.lang.ILookup;
+import clojure.lang.Seqable;
+import clojure.lang.Indexed;
+import clojure.lang.Counted;
+import clojure.lang.ISeq;
+import clojure.lang.ASeq;
+import clojure.lang.AFn;
+import clojure.lang.IPersistentMap;
+import clojure.lang.PersistentArrayMap;
+import clojure.lang.IMapEntry;
+import clojure.lang.IPersistentCollection;
+import clojure.lang.Obj;
+import clojure.lang.IMeta;
import clojure.lang.Keyword;
import clojure.lang.Symbol;
+import clojure.lang.MapEntry;
+import java.util.Iterator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.Collection;
+import java.util.Set;
+import clojure.lang.RT;
/**
* The tuple is the main data structure in Storm. A tuple is a named list of values,
@@ -20,20 +37,26 @@
* use another type, you'll need to implement and register a serializer for that type.
* See {@link http://github.com/nathanmarz/storm/wiki/Serialization} for more info.
*/
-public class Tuple implements ILookup {
+public class Tuple extends IndifferentAccessMap implements Seqable, Indexed, IMeta {
private List<Object> values;
private int taskId;
private String streamId;
private TopologyContext context;
private MessageId id;
-
+ private IPersistentMap _meta;
+
//needs to get taskId explicitly b/c could be in a different task than where it was created
public Tuple(TopologyContext context, List<Object> values, int taskId, String streamId, MessageId id) {
+ super();
this.values = values;
this.taskId = taskId;
this.streamId = streamId;
this.id = id;
this.context = context;
+ this._meta = new PersistentArrayMap( new Object[] {
+ makeKeyword("stream"), getSourceStreamId(),
+ makeKeyword("component"), getSourceComponent(),
+ makeKeyword("task"), getSourceTask()});
String componentId = context.getComponentId(taskId);
Fields schema = context.getComponentOutputFields(componentId, streamId);
@@ -264,30 +287,109 @@ public int hashCode() {
return System.identityHashCode(this);
}
- private static final Keyword makeKeyword(String name) {
+ private final Keyword makeKeyword(String name) {
return Keyword.intern(Symbol.create(name));
- }
-
- private static final Keyword STREAM_KEYWORD = makeKeyword("stream");
- private static final Keyword COMPONENT_KEYWORD = makeKeyword("component");
- private static final Keyword TASK_KEYWORD = makeKeyword("task");
-
+ }
+
+ /* ILookup */
@Override
public Object valAt(Object o) {
- if(o.equals(STREAM_KEYWORD)) {
- return getSourceStreamId();
- } else if(o.equals(COMPONENT_KEYWORD)) {
- return getSourceComponent();
- } else if(o.equals(TASK_KEYWORD)) {
- return getSourceTask();
+ try {
+ if(o instanceof Keyword) {
+ return getValueByField(((Keyword) o).getName());
+ } else if(o instanceof String) {
+ return getValueByField((String) o);
+ }
+ } catch(IllegalArgumentException e) {
}
return null;
}
- @Override
- public Object valAt(Object o, Object def) {
- Object ret = valAt(o);
- if(ret==null) ret = def;
+ /* Seqable */
+ public ISeq seq() {
+ if(values.size() > 0) {
+ return new Seq(getFields().toList(), values, 0);
+ }
+ return null;
+ }
+
+ static class Seq extends ASeq implements Counted {
+ final List<String> fields;
+ final List<Object> values;
+ final int i;
+
+ Seq(List<String> fields, List<Object> values, int i) {
+ this.fields = fields;
+ this.values = values;
+ this.i = i;
+ }
+
+ public Seq(IPersistentMap meta, List<String> fields, List<Object> values, int i) {
+ super(meta);
+ this.fields= fields;
+ this.values = values;
+ this.i = i;
+ }
+
+ public Object first() {
+ return new MapEntry(fields.get(i), values.get(i));
+ }
+
+ public ISeq next() {
+ if(i+1 < fields.size()) {
+ return new Seq(fields, values, i+1);
+ }
+ return null;
+ }
+
+ public int count() {
+ return fields.size();
+ }
+
+ public Obj withMeta(IPersistentMap meta) {
+ return new Seq(meta, fields, values, i);
+ }
+ }
+
+ /* Indexed */
+ public Object nth(int i) {
+ if(i < values.size()) {
+ return values.get(i);
+ } else {
+ return null;
+ }
+ }
+
+ public Object nth(int i, Object notfound) {
+ Object ret = nth(i);
+ if(ret==null) ret = notfound;
return ret;
}
+
+ /* Counted */
+ public int count() {
+ return values.size();
+ }
+
+ /* IMeta */
+ public IPersistentMap meta() {
+ return _meta;
+ }
+
+ private PersistentArrayMap toMap() {
+ Object array[] = new Object[values.size()*2];
+ List<String> fields = getFields().toList();
+ for(int i=0; i < values.size(); i++) {
+ array[i*2] = fields.get(i);
+ array[(i*2)+1] = values.get(i);
+ }
+ return new PersistentArrayMap(array);
+ }
+
+ public IPersistentMap getMap() {
+ if(_map==null) {
+ setMap(toMap());
+ }
+ return _map;
+ }
}
View
40 test/clj/backtype/storm/integration_test.clj
@@ -153,8 +153,8 @@
(defbolt lalala-bolt1 ["word"] [tuple collector]
(let [ret (-> (.getValue tuple 0) (str "lalala"))]
- (.emit collector tuple [ret])
- (.ack collector tuple)
+ (.emit (:output-collector collector) tuple [ret])
+ (.ack (:output-collector collector) tuple)
))
(defbolt lalala-bolt2 ["word"] {:prepare true}
@@ -164,8 +164,8 @@
(bolt
(execute [tuple]
(let [ret (-> (.getValue tuple 0) (str @state))]
- (.emit collector tuple [ret])
- (.ack collector tuple)
+ (.emit (:output-collector collector) tuple [ret])
+ (.ack (:output-collector collector) tuple)
))
)))
@@ -177,8 +177,8 @@
(reset! state (str prefix "lalala")))
(execute [tuple]
(let [ret (-> (.getValue tuple 0) (str @state))]
- (.emit collector tuple [ret])
- (.ack collector tuple)
+ (.emit (:output-collector collector) tuple [ret])
+ (.ack (:output-collector collector) tuple)
)))
))
@@ -205,6 +205,34 @@
(is (ms= [["david_nathan_lalala"] ["adam_nathan_lalala"]] (read-tuples results "4")))
)))
+(defbolt punctuator-bolt ["word" "period" "question" "exclamation"]
+ [tuple collector]
+ (if (= (:word tuple) "bar")
+ (do
+ (emit-bolt! collector {:word "bar" :period "bar" :question "bar"
+ "exclamation" "bar"})
+ (ack! collector tuple))
+ (let [ res (assoc tuple :period (str (:word tuple) "."))
+ res (assoc res :exclamation (str (:word tuple) "!"))
+ res (assoc res :question (str (:word tuple) "?")) ]
+ (emit-bolt! collector res)
+ (ack! collector tuple))))
+
+(deftest test-map-emit
+ (with-simulated-time-local-cluster [cluster :supervisors 4]
+ (let [topology (thrift/mk-topology
+ {"words" (thrift/mk-spout-spec (TestWordSpout. false))}
+ {"out" (thrift/mk-bolt-spec {"words" :shuffle}
+ punctuator-bolt)}
+ )
+ results (complete-topology cluster
+ topology
+ :mock-sources {"words" [["foo"] ["bar"]]}
+ )]
+ (is (ms= [["foo" "foo." "foo?" "foo!"]
+ ["bar" "bar" "bar" "bar"] ] (read-tuples results "out"))))))
+
+
(defn ack-tracking-feeder [fields]
(let [tracker (AckTracker.)]
[(doto (feeder-spout fields)
View
36 test/clj/backtype/storm/tuple_test.clj
@@ -0,0 +1,36 @@
+(ns backtype.storm.tuple-test
+ (:use [clojure test])
+ (:import [backtype.storm.tuple Tuple])
+ (:use [backtype.storm testing]))
+
+(deftest test-lookup
+ (let [ tuple (test-tuple [12 "hello"] :fields ["foo" "bar"]) ]
+ (is (= 12 (tuple "foo")))
+ (is (= 12 (tuple :foo)))
+ (is (= 12 (:foo tuple)))
+
+ (is (= "hello" (:bar tuple)))
+
+ (is (= :notfound (tuple "404" :notfound)))))
+
+(deftest test-indexed
+ (let [ tuple (test-tuple [12 "hello"] :fields ["foo" "bar"]) ]
+ (is (= 12 (nth tuple 0)))
+ (is (= "hello" (nth tuple 1)))))
+
+(deftest test-seq
+ (let [ tuple (test-tuple [12 "hello"] :fields ["foo" "bar"]) ]
+ (is (= [["foo" 12] ["bar" "hello"]] (seq tuple)))))
+
+(deftest test-map
+ (let [tuple (test-tuple [12 "hello"] :fields ["foo" "bar"]) ]
+ (is (= {"foo" 42 "bar" "hello"} (.getMap (assoc tuple "foo" 42))))
+ (is (= {"foo" 42 "bar" "hello"} (.getMap (assoc tuple :foo 42))))
+
+ (is (= {"bar" "hello"} (.getMap (dissoc tuple "foo"))))
+ (is (= {"bar" "hello"} (.getMap (dissoc tuple :foo))))
+
+ (is (= {"foo" 42 "bar" "world"} (.getMap (assoc
+ (assoc tuple "foo" 42)
+ :bar "world"))))))
+
Something went wrong with that request. Please try again.