-
Notifications
You must be signed in to change notification settings - Fork 0
/
diff-sequence.js
257 lines (229 loc) · 8.61 KB
/
diff-sequence.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
module.exports=function(Meteor) {
var _ = Meteor.underscore;
var EJSON = Meteor.EJSON;
var DiffSequence;
DiffSequence = {};
// ordered: bool.
// old_results and new_results: collections of documents.
// if ordered, they are arrays.
// if unordered, they are IdMaps
DiffSequence.diffQueryChanges = function (ordered, oldResults, newResults,
observer, options) {
if (ordered)
DiffSequence.diffQueryOrderedChanges(
oldResults, newResults, observer, options);
else
DiffSequence.diffQueryUnorderedChanges(
oldResults, newResults, observer, options);
};
DiffSequence.diffQueryUnorderedChanges = function (oldResults, newResults,
observer, options) {
options = options || {};
var projectionFn = options.projectionFn || EJSON.clone;
if (observer.movedBefore) {
throw new Error("_diffQueryUnordered called with a movedBefore observer!");
}
newResults.forEach(function (newDoc, id) {
var oldDoc = oldResults.get(id);
if (oldDoc) {
if (observer.changed && !EJSON.equals(oldDoc, newDoc)) {
var projectedNew = projectionFn(newDoc);
var projectedOld = projectionFn(oldDoc);
var changedFields =
DiffSequence.makeChangedFields(projectedNew, projectedOld);
if (! _.isEmpty(changedFields)) {
observer.changed(id, changedFields);
}
}
} else if (observer.added) {
var fields = projectionFn(newDoc);
delete fields._id;
observer.added(newDoc._id, fields);
}
});
if (observer.removed) {
oldResults.forEach(function (oldDoc, id) {
if (!newResults.has(id))
observer.removed(id);
});
}
};
DiffSequence.diffQueryOrderedChanges = function (old_results, new_results,
observer, options) {
options = options || {};
var projectionFn = options.projectionFn || EJSON.clone;
var new_presence_of_id = {};
_.each(new_results, function (doc) {
if (new_presence_of_id[doc._id])
Meteor._debug("Duplicate _id in new_results");
new_presence_of_id[doc._id] = true;
});
var old_index_of_id = {};
_.each(old_results, function (doc, i) {
if (doc._id in old_index_of_id)
Meteor._debug("Duplicate _id in old_results");
old_index_of_id[doc._id] = i;
});
// ALGORITHM:
//
// To determine which docs should be considered "moved" (and which
// merely change position because of other docs moving) we run
// a "longest common subsequence" (LCS) algorithm. The LCS of the
// old doc IDs and the new doc IDs gives the docs that should NOT be
// considered moved.
// To actually call the appropriate callbacks to get from the old state to the
// new state:
// First, we call removed() on all the items that only appear in the old
// state.
// Then, once we have the items that should not move, we walk through the new
// results array group-by-group, where a "group" is a set of items that have
// moved, anchored on the end by an item that should not move. One by one, we
// move each of those elements into place "before" the anchoring end-of-group
// item, and fire changed events on them if necessary. Then we fire a changed
// event on the anchor, and move on to the next group. There is always at
// least one group; the last group is anchored by a virtual "null" id at the
// end.
// Asymptotically: O(N k) where k is number of ops, or potentially
// O(N log N) if inner loop of LCS were made to be binary search.
//////// LCS (longest common sequence, with respect to _id)
// (see Wikipedia article on Longest Increasing Subsequence,
// where the LIS is taken of the sequence of old indices of the
// docs in new_results)
//
// unmoved: the output of the algorithm; members of the LCS,
// in the form of indices into new_results
var unmoved = [];
// max_seq_len: length of LCS found so far
var max_seq_len = 0;
// seq_ends[i]: the index into new_results of the last doc in a
// common subsequence of length of i+1 <= max_seq_len
var N = new_results.length;
var seq_ends = new Array(N);
// ptrs: the common subsequence ending with new_results[n] extends
// a common subsequence ending with new_results[ptr[n]], unless
// ptr[n] is -1.
var ptrs = new Array(N);
// virtual sequence of old indices of new results
var old_idx_seq = function(i_new) {
return old_index_of_id[new_results[i_new]._id];
};
// for each item in new_results, use it to extend a common subsequence
// of length j <= max_seq_len
for(var i=0; i<N; i++) {
if (old_index_of_id[new_results[i]._id] !== undefined) {
var j = max_seq_len;
// this inner loop would traditionally be a binary search,
// but scanning backwards we will likely find a subseq to extend
// pretty soon, bounded for example by the total number of ops.
// If this were to be changed to a binary search, we'd still want
// to scan backwards a bit as an optimization.
while (j > 0) {
if (old_idx_seq(seq_ends[j-1]) < old_idx_seq(i))
break;
j--;
}
ptrs[i] = (j === 0 ? -1 : seq_ends[j-1]);
seq_ends[j] = i;
if (j+1 > max_seq_len)
max_seq_len = j+1;
}
}
// pull out the LCS/LIS into unmoved
var idx = (max_seq_len === 0 ? -1 : seq_ends[max_seq_len-1]);
while (idx >= 0) {
unmoved.push(idx);
idx = ptrs[idx];
}
// the unmoved item list is built backwards, so fix that
unmoved.reverse();
// the last group is always anchored by the end of the result list, which is
// an id of "null"
unmoved.push(new_results.length);
_.each(old_results, function (doc) {
if (!new_presence_of_id[doc._id])
observer.removed && observer.removed(doc._id);
});
// for each group of things in the new_results that is anchored by an unmoved
// element, iterate through the things before it.
var startOfGroup = 0;
_.each(unmoved, function (endOfGroup) {
var groupId = new_results[endOfGroup] ? new_results[endOfGroup]._id : null;
var oldDoc, newDoc, fields, projectedNew, projectedOld;
for (var i = startOfGroup; i < endOfGroup; i++) {
newDoc = new_results[i];
if (!_.has(old_index_of_id, newDoc._id)) {
fields = projectionFn(newDoc);
delete fields._id;
observer.addedBefore && observer.addedBefore(newDoc._id, fields, groupId);
observer.added && observer.added(newDoc._id, fields);
} else {
// moved
oldDoc = old_results[old_index_of_id[newDoc._id]];
projectedNew = projectionFn(newDoc);
projectedOld = projectionFn(oldDoc);
fields = DiffSequence.makeChangedFields(projectedNew, projectedOld);
if (!_.isEmpty(fields)) {
observer.changed && observer.changed(newDoc._id, fields);
}
observer.movedBefore && observer.movedBefore(newDoc._id, groupId);
}
}
if (groupId) {
newDoc = new_results[endOfGroup];
oldDoc = old_results[old_index_of_id[newDoc._id]];
projectedNew = projectionFn(newDoc);
projectedOld = projectionFn(oldDoc);
fields = DiffSequence.makeChangedFields(projectedNew, projectedOld);
if (!_.isEmpty(fields)) {
observer.changed && observer.changed(newDoc._id, fields);
}
}
startOfGroup = endOfGroup+1;
});
};
// General helper for diff-ing two objects.
// callbacks is an object like so:
// { leftOnly: function (key, leftValue) {...},
// rightOnly: function (key, rightValue) {...},
// both: function (key, leftValue, rightValue) {...},
// }
DiffSequence.diffObjects = function (left, right, callbacks) {
_.each(left, function (leftValue, key) {
if (_.has(right, key))
callbacks.both && callbacks.both(key, leftValue, right[key]);
else
callbacks.leftOnly && callbacks.leftOnly(key, leftValue);
});
if (callbacks.rightOnly) {
_.each(right, function(rightValue, key) {
if (!_.has(left, key))
callbacks.rightOnly(key, rightValue);
});
}
};
DiffSequence.makeChangedFields = function (newDoc, oldDoc) {
var fields = {};
DiffSequence.diffObjects(oldDoc, newDoc, {
leftOnly: function (key, value) {
fields[key] = undefined;
},
rightOnly: function (key, value) {
fields[key] = value;
},
both: function (key, leftValue, rightValue) {
if (!EJSON.equals(leftValue, rightValue))
fields[key] = rightValue;
}
});
return fields;
};
DiffSequence.applyChanges = function (doc, changeFields) {
_.each(changeFields, function (value, key) {
if (value === undefined)
delete doc[key];
else
doc[key] = value;
});
};
Meteor.DiffSequence = DiffSequence;
};