sosj
Version:
Sorted Object Stream Join -- Joins two sorted objectMode streams based on a provided join key.
137 lines (120 loc) • 4 kB
JavaScript
module.exports = align
var Transform = require("stream").Transform
|| require("readable-stream/transform")
var inherits = require("util").inherits
var map = require("through2-map")
/**
* align takes two objectMode streams and a sequence key and will create a stream
* aligning records from each stream resulting in a stream that emits record doublets
* when the sequence key exactly matches. IMPORTANT: This only works on ordered streams!
* @param {String} key The sequence key the two streams are storted by and will be joined by.
* @param {Stream} leftStream An objectMode stream ordered by `key`
* @param {Stream} rightStream An objectMode stream ordered by `key`
* @return {Stream} A doublet stream containing [leftRecord, rightRecord], [leftRecord, null], or [null, rightRecord]
*/
function align(key, leftStream, rightStream, epsilon) {
// TODO probably need to add stream options support for thigns like high water mark
var aligner = new Aligner(key, {}, epsilon)
var l = left()
var r = right()
var ended = 0
l.on("end", function () {
l.unpipe(aligner)
if (++ended == 2) aligner.end()
})
r.on("end", function () {
r.unpipe(aligner)
if (++ended == 2) aligner.end()
})
leftStream.pipe(l).pipe(aligner, {end: false})
rightStream.pipe(r).pipe(aligner, {end: false})
return aligner
}
function left(stream) {
return map({objectMode: true}, function (record) {
return [record, null]
})
}
function right(stream) {
return map({objectMode: true}, function (record) {
return [null, record]
})
}
function Aligner(key, options, epsilon) {
if (!(this instanceof Aligner)) return new Aligner(key, options, epsilon)
if (!key) throw new Error("Aligner requires a sequence key")
this.key = key
this.epsilon = epsilon
// This MUST be an objectMode stream.
options = options || {}
options.objectMode = true
Transform.call(this, options)
this.queue = [[], []]
}
inherits(Aligner, Transform)
Aligner.prototype._transform = function (record, encoding, callback) {
var i = (record[0] != null) ? 0 : 1
var o = +(!i)
var key = this.key
var me = record[i]
var other, otherRecord
var myQueue = this.queue[i]
var otherQueue = this.queue[o]
if (myQueue.length > 0 || otherQueue.length == 0) {
// My queue is not empty, or the other queue is empty.
myQueue.push(record)
return callback()
}
while (otherRecord = otherQueue.shift()) {
other = otherRecord[o]
if (me[key] == other[key] || compareDelta(me[key], other[key], this.epsilon)) {
// These keys are joined, w00t
record[o] = other
this.push(record)
return callback()
}
else if (me[key] instanceof Date && other[key] instanceof Date && me[key].valueOf() == other[key].valueOf()) {
// These keys are joined, w00t
record[o] = other
this.push(record)
return callback()
}
else if (me[key] < other[key]) {
// This record pre-dates all seen records on the other's queue
otherQueue.unshift(otherRecord)
this.push(record)
return callback()
}
else {
// This comes after the earliest leftStream record.
// Emit that record and continue to the next leftStream record.
this.push(otherRecord)
}
}
myQueue.push(record)
return callback()
}
Aligner.prototype._flush = function (callback) {
// At this point only one queue should have any items in it.
if (this.queue[0].length && this.queue[1].length)
return callback(new Error("Incomplete align!"))
var self = this
this.queue.map(function (q) {
q.map(function (r) { self.push(r) })
})
return callback()
}
function compareDelta(val1, val2, epsilon) {
if (!epsilon) return false
var compareDouble = function(e, a, d) {
return Math.abs(e - a) <= d
}
if (val1 === val2) {
return true
}
if (typeof val1 == "number" ||
typeof val2 == "number" ||
!val1 || !val2) {
return compareDouble(val1, val2, epsilon)
}
}