es6(db): Convert the ORM specs to ES2016

This commit is contained in:
Ben Gotow 2016-10-14 12:06:07 -07:00
parent 51f9001c21
commit ed8b0b222e
20 changed files with 1975 additions and 1588 deletions

View file

@ -1,32 +0,0 @@
_ = require 'underscore'
Model = require '../src/flux/models/model'
Attributes = require('../src/flux/attributes').default
DatabaseObjectRegistry = require('../src/database-object-registry').default
class GoodTest extends Model
@attributes: _.extend {}, Model.attributes,
"foo": Attributes.String
modelKey: 'foo'
jsonKey: 'foo'
describe 'DatabaseObjectRegistry', ->
beforeEach ->
DatabaseObjectRegistry.unregister("GoodTest")
it "can register constructors", ->
testFn = -> GoodTest
expect( -> DatabaseObjectRegistry.register("GoodTest", testFn)).not.toThrow()
expect(DatabaseObjectRegistry.get("GoodTest")).toBe GoodTest
it "Tests if a constructor is in the registry", ->
DatabaseObjectRegistry.register("GoodTest", -> GoodTest)
expect(DatabaseObjectRegistry.isInRegistry("GoodTest")).toBe true
it "deserializes the objects for a constructor", ->
DatabaseObjectRegistry.register("GoodTest", -> GoodTest)
obj = DatabaseObjectRegistry.deserialize("GoodTest", foo: "bar")
expect(obj instanceof GoodTest).toBe true
expect(obj.foo).toBe "bar"
it "throws an error if the object can't be deserialized", ->
expect( -> DatabaseObjectRegistry.deserialize("GoodTest", foo: "bar")).toThrow()

View file

@ -0,0 +1,40 @@
/* eslint quote-props: 0 */
import _ from 'underscore';
import Model from '../src/flux/models/model';
import Attributes from '../src/flux/attributes';
import DatabaseObjectRegistry from '../src/database-object-registry';
class GoodTest extends Model {
static attributes = _.extend({}, Model.attributes, {
"foo": Attributes.String({
modelKey: 'foo',
jsonKey: 'foo',
}),
});
}
describe('DatabaseObjectRegistry', function DatabaseObjectRegistrySpecs() {
beforeEach(() => DatabaseObjectRegistry.unregister("GoodTest"));
it("can register constructors", () => {
const testFn = () => GoodTest;
expect(() => DatabaseObjectRegistry.register("GoodTest", testFn)).not.toThrow();
expect(DatabaseObjectRegistry.get("GoodTest")).toBe(GoodTest);
});
it("Tests if a constructor is in the registry", () => {
DatabaseObjectRegistry.register("GoodTest", () => GoodTest);
expect(DatabaseObjectRegistry.isInRegistry("GoodTest")).toBe(true);
});
it("deserializes the objects for a constructor", () => {
DatabaseObjectRegistry.register("GoodTest", () => GoodTest);
const obj = DatabaseObjectRegistry.deserialize("GoodTest", {foo: "bar"});
expect(obj instanceof GoodTest).toBe(true);
expect(obj.foo).toBe("bar");
});
it("throws an error if the object can't be deserialized", () =>
expect(() => DatabaseObjectRegistry.deserialize("GoodTest", {foo: "bar"})).toThrow()
);
});

View file

@ -1,239 +0,0 @@
Model = require '../../src/flux/models/model'
Utils = require '../../src/flux/models/utils'
Attributes = require('../../src/flux/attributes').default
{isTempId} = require '../../src/flux/models/utils'
_ = require 'underscore'
describe "Model", ->
describe "constructor", ->
it "should accept a hash of attributes and assign them to the new Model", ->
attrs =
id: "A",
accountId: "B"
m = new Model(attrs)
expect(m.id).toBe(attrs.id)
expect(m.accountId).toBe(attrs.accountId)
it "by default assigns things passed into the id constructor to the serverId", ->
attrs =
id: "A",
m = new Model(attrs)
expect(m.serverId).toBe(attrs.id)
it "by default assigns values passed into the id constructor that look like localIds to be a localID", ->
attrs =
id: "A",
m = new Model(attrs)
expect(m.serverId).toBe(attrs.id)
it "assigns serverIds and clientIds", ->
attrs =
clientId: "local-A",
serverId: "A",
m = new Model(attrs)
expect(m.serverId).toBe(attrs.serverId)
expect(m.clientId).toBe(attrs.clientId)
expect(m.id).toBe(attrs.serverId)
it "throws an error if you attempt to manually assign the id", ->
m = new Model(id: "foo")
expect( -> m.id = "bar" ).toThrow()
it "automatically assigns a clientId (and id) to the model if no id is provided", ->
m = new Model
expect(Utils.isTempId(m.id)).toBe true
expect(Utils.isTempId(m.clientId)).toBe true
expect(m.serverId).toBeUndefined()
describe "attributes", ->
it "should return the attributes of the class EXCEPT the id field", ->
m = new Model()
retAttrs = _.clone(m.constructor.attributes)
delete retAttrs["id"]
expect(m.attributes()).toEqual(retAttrs)
describe "clone", ->
it "should return a deep copy of the object", ->
class SubSubmodel extends Model
@attributes: _.extend {}, Model.attributes,
'value': Attributes.Number
modelKey: 'value'
jsonKey: 'value'
class Submodel extends Model
@attributes: _.extend {}, Model.attributes,
'testNumber': Attributes.Number
modelKey: 'testNumber'
jsonKey: 'test_number'
'testArray': Attributes.Collection
itemClass: SubSubmodel
modelKey: 'testArray'
jsonKey: 'test_array'
old = new Submodel(testNumber: 4, testArray: [new SubSubmodel(value: 2), new SubSubmodel(value: 6)])
clone = old.clone()
# Check entire trees are equivalent
expect(old.toJSON()).toEqual(clone.toJSON())
# Check object identity has changed
expect(old.constructor.name).toEqual(clone.constructor.name)
expect(old.testArray).not.toBe(clone.testArray)
# Check classes
expect(old.testArray[0]).not.toBe(clone.testArray[0])
expect(old.testArray[0].constructor.name).toEqual(clone.testArray[0].constructor.name)
describe "fromJSON", ->
beforeEach ->
class SubmodelItem extends Model
class Submodel extends Model
@attributes: _.extend {}, Model.attributes,
'testNumber': Attributes.Number
modelKey: 'testNumber'
jsonKey: 'test_number'
'testBoolean': Attributes.Boolean
modelKey: 'testBoolean'
jsonKey: 'test_boolean'
'testCollection': Attributes.Collection
modelKey: 'testCollection'
jsonKey: 'test_collection'
itemClass: SubmodelItem
'testJoinedData': Attributes.JoinedData
modelKey: 'testJoinedData'
jsonKey: 'test_joined_data'
@json =
'id': '1234'
'test_number': 4
'test_boolean': true
'daysOld': 4
'account_id': 'bla'
@m = new Submodel
it "should assign attribute values by calling through to attribute fromJSON functions", ->
spyOn(Model.attributes.accountId, 'fromJSON').andCallFake (json) ->
'inflated value!'
@m.fromJSON(@json)
expect(Model.attributes.accountId.fromJSON.callCount).toBe 1
expect(@m.accountId).toBe('inflated value!')
it "should not touch attributes that are missing in the json", ->
@m.fromJSON(@json)
expect(@m.object).toBe(undefined)
@m.object = 'abc'
@m.fromJSON(@json)
expect(@m.object).toBe('abc')
it "should not do anything with extra JSON keys", ->
@m.fromJSON(@json)
expect(@m.daysOld).toBe(undefined)
it "should maintain empty string as empty strings", ->
expect(@m.accountId).toBe(undefined)
@m.fromJSON({account_id: ''})
expect(@m.accountId).toBe('')
describe "Attributes.Number", ->
it "should read number attributes and coerce them to numeric values", ->
@m.fromJSON('test_number': 4)
expect(@m.testNumber).toBe(4)
@m.fromJSON('test_number': '4')
expect(@m.testNumber).toBe(4)
@m.fromJSON('test_number': 'lolz')
expect(@m.testNumber).toBe(null)
@m.fromJSON('test_number': 0)
expect(@m.testNumber).toBe(0)
describe "Attributes.JoinedData", ->
it "should read joined data attributes and coerce them to string values", ->
@m.fromJSON('test_joined_data': null)
expect(@m.testJoinedData).toBe(null)
@m.fromJSON('test_joined_data': '')
expect(@m.testJoinedData).toBe('')
@m.fromJSON('test_joined_data': 'lolz')
expect(@m.testJoinedData).toBe('lolz')
describe "Attributes.Collection", ->
it "should parse and inflate items", ->
@m.fromJSON('test_collection': [{id: '123'}])
expect(@m.testCollection.length).toBe(1)
expect(@m.testCollection[0].id).toBe('123')
expect(@m.testCollection[0].constructor.name).toBe('SubmodelItem')
it "should be fine with malformed arrays", ->
@m.fromJSON('test_collection': [null])
expect(@m.testCollection.length).toBe(0)
@m.fromJSON('test_collection': [])
expect(@m.testCollection.length).toBe(0)
@m.fromJSON('test_collection': null)
expect(@m.testCollection.length).toBe(0)
describe "Attributes.Boolean", ->
it "should read `true` or true and coerce everything else to false", ->
@m.fromJSON('test_boolean': true)
expect(@m.testBoolean).toBe(true)
@m.fromJSON('test_boolean': 'true')
expect(@m.testBoolean).toBe(true)
@m.fromJSON('test_boolean': 4)
expect(@m.testBoolean).toBe(false)
@m.fromJSON('test_boolean': '4')
expect(@m.testBoolean).toBe(false)
@m.fromJSON('test_boolean': false)
expect(@m.testBoolean).toBe(false)
@m.fromJSON('test_boolean': 0)
expect(@m.testBoolean).toBe(false)
@m.fromJSON('test_boolean': null)
expect(@m.testBoolean).toBe(false)
describe "toJSON", ->
beforeEach ->
@model = new Model
id: "1234",
accountId: "ACD"
it "should return a JSON object and call attribute toJSON functions to map values", ->
spyOn(Model.attributes.accountId, 'toJSON').andCallFake (json) ->
'inflated value!'
json = @model.toJSON()
expect(json instanceof Object).toBe(true)
expect(json.id).toBe('1234')
expect(json.account_id).toBe('inflated value!')
it "should surface any exception one of the attribute toJSON functions raises", ->
spyOn(Model.attributes.accountId, 'toJSON').andCallFake (json) ->
throw new Error("Can't convert value into JSON format")
expect(-> @model.toJSON()).toThrow()
describe "matches", ->
beforeEach ->
@model = new Model
id: "1234",
accountId: "ACD"
@truthyMatcher =
evaluate: (model) -> true
@falsyMatcher =
evaluate: (model) -> false
it "should run the matchers and return true iff all matchers pass", ->
expect(@model.matches([@truthyMatcher, @truthyMatcher])).toBe(true)
expect(@model.matches([@truthyMatcher, @falsyMatcher])).toBe(false)
expect(@model.matches([@falsyMatcher, @truthyMatcher])).toBe(false)
it "should pass itself as an argument to the matchers", ->
spyOn(@truthyMatcher, 'evaluate').andCallFake (param) =>
expect(param).toBe(@model)
@model.matches([@truthyMatcher])

294
spec/models/model-spec.es6 Normal file
View file

@ -0,0 +1,294 @@
/* eslint quote-props: 0 */
import Model from '../../src/flux/models/model';
import Utils from '../../src/flux/models/utils';
import Attributes from '../../src/flux/attributes';
import _ from 'underscore';
describe("Model", function modelSpecs() {
describe("constructor", () => {
it("should accept a hash of attributes and assign them to the new Model", () => {
const attrs = {
id: "A",
accountId: "B",
};
const m = new Model(attrs);
expect(m.id).toBe(attrs.id);
return expect(m.accountId).toBe(attrs.accountId);
});
it("by default assigns things passed into the id constructor to the serverId", () => {
const attrs = {id: "A"};
const m = new Model(attrs);
return expect(m.serverId).toBe(attrs.id);
});
it("by default assigns values passed into the id constructor that look like localIds to be a localID", () => {
const attrs = {id: "A"};
const m = new Model(attrs);
return expect(m.serverId).toBe(attrs.id);
});
it("assigns serverIds and clientIds", () => {
const attrs = {
clientId: "local-A",
serverId: "A",
};
const m = new Model(attrs);
expect(m.serverId).toBe(attrs.serverId);
expect(m.clientId).toBe(attrs.clientId);
return expect(m.id).toBe(attrs.serverId);
});
it("throws an error if you attempt to manually assign the id", () => {
const m = new Model({id: "foo"});
return expect(() => { m.id = "bar" }).toThrow();
});
return it("automatically assigns a clientId (and id) to the model if no id is provided", () => {
const m = new Model;
expect(Utils.isTempId(m.id)).toBe(true);
expect(Utils.isTempId(m.clientId)).toBe(true);
return expect(m.serverId).toBeUndefined();
});
});
describe("attributes", () =>
it("should return the attributes of the class EXCEPT the id field", () => {
const m = new Model();
const retAttrs = _.clone(m.constructor.attributes);
delete retAttrs.id;
return expect(m.attributes()).toEqual(retAttrs);
})
);
describe("clone", () =>
it("should return a deep copy of the object", () => {
class SubSubmodel extends Model {
static attributes = Object.assign({}, Model.attributes, {
'value': Attributes.Number({
modelKey: 'value',
jsonKey: 'value',
}),
});
}
class Submodel extends Model {
static attributes = Object.assign({}, Model.attributes, {
'testNumber': Attributes.Number({
modelKey: 'testNumber',
jsonKey: 'test_number',
}),
'testArray': Attributes.Collection({
itemClass: SubSubmodel,
modelKey: 'testArray',
jsonKey: 'test_array',
}),
});
}
const old = new Submodel({testNumber: 4, testArray: [new SubSubmodel({value: 2}), new SubSubmodel({value: 6})]});
const clone = old.clone();
// Check entire trees are equivalent
expect(old.toJSON()).toEqual(clone.toJSON());
// Check object identity has changed
expect(old.constructor.name).toEqual(clone.constructor.name);
expect(old.testArray).not.toBe(clone.testArray);
// Check classes
expect(old.testArray[0]).not.toBe(clone.testArray[0]);
return expect(old.testArray[0].constructor.name).toEqual(clone.testArray[0].constructor.name);
})
);
describe("fromJSON", () => {
beforeEach(() => {
class SubmodelItem extends Model {}
class Submodel extends Model {
static attributes = Object.assign({}, Model.attributes, {
'testNumber': Attributes.Number({
modelKey: 'testNumber',
jsonKey: 'test_number',
}),
'testBoolean': Attributes.Boolean({
modelKey: 'testBoolean',
jsonKey: 'test_boolean',
}),
'testCollection': Attributes.Collection({
modelKey: 'testCollection',
jsonKey: 'test_collection',
itemClass: SubmodelItem,
}),
'testJoinedData': Attributes.JoinedData({
modelKey: 'testJoinedData',
jsonKey: 'test_joined_data',
}),
});
}
this.json = {
'id': '1234',
'test_number': 4,
'test_boolean': true,
'daysOld': 4,
'account_id': 'bla',
};
this.m = new Submodel;
});
it("should assign attribute values by calling through to attribute fromJSON functions", () => {
spyOn(Model.attributes.accountId, 'fromJSON').andCallFake(() => 'inflated value!');
this.m.fromJSON(this.json);
expect(Model.attributes.accountId.fromJSON.callCount).toBe(1);
return expect(this.m.accountId).toBe('inflated value!');
});
it("should not touch attributes that are missing in the json", () => {
this.m.fromJSON(this.json);
expect(this.m.object).toBe(undefined);
this.m.object = 'abc';
this.m.fromJSON(this.json);
return expect(this.m.object).toBe('abc');
});
it("should not do anything with extra JSON keys", () => {
this.m.fromJSON(this.json);
return expect(this.m.daysOld).toBe(undefined);
});
it("should maintain empty string as empty strings", () => {
expect(this.m.accountId).toBe(undefined);
this.m.fromJSON({account_id: ''});
return expect(this.m.accountId).toBe('');
});
describe("Attributes.Number", () =>
it("should read number attributes and coerce them to numeric values", () => {
this.m.fromJSON({'test_number': 4});
expect(this.m.testNumber).toBe(4);
this.m.fromJSON({'test_number': '4'});
expect(this.m.testNumber).toBe(4);
this.m.fromJSON({'test_number': 'lolz'});
expect(this.m.testNumber).toBe(null);
this.m.fromJSON({'test_number': 0});
return expect(this.m.testNumber).toBe(0);
})
);
describe("Attributes.JoinedData", () =>
it("should read joined data attributes and coerce them to string values", () => {
this.m.fromJSON({'test_joined_data': null});
expect(this.m.testJoinedData).toBe(null);
this.m.fromJSON({'test_joined_data': ''});
expect(this.m.testJoinedData).toBe('');
this.m.fromJSON({'test_joined_data': 'lolz'});
return expect(this.m.testJoinedData).toBe('lolz');
})
);
describe("Attributes.Collection", () => {
it("should parse and inflate items", () => {
this.m.fromJSON({'test_collection': [{id: '123'}]});
expect(this.m.testCollection.length).toBe(1);
expect(this.m.testCollection[0].id).toBe('123');
return expect(this.m.testCollection[0].constructor.name).toBe('SubmodelItem');
});
return it("should be fine with malformed arrays", () => {
this.m.fromJSON({'test_collection': [null]});
expect(this.m.testCollection.length).toBe(0);
this.m.fromJSON({'test_collection': []});
expect(this.m.testCollection.length).toBe(0);
this.m.fromJSON({'test_collection': null});
return expect(this.m.testCollection.length).toBe(0);
});
});
return describe("Attributes.Boolean", () =>
it("should read `true` or true and coerce everything else to false", () => {
this.m.fromJSON({'test_boolean': true});
expect(this.m.testBoolean).toBe(true);
this.m.fromJSON({'test_boolean': 'true'});
expect(this.m.testBoolean).toBe(true);
this.m.fromJSON({'test_boolean': 4});
expect(this.m.testBoolean).toBe(false);
this.m.fromJSON({'test_boolean': '4'});
expect(this.m.testBoolean).toBe(false);
this.m.fromJSON({'test_boolean': false});
expect(this.m.testBoolean).toBe(false);
this.m.fromJSON({'test_boolean': 0});
expect(this.m.testBoolean).toBe(false);
this.m.fromJSON({'test_boolean': null});
return expect(this.m.testBoolean).toBe(false);
})
);
});
describe("toJSON", () => {
beforeEach(() => {
this.model = new Model({
id: "1234",
accountId: "ACD",
});
return;
});
it("should return a JSON object and call attribute toJSON functions to map values", () => {
spyOn(Model.attributes.accountId, 'toJSON').andCallFake(() => 'inflated value!');
const json = this.model.toJSON();
expect(json instanceof Object).toBe(true);
expect(json.id).toBe('1234');
return expect(json.account_id).toBe('inflated value!');
});
return it("should surface any exception one of the attribute toJSON functions raises", () => {
spyOn(Model.attributes.accountId, 'toJSON').andCallFake(() => {
throw new Error("Can't convert value into JSON format");
});
return expect(() => { return this.model.toJSON(); }).toThrow();
});
});
return describe("matches", () => {
beforeEach(() => {
this.model = new Model({
id: "1234",
accountId: "ACD",
});
this.truthyMatcher = {evaluate() { return true; }};
this.falsyMatcher = {evaluate() { return false; }};
});
it("should run the matchers and return true iff all matchers pass", () => {
expect(this.model.matches([this.truthyMatcher, this.truthyMatcher])).toBe(true);
expect(this.model.matches([this.truthyMatcher, this.falsyMatcher])).toBe(false);
return expect(this.model.matches([this.falsyMatcher, this.truthyMatcher])).toBe(false);
});
return it("should pass itself as an argument to the matchers", () => {
spyOn(this.truthyMatcher, 'evaluate').andCallFake(param => {
return expect(param).toBe(this.model);
});
return this.model.matches([this.truthyMatcher]);
});
});
});

View file

@ -1,130 +0,0 @@
MutableQueryResultSet = require('../../src/flux/models/mutable-query-result-set').default
QueryRange = require('../../src/flux/models/query-range').default
_ = require 'underscore'
describe "MutableQueryResultSet", ->
describe "clipToRange", ->
it "should do nothing if the clipping range is infinite", ->
set = new MutableQueryResultSet(_ids: ['A','B','C','D','E'], _offset: 5)
beforeRange = set.range()
set.clipToRange(QueryRange.infinite())
afterRange = set.range()
expect(beforeRange.isEqual(afterRange)).toBe(true)
it "should correctly trim the result set 5-10 to the clipping range 2-9", ->
set = new MutableQueryResultSet(_ids: ['A','B','C','D','E'], _offset: 5)
expect(set.range().isEqual(new QueryRange(offset: 5, limit: 5))).toBe(true)
set.clipToRange(new QueryRange(offset: 2, limit: 7))
expect(set.range().isEqual(new QueryRange(offset: 5, limit: 4))).toBe(true)
expect(set.ids()).toEqual(['A','B','C','D'])
it "should correctly trim the result set 5-10 to the clipping range 5-10", ->
set = new MutableQueryResultSet(_ids: ['A','B','C','D','E'], _offset: 5)
set.clipToRange(new QueryRange(start: 5, end: 10))
expect(set.range().isEqual(new QueryRange(start: 5, end: 10))).toBe(true)
expect(set.ids()).toEqual(['A','B','C','D','E'])
it "should correctly trim the result set 5-10 to the clipping range 6", ->
set = new MutableQueryResultSet(_ids: ['A','B','C','D','E'], _offset: 5)
set.clipToRange(new QueryRange(offset: 6, limit: 1))
expect(set.range().isEqual(new QueryRange(offset: 6, limit: 1))).toBe(true)
expect(set.ids()).toEqual(['B'])
it "should correctly trim the result set 5-10 to the clipping range 100-200", ->
set = new MutableQueryResultSet(_ids: ['A','B','C','D','E'], _offset: 5)
set.clipToRange(new QueryRange(start: 100, end: 200))
expect(set.range().isEqual(new QueryRange(start: 100, end: 100))).toBe(true)
expect(set.ids()).toEqual([])
it "should correctly trim the result set 5-10 to the clipping range 0-2", ->
set = new MutableQueryResultSet(_ids: ['A','B','C','D','E'], _offset: 5)
set.clipToRange(new QueryRange(offset: 0, limit: 2))
expect(set.range().isEqual(new QueryRange(offset: 5, limit: 0))).toBe(true)
expect(set.ids()).toEqual([])
it "should trim the models cache to remove models no longer needed", ->
set = new MutableQueryResultSet
_ids: ['A','B','C','D','E'],
_offset: 5
_modelsHash: {
'A-local': {id: 'A', clientId: 'A-local'},
'A': {id: 'A', clientId: 'A-local'},
'B-local': {id: 'B', clientId: 'B-local'},
'B': {id: 'B', clientId: 'B-local'},
'C-local': {id: 'C', clientId: 'C-local'},
'C': {id: 'C', clientId: 'C-local'},
'D-local': {id: 'D', clientId: 'D-local'},
'D': {id: 'D', clientId: 'D-local'},
'E-local': {id: 'E', clientId: 'E-local'},
'E': {id: 'E', clientId: 'E-local'}
}
set.clipToRange(new QueryRange(start: 5, end: 8))
expect(set._modelsHash).toEqual({
'A-local': {id: 'A', clientId: 'A-local'},
'A': {id: 'A', clientId: 'A-local'},
'B-local': {id: 'B', clientId: 'B-local'},
'B': {id: 'B', clientId: 'B-local'},
'C-local': {id: 'C', clientId: 'C-local'},
'C': {id: 'C', clientId: 'C-local'},
})
describe "addIdsInRange", ->
describe "when the set is currently empty", ->
it "should set the result set to the provided one", ->
@set = new MutableQueryResultSet()
@set.addIdsInRange(['B','C','D'], new QueryRange(start: 1, end: 4))
expect(@set.ids()).toEqual(['B','C','D'])
expect(@set.range().isEqual(new QueryRange(start: 1, end: 4))).toBe(true)
describe "when the set has existing values", ->
beforeEach ->
@set = new MutableQueryResultSet
_ids: ['A','B','C','D','E'],
_offset: 5
_modelsHash: {'A': {id: 'A'}, 'B': {id: 'B'}, 'C': {id: 'C'}, 'D': {id: 'D'}, 'E': {id: 'E'}}
it "should throw an exception if the range provided doesn't intersect (trailing)", ->
expect =>
@set.addIdsInRange(['G', 'H', 'I'], new QueryRange(offset: 11, limit: 3))
.toThrow()
expect =>
@set.addIdsInRange(['F', 'G', 'H'], new QueryRange(offset: 10, limit: 3))
.not.toThrow()
it "should throw an exception if the range provided doesn't intersect (leading)", ->
expect =>
@set.addIdsInRange(['0', '1', '2'], new QueryRange(offset: 1, limit: 3))
.toThrow()
expect =>
@set.addIdsInRange(['0', '1', '2'], new QueryRange(offset: 2, limit: 3))
.not.toThrow()
it "should work if the IDs array is shorter than the result range they represent (addition)", ->
@set.addIdsInRange(['F', 'G', 'H'], new QueryRange(offset: 10, limit: 5))
expect(@set.ids()).toEqual(['A','B','C','D','E', 'F', 'G', 'H'])
it "should work if the IDs array is shorter than the result range they represent (replacement)", ->
@set.addIdsInRange(['A', 'B', 'C'], new QueryRange(offset: 5, limit: 5))
expect(@set.ids()).toEqual(['A','B','C'])
it "should correctly add ids (trailing) and update the offset", ->
@set.addIdsInRange(['F', 'G', 'H'], new QueryRange(offset: 10, limit: 3))
expect(@set.ids()).toEqual(['A','B','C','D','E','F','G','H'])
expect(@set.range().offset).toEqual(5)
it "should correctly add ids (leading) and update the offset", ->
@set.addIdsInRange(['0', '1', '2'], new QueryRange(offset: 2, limit: 3))
expect(@set.ids()).toEqual(['0', '1', '2', 'A','B','C','D','E'])
expect(@set.range().offset).toEqual(2)
it "should correctly add ids (middle) and update the offset", ->
@set.addIdsInRange(['B-new', 'C-new', 'D-new'], new QueryRange(offset: 6, limit: 3))
expect(@set.ids()).toEqual(['A', 'B-new', 'C-new', 'D-new','E'])
expect(@set.range().offset).toEqual(5)
it "should correctly add ids (middle+trailing) and update the offset", ->
@set.addIdsInRange(['D-new', 'E-new', 'F-new'], new QueryRange(offset: 8, limit: 3))
expect(@set.ids()).toEqual(['A', 'B', 'C', 'D-new','E-new', 'F-new'])
expect(@set.range().offset).toEqual(5)

View file

@ -0,0 +1,156 @@
/* eslint quote-props: 0 */
import MutableQueryResultSet from '../../src/flux/models/mutable-query-result-set';
import QueryRange from '../../src/flux/models/query-range';
describe("MutableQueryResultSet", function MutableQueryResultSetSpecs() {
describe("clipToRange", () => {
it("should do nothing if the clipping range is infinite", () => {
const set = new MutableQueryResultSet({_ids: ['A', 'B', 'C', 'D', 'E'], _offset: 5});
const beforeRange = set.range();
set.clipToRange(QueryRange.infinite());
const afterRange = set.range();
expect(beforeRange.isEqual(afterRange)).toBe(true);
});
it("should correctly trim the result set 5-10 to the clipping range 2-9", () => {
const set = new MutableQueryResultSet({_ids: ['A', 'B', 'C', 'D', 'E'], _offset: 5});
expect(set.range().isEqual(new QueryRange({offset: 5, limit: 5}))).toBe(true);
set.clipToRange(new QueryRange({offset: 2, limit: 7}));
expect(set.range().isEqual(new QueryRange({offset: 5, limit: 4}))).toBe(true);
expect(set.ids()).toEqual(['A', 'B', 'C', 'D']);
});
it("should correctly trim the result set 5-10 to the clipping range 5-10", () => {
const set = new MutableQueryResultSet({_ids: ['A', 'B', 'C', 'D', 'E'], _offset: 5});
set.clipToRange(new QueryRange({start: 5, end: 10}));
expect(set.range().isEqual(new QueryRange({start: 5, end: 10}))).toBe(true);
expect(set.ids()).toEqual(['A', 'B', 'C', 'D', 'E']);
});
it("should correctly trim the result set 5-10 to the clipping range 6", () => {
const set = new MutableQueryResultSet({_ids: ['A', 'B', 'C', 'D', 'E'], _offset: 5});
set.clipToRange(new QueryRange({offset: 6, limit: 1}));
expect(set.range().isEqual(new QueryRange({offset: 6, limit: 1}))).toBe(true);
expect(set.ids()).toEqual(['B']);
});
it("should correctly trim the result set 5-10 to the clipping range 100-200", () => {
const set = new MutableQueryResultSet({_ids: ['A', 'B', 'C', 'D', 'E'], _offset: 5});
set.clipToRange(new QueryRange({start: 100, end: 200}));
expect(set.range().isEqual(new QueryRange({start: 100, end: 100}))).toBe(true);
expect(set.ids()).toEqual([]);
});
it("should correctly trim the result set 5-10 to the clipping range 0-2", () => {
const set = new MutableQueryResultSet({_ids: ['A', 'B', 'C', 'D', 'E'], _offset: 5});
set.clipToRange(new QueryRange({offset: 0, limit: 2}));
expect(set.range().isEqual(new QueryRange({offset: 5, limit: 0}))).toBe(true);
expect(set.ids()).toEqual([]);
});
it("should trim the models cache to remove models no longer needed", () => {
const set = new MutableQueryResultSet({
_ids: ['A', 'B', 'C', 'D', 'E'],
_offset: 5,
_modelsHash: {
'A-local': {id: 'A', clientId: 'A-local'},
'A': {id: 'A', clientId: 'A-local'},
'B-local': {id: 'B', clientId: 'B-local'},
'B': {id: 'B', clientId: 'B-local'},
'C-local': {id: 'C', clientId: 'C-local'},
'C': {id: 'C', clientId: 'C-local'},
'D-local': {id: 'D', clientId: 'D-local'},
'D': {id: 'D', clientId: 'D-local'},
'E-local': {id: 'E', clientId: 'E-local'},
'E': {id: 'E', clientId: 'E-local'},
}});
set.clipToRange(new QueryRange({start: 5, end: 8}));
expect(set._modelsHash).toEqual({
'A-local': {id: 'A', clientId: 'A-local'},
'A': {id: 'A', clientId: 'A-local'},
'B-local': {id: 'B', clientId: 'B-local'},
'B': {id: 'B', clientId: 'B-local'},
'C-local': {id: 'C', clientId: 'C-local'},
'C': {id: 'C', clientId: 'C-local'},
});
});
});
describe("addIdsInRange", () => {
describe("when the set is currently empty", () =>
it("should set the result set to the provided one", () => {
this.set = new MutableQueryResultSet();
this.set.addIdsInRange(['B', 'C', 'D'], new QueryRange({start: 1, end: 4}));
expect(this.set.ids()).toEqual(['B', 'C', 'D']);
expect(this.set.range().isEqual(new QueryRange({start: 1, end: 4}))).toBe(true);
})
);
describe("when the set has existing values", () => {
beforeEach(() => {
this.set = new MutableQueryResultSet({
_ids: ['A', 'B', 'C', 'D', 'E'],
_offset: 5,
_modelsHash: {'A': {id: 'A'}, 'B': {id: 'B'}, 'C': {id: 'C'}, 'D': {id: 'D'}, 'E': {id: 'E'}},
});
});
it("should throw an exception if the range provided doesn't intersect (trailing)", () => {
expect(() => {
this.set.addIdsInRange(['G', 'H', 'I'], new QueryRange({offset: 11, limit: 3}));
}).toThrow();
expect(() => {
this.set.addIdsInRange(['F', 'G', 'H'], new QueryRange({offset: 10, limit: 3}));
}).not.toThrow();
});
it("should throw an exception if the range provided doesn't intersect (leading)", () => {
expect(() => {
this.set.addIdsInRange(['0', '1', '2'], new QueryRange({offset: 1, limit: 3}));
}).toThrow();
expect(() => {
this.set.addIdsInRange(['0', '1', '2'], new QueryRange({offset: 2, limit: 3}));
}).not.toThrow();
});
it("should work if the IDs array is shorter than the result range they represent (addition)", () => {
this.set.addIdsInRange(['F', 'G', 'H'], new QueryRange({offset: 10, limit: 5}));
expect(this.set.ids()).toEqual(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']);
});
it("should work if the IDs array is shorter than the result range they represent (replacement)", () => {
this.set.addIdsInRange(['A', 'B', 'C'], new QueryRange({offset: 5, limit: 5}));
expect(this.set.ids()).toEqual(['A', 'B', 'C']);
});
it("should correctly add ids (trailing) and update the offset", () => {
this.set.addIdsInRange(['F', 'G', 'H'], new QueryRange({offset: 10, limit: 3}));
expect(this.set.ids()).toEqual(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']);
expect(this.set.range().offset).toEqual(5);
});
it("should correctly add ids (leading) and update the offset", () => {
this.set.addIdsInRange(['0', '1', '2'], new QueryRange({offset: 2, limit: 3}));
expect(this.set.ids()).toEqual(['0', '1', '2', 'A', 'B', 'C', 'D', 'E']);
expect(this.set.range().offset).toEqual(2);
});
it("should correctly add ids (middle) and update the offset", () => {
this.set.addIdsInRange(['B-new', 'C-new', 'D-new'], new QueryRange({offset: 6, limit: 3}));
expect(this.set.ids()).toEqual(['A', 'B-new', 'C-new', 'D-new', 'E']);
expect(this.set.range().offset).toEqual(5);
});
it("should correctly add ids (middle+trailing) and update the offset", () => {
this.set.addIdsInRange(['D-new', 'E-new', 'F-new'], new QueryRange({offset: 8, limit: 3}));
expect(this.set.ids()).toEqual(['A', 'B', 'C', 'D-new', 'E-new', 'F-new']);
expect(this.set.range().offset).toEqual(5);
});
});
});
});

View file

@ -1,88 +0,0 @@
QueryRange = require('../../src/flux/models/query-range').default
describe "QueryRange", ->
describe "@infinite", ->
it "should return a query range with a null limit and offset", ->
infinite = QueryRange.infinite()
expect(infinite.limit).toBe(null)
expect(infinite.offset).toBe(null)
describe "@rangesBySubtracting", ->
it "should throw an exception if either range is infinite", ->
infinite = QueryRange.infinite()
expect ->
QueryRange.rangesBySubtracting(infinite, new QueryRange({offset: 0, limit: 10}))
.toThrow()
expect ->
QueryRange.rangesBySubtracting(new QueryRange({offset: 0, limit: 10}), infinite)
.toThrow()
it "should return one or more ranges created by punching the provided range", ->
test = ({a, b, result}) ->
expect(QueryRange.rangesBySubtracting(a, b)).toEqual(result)
test
a: new QueryRange(offset: 0, limit: 10),
b: new QueryRange(offset: 3, limit: 3),
result: [new QueryRange(offset: 0, limit: 3), new QueryRange(offset: 6, limit: 4)]
test
a: new QueryRange(offset: 0, limit: 10),
b: new QueryRange(offset: 3, limit: 10),
result: [new QueryRange(offset: 0, limit: 3)]
test
a: new QueryRange(offset: 0, limit: 10),
b: new QueryRange(offset: 0, limit: 10),
result: []
test
a: new QueryRange(offset: 5, limit: 10),
b: new QueryRange(offset: 0, limit: 4),
result: [new QueryRange(offset: 5, limit: 10)]
test
a: new QueryRange(offset: 5, limit: 10),
b: new QueryRange(offset: 0, limit: 8),
result: [new QueryRange(offset: 8, limit: 7)]
describe "isInfinite", ->
it "should return true for an infinite range, false otherwise", ->
infinite = QueryRange.infinite()
expect(infinite.isInfinite()).toBe(true)
expect(new QueryRange(offset:0, limit:4).isInfinite()).toBe(false)
describe "start", ->
it "should be an alias for offset", ->
expect((new QueryRange(offset:3, limit:4)).start).toBe(3)
describe "end", ->
it "should be offset + limit", ->
expect((new QueryRange(offset:3, limit:4)).end).toBe(7)
describe "isContiguousWith", ->
it "should return true if either range is infinite", ->
a = new QueryRange(offset:3, limit:4)
expect(a.isContiguousWith(QueryRange.infinite())).toBe(true)
expect(QueryRange.infinite().isContiguousWith(a)).toBe(true)
it "should return true if the ranges intersect or touch, false otherwise", ->
a = new QueryRange(offset:3, limit:4)
b = new QueryRange(offset:0, limit:2)
c = new QueryRange(offset:0, limit:3)
d = new QueryRange(offset:7, limit:10)
e = new QueryRange(offset:8, limit:10)
# True
expect(a.isContiguousWith(d)).toBe(true)
expect(d.isContiguousWith(a)).toBe(true)
expect(a.isContiguousWith(c)).toBe(true)
expect(c.isContiguousWith(a)).toBe(true)
# False
expect(a.isContiguousWith(b)).toBe(false)
expect(b.isContiguousWith(a)).toBe(false)
expect(a.isContiguousWith(e)).toBe(false)
expect(e.isContiguousWith(a)).toBe(false)
expect(b.isContiguousWith(e)).toBe(false)
expect(e.isContiguousWith(b)).toBe(false)

View file

@ -0,0 +1,109 @@
import QueryRange from '../../src/flux/models/query-range';
describe("QueryRange", function QueryRangeSpecs() {
describe("@infinite", () =>
it("should return a query range with a null limit and offset", () => {
const infinite = QueryRange.infinite();
expect(infinite.limit).toBe(null);
expect(infinite.offset).toBe(null);
})
);
describe("@rangesBySubtracting", () => {
it("should throw an exception if either range is infinite", () => {
const infinite = QueryRange.infinite();
expect(() =>
QueryRange.rangesBySubtracting(infinite, new QueryRange({offset: 0, limit: 10}))
).toThrow();
expect(() =>
QueryRange.rangesBySubtracting(new QueryRange({offset: 0, limit: 10}), infinite)
).toThrow();
});
it("should return one or more ranges created by punching the provided range", () => {
const test = ({a, b, result}) => expect(QueryRange.rangesBySubtracting(a, b)).toEqual(result);
test({
a: new QueryRange({offset: 0, limit: 10}),
b: new QueryRange({offset: 3, limit: 3}),
result: [new QueryRange({offset: 0, limit: 3}), new QueryRange({offset: 6, limit: 4})]});
test({
a: new QueryRange({offset: 0, limit: 10}),
b: new QueryRange({offset: 3, limit: 10}),
result: [new QueryRange({offset: 0, limit: 3})]});
test({
a: new QueryRange({offset: 0, limit: 10}),
b: new QueryRange({offset: 0, limit: 10}),
result: []});
test({
a: new QueryRange({offset: 5, limit: 10}),
b: new QueryRange({offset: 0, limit: 4}),
result: [new QueryRange({offset: 5, limit: 10})]});
test({
a: new QueryRange({offset: 5, limit: 10}),
b: new QueryRange({offset: 0, limit: 8}),
result: [new QueryRange({offset: 8, limit: 7})]});
});
});
describe("isInfinite", () =>
it("should return true for an infinite range, false otherwise", () => {
const infinite = QueryRange.infinite();
expect(infinite.isInfinite()).toBe(true);
expect(new QueryRange({offset: 0, limit: 4}).isInfinite()).toBe(false);
})
);
describe("start", () =>
it("should be an alias for offset", () =>
expect((new QueryRange({offset: 3, limit: 4})).start).toBe(3)
)
);
describe("end", () =>
it("should be offset + limit", () =>
expect((new QueryRange({offset: 3, limit: 4})).end).toBe(7)
)
);
describe("isContiguousWith", () => {
it("should return true if either range is infinite", () => {
const a = new QueryRange({offset: 3, limit: 4});
expect(a.isContiguousWith(QueryRange.infinite())).toBe(true);
expect(QueryRange.infinite().isContiguousWith(a)).toBe(true);
});
it("should return true if the ranges intersect or touch, false otherwise", () => {
const a = new QueryRange({offset: 3, limit: 4});
const b = new QueryRange({offset: 0, limit: 2});
const c = new QueryRange({offset: 0, limit: 3});
const d = new QueryRange({offset: 7, limit: 10});
const e = new QueryRange({offset: 8, limit: 10});
// True
expect(a.isContiguousWith(d)).toBe(true);
expect(d.isContiguousWith(a)).toBe(true);
expect(a.isContiguousWith(c)).toBe(true);
expect(c.isContiguousWith(a)).toBe(true);
// False
expect(a.isContiguousWith(b)).toBe(false);
expect(b.isContiguousWith(a)).toBe(false);
expect(a.isContiguousWith(e)).toBe(false);
expect(e.isContiguousWith(a)).toBe(false);
expect(b.isContiguousWith(e)).toBe(false);
expect(e.isContiguousWith(b)).toBe(false);
});
});
});

View file

@ -1,188 +0,0 @@
ModelQuery = require('../../src/flux/models/query').default
{Matcher} = require('../../src/flux/attributes').default
Message = require('../../src/flux/models/message').default
Thread = require('../../src/flux/models/thread').default
Account = require('../../src/flux/models/account').default
describe "ModelQuery", ->
beforeEach ->
@db = {}
describe "where", ->
beforeEach ->
@q = new ModelQuery(Thread, @db)
@m1 = Thread.attributes.id.equal(4)
@m2 = Thread.attributes.categories.contains('category-id')
it "should accept an array of Matcher objects", ->
@q.where([@m1,@m2])
expect(@q._matchers.length).toBe(2)
expect(@q._matchers[0]).toBe(@m1)
expect(@q._matchers[1]).toBe(@m2)
it "should accept a single Matcher object", ->
@q.where(@m1)
expect(@q._matchers.length).toBe(1)
expect(@q._matchers[0]).toBe(@m1)
it "should append to any existing where clauses", ->
@q.where(@m1)
@q.where(@m2)
expect(@q._matchers.length).toBe(2)
expect(@q._matchers[0]).toBe(@m1)
expect(@q._matchers[1]).toBe(@m2)
it "should accept a shorthand format", ->
@q.where({id: 4, lastMessageReceivedTimestamp: 1234})
expect(@q._matchers.length).toBe(2)
expect(@q._matchers[0].attr.modelKey).toBe('id')
expect(@q._matchers[0].comparator).toBe('=')
expect(@q._matchers[0].val).toBe(4)
it "should return the query so it can be chained", ->
expect(@q.where({id: 4})).toBe(@q)
it "should immediately raise an exception if an un-queryable attribute is specified", ->
expect(-> @q.where({snippet: 'My Snippet'})).toThrow()
it "should immediately raise an exception if a non-existent attribute is specified", ->
expect(-> @q.where({looksLikeADuck: 'of course'})).toThrow()
describe "order", ->
beforeEach ->
@q = new ModelQuery(Thread, @db)
@o1 = Thread.attributes.lastMessageReceivedTimestamp.descending()
@o2 = Thread.attributes.subject.descending()
it "should accept an array of SortOrders", ->
@q.order([@o1,@o2])
expect(@q._orders.length).toBe(2)
it "should accept a single SortOrder object", ->
@q.order(@o2)
expect(@q._orders.length).toBe(1)
it "should extend any existing ordering", ->
@q.order(@o1)
@q.order(@o2)
expect(@q._orders.length).toBe(2)
expect(@q._orders[0]).toBe(@o1)
expect(@q._orders[1]).toBe(@o2)
it "should return the query so it can be chained", ->
expect(@q.order(@o2)).toBe(@q)
describe "include", ->
beforeEach ->
@q = new ModelQuery(Message, @db)
it "should throw an exception if the attribute is not a joined data attribute", ->
expect( =>
@q.include(Message.attributes.unread)
).toThrow()
it "should add the provided property to the list of joined properties", ->
expect(@q._includeJoinedData).toEqual([])
@q.include(Message.attributes.body)
expect(@q._includeJoinedData).toEqual([Message.attributes.body])
describe "includeAll", ->
beforeEach ->
@q = new ModelQuery(Message, @db)
it "should add all the JoinedData attributes of the class", ->
expect(@q._includeJoinedData).toEqual([])
@q.includeAll()
expect(@q._includeJoinedData).toEqual([Message.attributes.body])
describe "response formatting", ->
it "should always return a Number for counts", ->
q = new ModelQuery(Message, @db)
q.where({accountId: 'abcd'}).count()
raw = [{count:"12"}]
expect(q.formatResult(q.inflateResult(raw))).toBe(12)
describe "sql", ->
beforeEach ->
@runScenario = (klass, scenario) ->
q = new ModelQuery(klass, @db)
Matcher.muid = 1
scenario.builder(q)
expect(q.sql().trim()).toBe(scenario.sql.trim())
it "should finalize the query so no further changes can be made", ->
q = new ModelQuery(Account, @db)
spyOn(q, 'finalize')
q.sql()
expect(q.finalize).toHaveBeenCalled()
it "should correctly generate queries with multiple where clauses", ->
@runScenario Account,
builder: (q) -> q.where({emailAddress: 'ben@nylas.com'}).where({id: 2})
sql: "SELECT `Account`.`data` FROM `Account` \
WHERE `Account`.`email_address` = 'ben@nylas.com' AND `Account`.`id` = 2"
it "should correctly escape single quotes with more double single quotes (LIKE)", ->
@runScenario Account,
builder: (q) -> q.where(Account.attributes.emailAddress.like("you're"))
sql: "SELECT `Account`.`data` FROM `Account` WHERE `Account`.`email_address` like '%you''re%'"
it "should correctly escape single quotes with more double single quotes (equal)", ->
@runScenario Account,
builder: (q) -> q.where(Account.attributes.emailAddress.equal("you're"))
sql: "SELECT `Account`.`data` FROM `Account` WHERE `Account`.`email_address` = 'you''re'"
it "should correctly generate COUNT queries", ->
@runScenario Thread,
builder: (q) -> q.where({accountId: 'abcd'}).count()
sql: "SELECT COUNT(*) as count FROM `Thread` \
WHERE `Thread`.`account_id` = 'abcd' "
it "should correctly generate LIMIT 1 queries for single items", ->
@runScenario Thread,
builder: (q) -> q.where({accountId: 'abcd'}).one()
sql: "SELECT `Thread`.`data` FROM `Thread` \
WHERE `Thread`.`account_id` = 'abcd' \
ORDER BY `Thread`.`last_message_received_timestamp` DESC LIMIT 1"
it "should correctly generate `contains` queries using JOINS", ->
@runScenario Thread,
builder: (q) -> q.where(Thread.attributes.categories.contains('category-id')).where({id: '1234'})
sql: "SELECT `Thread`.`data` FROM `Thread` \
INNER JOIN `ThreadCategory` AS `M1` ON `M1`.`id` = `Thread`.`id` \
WHERE `M1`.`value` = 'category-id' AND `Thread`.`id` = '1234' \
ORDER BY `Thread`.`last_message_received_timestamp` DESC"
@runScenario Thread,
builder: (q) -> q.where([Thread.attributes.categories.contains('l-1'), Thread.attributes.categories.contains('l-2')])
sql: "SELECT `Thread`.`data` FROM `Thread` \
INNER JOIN `ThreadCategory` AS `M1` ON `M1`.`id` = `Thread`.`id` \
INNER JOIN `ThreadCategory` AS `M2` ON `M2`.`id` = `Thread`.`id` \
WHERE `M1`.`value` = 'l-1' AND `M2`.`value` = 'l-2' \
ORDER BY `Thread`.`last_message_received_timestamp` DESC"
it "should correctly generate queries with the class's naturalSortOrder when one is available and no other orders are provided", ->
@runScenario Thread,
builder: (q) -> q.where({accountId: 'abcd'})
sql: "SELECT `Thread`.`data` FROM `Thread` \
WHERE `Thread`.`account_id` = 'abcd' \
ORDER BY `Thread`.`last_message_received_timestamp` DESC"
@runScenario Thread,
builder: (q) -> q.where({accountId: 'abcd'}).order(Thread.attributes.lastMessageReceivedTimestamp.ascending())
sql: "SELECT `Thread`.`data` FROM `Thread` \
WHERE `Thread`.`account_id` = 'abcd' \
ORDER BY `Thread`.`last_message_received_timestamp` ASC"
@runScenario Account,
builder: (q) -> q.where({id: 'abcd'})
sql: "SELECT `Account`.`data` FROM `Account` \
WHERE `Account`.`id` = 'abcd' "
it "should correctly generate queries requesting joined data attributes", ->
@runScenario Message,
builder: (q) -> q.where({id: '1234'}).include(Message.attributes.body)
sql: "SELECT `Message`.`data`, IFNULL(`MessageBody`.`value`, '!NULLVALUE!') AS `body` \
FROM `Message` LEFT OUTER JOIN `MessageBody` ON `MessageBody`.`id` = `Message`.`id` \
WHERE `Message`.`id` = '1234' ORDER BY `Message`.`date` ASC"

255
spec/models/query-spec.es6 Normal file
View file

@ -0,0 +1,255 @@
/* eslint quote-props: 0 */
import ModelQuery from '../../src/flux/models/query';
import Attributes from '../../src/flux/attributes';
import Message from '../../src/flux/models/message';
import Thread from '../../src/flux/models/thread';
import Account from '../../src/flux/models/account';
describe("ModelQuery", function ModelQuerySpecs() {
beforeEach(() => {
this.db = {};
});
describe("where", () => {
beforeEach(() => {
this.q = new ModelQuery(Thread, this.db);
this.m1 = Thread.attributes.id.equal(4);
this.m2 = Thread.attributes.categories.contains('category-id');
});
it("should accept an array of Matcher objects", () => {
this.q.where([this.m1, this.m2]);
expect(this.q._matchers.length).toBe(2);
expect(this.q._matchers[0]).toBe(this.m1);
expect(this.q._matchers[1]).toBe(this.m2);
});
it("should accept a single Matcher object", () => {
this.q.where(this.m1);
expect(this.q._matchers.length).toBe(1);
expect(this.q._matchers[0]).toBe(this.m1);
});
it("should append to any existing where clauses", () => {
this.q.where(this.m1);
this.q.where(this.m2);
expect(this.q._matchers.length).toBe(2);
expect(this.q._matchers[0]).toBe(this.m1);
expect(this.q._matchers[1]).toBe(this.m2);
});
it("should accept a shorthand format", () => {
this.q.where({id: 4, lastMessageReceivedTimestamp: 1234});
expect(this.q._matchers.length).toBe(2);
expect(this.q._matchers[0].attr.modelKey).toBe('id');
expect(this.q._matchers[0].comparator).toBe('=');
expect(this.q._matchers[0].val).toBe(4);
});
it("should return the query so it can be chained", () => {
expect(this.q.where({id: 4})).toBe(this.q);
});
it("should immediately raise an exception if an un-queryable attribute is specified", () =>
expect(() => {
this.q.where({snippet: 'My Snippet'});
}).toThrow()
);
it("should immediately raise an exception if a non-existent attribute is specified", () =>
expect(() => {
this.q.where({looksLikeADuck: 'of course'});
}).toThrow()
);
});
describe("order", () => {
beforeEach(() => {
this.q = new ModelQuery(Thread, this.db);
this.o1 = Thread.attributes.lastMessageReceivedTimestamp.descending();
this.o2 = Thread.attributes.subject.descending();
});
it("should accept an array of SortOrders", () => {
this.q.order([this.o1, this.o2]);
expect(this.q._orders.length).toBe(2);
});
it("should accept a single SortOrder object", () => {
this.q.order(this.o2);
expect(this.q._orders.length).toBe(1);
});
it("should extend any existing ordering", () => {
this.q.order(this.o1);
this.q.order(this.o2);
expect(this.q._orders.length).toBe(2);
expect(this.q._orders[0]).toBe(this.o1);
expect(this.q._orders[1]).toBe(this.o2);
});
it("should return the query so it can be chained", () => {
expect(this.q.order(this.o2)).toBe(this.q);
});
});
describe("include", () => {
beforeEach(() => {
this.q = new ModelQuery(Message, this.db);
});
it("should throw an exception if the attribute is not a joined data attribute", () =>
expect(() => {
this.q.include(Message.attributes.unread);
}).toThrow()
);
it("should add the provided property to the list of joined properties", () => {
expect(this.q._includeJoinedData).toEqual([]);
this.q.include(Message.attributes.body);
expect(this.q._includeJoinedData).toEqual([Message.attributes.body]);
});
});
describe("includeAll", () => {
beforeEach(() => {
this.q = new ModelQuery(Message, this.db);
});
it("should add all the JoinedData attributes of the class", () => {
expect(this.q._includeJoinedData).toEqual([]);
this.q.includeAll();
expect(this.q._includeJoinedData).toEqual([Message.attributes.body]);
});
});
describe("response formatting", () =>
it("should always return a Number for counts", () => {
const q = new ModelQuery(Message, this.db);
q.where({accountId: 'abcd'}).count();
const raw = [{count: "12"}];
expect(q.formatResult(q.inflateResult(raw))).toBe(12);
})
);
describe("sql", () => {
beforeEach(() => {
this.runScenario = (klass, scenario) => {
const q = new ModelQuery(klass, this.db);
Attributes.Matcher.muid = 1;
scenario.builder(q);
expect(q.sql().trim()).toBe(scenario.sql.trim());
};
});
it("should finalize the query so no further changes can be made", () => {
const q = new ModelQuery(Account, this.db);
spyOn(q, 'finalize');
q.sql();
expect(q.finalize).toHaveBeenCalled();
});
it("should correctly generate queries with multiple where clauses", () => {
this.runScenario(Account, {
builder: (q) =>
q.where({emailAddress: 'ben@nylas.com'}).where({id: 2}),
sql: "SELECT `Account`.`data` FROM `Account` " +
"WHERE `Account`.`email_address` = 'ben@nylas.com' AND `Account`.`id` = 2",
});
});
it("should correctly escape single quotes with more double single quotes (LIKE)", () => {
this.runScenario(Account, {
builder: (q) =>
q.where(Account.attributes.emailAddress.like("you're")),
sql: "SELECT `Account`.`data` FROM `Account` WHERE `Account`.`email_address` like '%you''re%'",
});
});
it("should correctly escape single quotes with more double single quotes (equal)", () => {
this.runScenario(Account, {
builder: (q) =>
q.where(Account.attributes.emailAddress.equal("you're")),
sql: "SELECT `Account`.`data` FROM `Account` WHERE `Account`.`email_address` = 'you''re'",
});
});
it("should correctly generate COUNT queries", () => {
this.runScenario(Thread, {
builder: (q) =>
q.where({accountId: 'abcd'}).count(),
sql: "SELECT COUNT(*) as count FROM `Thread` " +
"WHERE `Thread`.`account_id` = 'abcd' ",
});
});
it("should correctly generate LIMIT 1 queries for single items", () => {
this.runScenario(Thread, {
builder: (q) =>
q.where({accountId: 'abcd'}).one(),
sql: "SELECT `Thread`.`data` FROM `Thread` " +
"WHERE `Thread`.`account_id` = 'abcd' " +
"ORDER BY `Thread`.`last_message_received_timestamp` DESC LIMIT 1",
});
});
it("should correctly generate `contains` queries using JOINS", () => {
this.runScenario(Thread, {
builder: (q) =>
q.where(Thread.attributes.categories.contains('category-id')).where({id: '1234'}),
sql: "SELECT `Thread`.`data` FROM `Thread` " +
"INNER JOIN `ThreadCategory` AS `M1` ON `M1`.`id` = `Thread`.`id` " +
"WHERE `M1`.`value` = 'category-id' AND `Thread`.`id` = '1234' " +
"ORDER BY `Thread`.`last_message_received_timestamp` DESC",
});
this.runScenario(Thread, {
builder: (q) =>
q.where([Thread.attributes.categories.contains('l-1'), Thread.attributes.categories.contains('l-2')]),
sql: "SELECT `Thread`.`data` FROM `Thread` " +
"INNER JOIN `ThreadCategory` AS `M1` ON `M1`.`id` = `Thread`.`id` " +
"INNER JOIN `ThreadCategory` AS `M2` ON `M2`.`id` = `Thread`.`id` " +
"WHERE `M1`.`value` = 'l-1' AND `M2`.`value` = 'l-2' " +
"ORDER BY `Thread`.`last_message_received_timestamp` DESC",
});
});
it("should correctly generate queries with the class's naturalSortOrder when one is available and no other orders are provided", () => {
this.runScenario(Thread, {
builder: (q) =>
q.where({accountId: 'abcd'}),
sql: "SELECT `Thread`.`data` FROM `Thread` " +
"WHERE `Thread`.`account_id` = 'abcd' " +
"ORDER BY `Thread`.`last_message_received_timestamp` DESC",
});
this.runScenario(Thread, {
builder: (q) =>
q.where({accountId: 'abcd'}).order(Thread.attributes.lastMessageReceivedTimestamp.ascending()),
sql: "SELECT `Thread`.`data` FROM `Thread` " +
"WHERE `Thread`.`account_id` = 'abcd' " +
"ORDER BY `Thread`.`last_message_received_timestamp` ASC",
});
this.runScenario(Account, {
builder: (q) =>
q.where({id: 'abcd'}),
sql: "SELECT `Account`.`data` FROM `Account` " +
"WHERE `Account`.`id` = 'abcd' ",
});
});
it("should correctly generate queries requesting joined data attributes", () => {
this.runScenario(Message, {
builder: (q) =>
q.where({id: '1234'}).include(Message.attributes.body),
sql: "SELECT `Message`.`data`, IFNULL(`MessageBody`.`value`, '!NULLVALUE!') AS `body` " +
"FROM `Message` LEFT OUTER JOIN `MessageBody` ON `MessageBody`.`id` = `Message`.`id` " +
"WHERE `Message`.`id` = '1234' ORDER BY `Message`.`date` ASC",
});
});
});
});

View file

@ -1,51 +0,0 @@
QuerySubscriptionPool = require('../../src/flux/models/query-subscription-pool').default
DatabaseStore = require('../../src/flux/stores/database-store').default
Label = require '../../src/flux/models/label'
describe "QuerySubscriptionPool", ->
beforeEach ->
@query = DatabaseStore.findAll(Label)
@queryKey = @query.sql()
QuerySubscriptionPool._subscriptions = {}
QuerySubscriptionPool._cleanupChecks = []
describe "add", ->
it "should add a new subscription with the callback", ->
callback = jasmine.createSpy('callback')
QuerySubscriptionPool.add(@query, callback)
expect(QuerySubscriptionPool._subscriptions[@queryKey]).toBeDefined()
subscription = QuerySubscriptionPool._subscriptions[@queryKey]
expect(subscription.hasCallback(callback)).toBe(true)
it "should yield database changes to the subscription", ->
callback = jasmine.createSpy('callback')
QuerySubscriptionPool.add(@query, callback)
subscription = QuerySubscriptionPool._subscriptions[@queryKey]
spyOn(subscription, 'applyChangeRecord')
record = {objectType: 'whateves'}
QuerySubscriptionPool._onChange(record)
expect(subscription.applyChangeRecord).toHaveBeenCalledWith(record)
describe "unsubscribe", ->
it "should return an unsubscribe method", ->
expect(QuerySubscriptionPool.add(@query, -> ) instanceof Function).toBe(true)
it "should remove the callback from the subscription", ->
cb = ->
unsub = QuerySubscriptionPool.add(@query, cb)
subscription = QuerySubscriptionPool._subscriptions[@queryKey]
expect(subscription.hasCallback(cb)).toBe(true)
unsub()
expect(subscription.hasCallback(cb)).toBe(false)
it "should wait before removing th subscription to make sure it's not reused", ->
unsub = QuerySubscriptionPool.add(@query, -> )
expect(QuerySubscriptionPool._subscriptions[@queryKey]).toBeDefined()
unsub()
expect(QuerySubscriptionPool._subscriptions[@queryKey]).toBeDefined()
advanceClock()
expect(QuerySubscriptionPool._subscriptions[@queryKey]).toBeUndefined()

View file

@ -0,0 +1,60 @@
import QuerySubscriptionPool from '../../src/flux/models/query-subscription-pool';
import DatabaseStore from '../../src/flux/stores/database-store';
import Label from '../../src/flux/models/label';
describe("QuerySubscriptionPool", function QuerySubscriptionPoolSpecs() {
beforeEach(() => {
this.query = DatabaseStore.findAll(Label);
this.queryKey = this.query.sql();
QuerySubscriptionPool._subscriptions = {};
QuerySubscriptionPool._cleanupChecks = [];
});
describe("add", () => {
it("should add a new subscription with the callback", () => {
const callback = jasmine.createSpy('callback');
QuerySubscriptionPool.add(this.query, callback);
expect(QuerySubscriptionPool._subscriptions[this.queryKey]).toBeDefined();
const subscription = QuerySubscriptionPool._subscriptions[this.queryKey];
expect(subscription.hasCallback(callback)).toBe(true);
});
it("should yield database changes to the subscription", () => {
const callback = jasmine.createSpy('callback');
QuerySubscriptionPool.add(this.query, callback);
const subscription = QuerySubscriptionPool._subscriptions[this.queryKey];
spyOn(subscription, 'applyChangeRecord');
const record = {objectType: 'whateves'};
QuerySubscriptionPool._onChange(record);
expect(subscription.applyChangeRecord).toHaveBeenCalledWith(record);
});
describe("unsubscribe", () => {
it("should return an unsubscribe method", () => {
expect(QuerySubscriptionPool.add(this.query, () => {}) instanceof Function).toBe(true);
});
it("should remove the callback from the subscription", () => {
const cb = () => {};
const unsub = QuerySubscriptionPool.add(this.query, cb);
const subscription = QuerySubscriptionPool._subscriptions[this.queryKey];
expect(subscription.hasCallback(cb)).toBe(true);
unsub();
expect(subscription.hasCallback(cb)).toBe(false);
});
it("should wait before removing th subscription to make sure it's not reused", () => {
const unsub = QuerySubscriptionPool.add(this.query, () => {});
expect(QuerySubscriptionPool._subscriptions[this.queryKey]).toBeDefined();
unsub();
expect(QuerySubscriptionPool._subscriptions[this.queryKey]).toBeDefined();
advanceClock();
expect(QuerySubscriptionPool._subscriptions[this.queryKey]).toBeUndefined();
});
});
});
});

View file

@ -1,265 +0,0 @@
DatabaseStore = require('../../src/flux/stores/database-store').default
QueryRange = require('../../src/flux/models/query-range').default
MutableQueryResultSet = require('../../src/flux/models/mutable-query-result-set').default
QuerySubscription = require('../../src/flux/models/query-subscription').default
Thread = require('../../src/flux/models/thread').default
Label = require '../../src/flux/models/label'
Utils = require '../../src/flux/models/utils'
describe "QuerySubscription", ->
describe "constructor", ->
describe "when a query is provided", ->
it "should finalize the query", ->
query = DatabaseStore.findAll(Thread)
subscription = new QuerySubscription(query)
expect(query._finalized).toBe(true)
it "should throw an exception if the query is a count query, which cannot be observed", ->
query = DatabaseStore.count(Thread)
expect =>
subscription = new QuerySubscription(query)
.toThrow()
it "should call `update` to initialize the result set", ->
query = DatabaseStore.findAll(Thread)
spyOn(QuerySubscription.prototype, 'update')
subscription = new QuerySubscription(query)
expect(QuerySubscription.prototype.update).toHaveBeenCalled()
describe "when initialModels are provided", ->
it "should apply the models and trigger", ->
query = DatabaseStore.findAll(Thread)
threads = [1..5].map (i) -> new Thread(id: i)
subscription = new QuerySubscription(query, {initialModels: threads})
expect(subscription._set).not.toBe(null)
describe "query", ->
it "should return the query", ->
query = DatabaseStore.findAll(Thread)
subscription = new QuerySubscription(query)
expect(subscription.query()).toBe(query)
describe "addCallback", ->
it "should emit the last result to the new callback if one is available", ->
cb = jasmine.createSpy('callback')
runs =>
subscription = new QuerySubscription(DatabaseStore.findAll(Thread))
subscription._lastResult = 'something'
subscription.addCallback(cb)
waitsFor =>
cb.callCount > 0
expect =>
expect(cb).toHaveBeenCalledWith('something')
describe "applyChangeRecord", ->
spyOn(Utils, 'generateTempId').andCallFake => undefined
scenarios = [{
name: "query with full set of objects (4)"
query: DatabaseStore.findAll(Thread)
.where(Thread.attributes.accountId.equal('a'))
.limit(4)
.offset(2)
lastModels: [
new Thread(accountId: 'a', clientId: '4', lastMessageReceivedTimestamp: 4)
new Thread(accountId: 'a', clientId: '3', lastMessageReceivedTimestamp: 3),
new Thread(accountId: 'a', clientId: '2', lastMessageReceivedTimestamp: 2),
new Thread(accountId: 'a', clientId: '1', lastMessageReceivedTimestamp: 1),
]
tests: [{
name: 'Item in set saved - new serverId, same sort value'
change:
objectClass: Thread.name
objects: [new Thread(accountId: 'a', serverId: 's-4', clientId: '4', lastMessageReceivedTimestamp: 4, subject: 'hello')]
type: 'persist'
nextModels:[
new Thread(accountId: 'a', serverId: 's-4', clientId: '4', lastMessageReceivedTimestamp: 4, subject: 'hello')
new Thread(accountId: 'a', clientId: '3', lastMessageReceivedTimestamp: 3),
new Thread(accountId: 'a', clientId: '2', lastMessageReceivedTimestamp: 2),
new Thread(accountId: 'a', clientId: '1', lastMessageReceivedTimestamp: 1),
]
mustUpdate: false
mustTrigger: true
},{
name: 'Item in set saved - new sort value'
change:
objectClass: Thread.name
objects: [new Thread(accountId: 'a', clientId: '5', lastMessageReceivedTimestamp: 3.5)]
type: 'persist'
nextModels:[
new Thread(accountId: 'a', clientId: '4', lastMessageReceivedTimestamp: 4),
new Thread(accountId: 'a', clientId: '5', lastMessageReceivedTimestamp: 3.5),
new Thread(accountId: 'a', clientId: '3', lastMessageReceivedTimestamp: 3),
new Thread(accountId: 'a', clientId: '2', lastMessageReceivedTimestamp: 2),
]
mustUpdate: true
mustTrigger: true
},{
name: 'Item saved - does not match query clauses, offset > 0'
change:
objectClass: Thread.name
objects: [new Thread(accountId: 'b', clientId: '5', lastMessageReceivedTimestamp: 5)]
type: 'persist'
nextModels: 'unchanged'
mustUpdate: true
},{
name: 'Item saved - matches query clauses'
change:
objectClass: Thread.name
objects: [new Thread(accountId: 'a', clientId: '5', lastMessageReceivedTimestamp: -2)]
type: 'persist'
mustUpdate: true
},{
name: 'Item in set saved - no longer matches query clauses'
change:
objectClass: Thread.name
objects: [new Thread(accountId: 'b', clientId: '4', lastMessageReceivedTimestamp: 4)]
type: 'persist'
nextModels: [
new Thread(accountId: 'a', clientId: '3', lastMessageReceivedTimestamp: 3),
new Thread(accountId: 'a', clientId: '2', lastMessageReceivedTimestamp: 2),
new Thread(accountId: 'a', clientId: '1', lastMessageReceivedTimestamp: 1),
]
mustUpdate: true
},{
name: 'Item in set deleted'
change:
objectClass: Thread.name
objects: [new Thread(accountId: 'a', clientId: '4')]
type: 'unpersist'
nextModels: [
new Thread(accountId: 'a', clientId: '3', lastMessageReceivedTimestamp: 3),
new Thread(accountId: 'a', clientId: '2', lastMessageReceivedTimestamp: 2),
new Thread(accountId: 'a', clientId: '1', lastMessageReceivedTimestamp: 1),
]
mustUpdate: true
},{
name: 'Item not in set deleted'
change:
objectClass: Thread.name
objects: [new Thread(accountId: 'a', clientId: '5')]
type: 'unpersist'
nextModels: 'unchanged'
mustUpdate: false
}]
},{
name: "query with multiple sort orders"
query: DatabaseStore.findAll(Thread)
.where(Thread.attributes.accountId.equal('a'))
.limit(4)
.offset(2)
.order([
Thread.attributes.lastMessageReceivedTimestamp.ascending(),
Thread.attributes.unread.descending()
])
lastModels: [
new Thread(accountId: 'a', clientId: '1', lastMessageReceivedTimestamp: 1, unread: true)
new Thread(accountId: 'a', clientId: '2', lastMessageReceivedTimestamp: 1, unread: false)
new Thread(accountId: 'a', clientId: '3', lastMessageReceivedTimestamp: 1, unread: false)
new Thread(accountId: 'a', clientId: '4', lastMessageReceivedTimestamp: 2, unread: true)
]
tests: [{
name: 'Item in set saved, secondary sort order changed'
change:
objectClass: Thread.name
objects: [new Thread(accountId: 'a', clientId: '3', lastMessageReceivedTimestamp: 1, unread: true)]
type: 'persist'
mustUpdate: true
}]
}]
jasmine.unspy(Utils, 'generateTempId')
describe "scenarios", ->
scenarios.forEach (scenario) =>
scenario.tests.forEach (test) =>
it "with #{scenario.name}, should correctly apply #{test.name}", ->
subscription = new QuerySubscription(scenario.query)
subscription._set = new MutableQueryResultSet()
subscription._set.addModelsInRange(scenario.lastModels, new QueryRange(start: 0, end: scenario.lastModels.length))
spyOn(subscription, 'update')
spyOn(subscription, '_createResultAndTrigger')
subscription._updateInFlight = false
subscription.applyChangeRecord(test.change)
if test.mustUpdate
expect(subscription.update).toHaveBeenCalledWith({mustRefetchEntireRange: true})
else
if test.nextModels is 'unchanged'
expect(subscription._set.models()).toEqual(scenario.lastModels)
else
expect(subscription._set.models()).toEqual(test.nextModels)
if test.mustTriger
expect(subscription._createResultAndTrigger).toHaveBeenCalled()
describe "update", ->
beforeEach ->
spyOn(QuerySubscription.prototype, '_fetchRange').andCallFake ->
@_set ?= new MutableQueryResultSet()
Promise.resolve()
describe "when the query has an infinite range", ->
it "should call _fetchRange for the entire range", ->
subscription = new QuerySubscription(DatabaseStore.findAll(Thread))
subscription.update()
advanceClock()
expect(subscription._fetchRange).toHaveBeenCalledWith(QueryRange.infinite(), {fetchEntireModels: true, version: 1})
it "should fetch full full models only when the previous set is empty", ->
subscription = new QuerySubscription(DatabaseStore.findAll(Thread))
subscription._set = new MutableQueryResultSet()
subscription._set.addModelsInRange([new Thread()], new QueryRange(start: 0, end: 1))
subscription.update()
advanceClock()
expect(subscription._fetchRange).toHaveBeenCalledWith(QueryRange.infinite(), {fetchEntireModels: false, version: 1})
describe "when the query has a range", ->
beforeEach ->
@query = DatabaseStore.findAll(Thread).limit(10)
describe "when we have no current range", ->
it "should call _fetchRange for the entire range and fetch full models", ->
subscription = new QuerySubscription(@query)
subscription._set = null
subscription.update()
advanceClock()
expect(subscription._fetchRange).toHaveBeenCalledWith(@query.range(), {fetchEntireModels: true, version: 1})
describe "when we have a previous range", ->
it "should call _fetchRange with the missingRange", ->
customRange = jasmine.createSpy('customRange1')
spyOn(QueryRange, 'rangesBySubtracting').andReturn [customRange]
subscription = new QuerySubscription(@query)
subscription._set = new MutableQueryResultSet()
subscription._set.addModelsInRange([new Thread()], new QueryRange(start: 0, end: 1))
advanceClock()
subscription._fetchRange.reset()
subscription._updateInFlight = false
subscription.update()
advanceClock()
expect(subscription._fetchRange.callCount).toBe(1)
expect(subscription._fetchRange.calls[0].args).toEqual([customRange, {fetchEntireModels: true, version: 1}])
it "should call _fetchRange for the entire query range when the missing range encompasses more than one range", ->
customRange1 = jasmine.createSpy('customRange1')
customRange2 = jasmine.createSpy('customRange2')
spyOn(QueryRange, 'rangesBySubtracting').andReturn [customRange1, customRange2]
range = new QueryRange(start: 0, end: 1)
subscription = new QuerySubscription(@query)
subscription._set = new MutableQueryResultSet()
subscription._set.addModelsInRange([new Thread()], range)
advanceClock()
subscription._fetchRange.reset()
subscription._updateInFlight = false
subscription.update()
advanceClock()
expect(subscription._fetchRange.callCount).toBe(1)
expect(subscription._fetchRange.calls[0].args).toEqual([@query.range(), {fetchEntireModels: true, version: 1}])

View file

@ -0,0 +1,302 @@
import DatabaseStore from '../../src/flux/stores/database-store';
import QueryRange from '../../src/flux/models/query-range';
import MutableQueryResultSet from '../../src/flux/models/mutable-query-result-set';
import QuerySubscription from '../../src/flux/models/query-subscription';
import Thread from '../../src/flux/models/thread';
import Utils from '../../src/flux/models/utils';
describe("QuerySubscription", function QuerySubscriptionSpecs() {
describe("constructor", () =>
describe("when a query is provided", () => {
it("should finalize the query", () => {
const query = DatabaseStore.findAll(Thread);
const subscription = new QuerySubscription(query);
expect(subscription).toBeDefined();
expect(query._finalized).toBe(true);
});
it("should throw an exception if the query is a count query, which cannot be observed", () => {
const query = DatabaseStore.count(Thread);
expect(() => {
const subscription = new QuerySubscription(query);
return subscription;
})
.toThrow();
});
it("should call `update` to initialize the result set", () => {
const query = DatabaseStore.findAll(Thread);
spyOn(QuerySubscription.prototype, 'update');
const subscription = new QuerySubscription(query);
expect(subscription).toBeDefined();
expect(QuerySubscription.prototype.update).toHaveBeenCalled();
});
describe("when initialModels are provided", () =>
it("should apply the models and trigger", () => {
const query = DatabaseStore.findAll(Thread);
const threads = [1, 2, 3, 4, 5].map(i => new Thread({id: i}));
const subscription = new QuerySubscription(query, {initialModels: threads});
expect(subscription._set).not.toBe(null);
})
);
})
);
describe("query", () =>
it("should return the query", () => {
const query = DatabaseStore.findAll(Thread);
const subscription = new QuerySubscription(query);
expect(subscription.query()).toBe(query);
})
);
describe("addCallback", () =>
it("should emit the last result to the new callback if one is available", () => {
const cb = jasmine.createSpy('callback');
spyOn(QuerySubscription.prototype, 'update').andReturn();
const subscription = new QuerySubscription(DatabaseStore.findAll(Thread));
subscription._lastResult = 'something';
runs(() => subscription.addCallback(cb));
waitsFor(() => cb.callCount > 0);
runs(() => expect(cb).toHaveBeenCalledWith('something'));
})
);
describe("applyChangeRecord", () => {
spyOn(Utils, 'generateTempId').andCallFake(() => undefined);
const scenarios = [{
name: "query with full set of objects (4)",
query: DatabaseStore.findAll(Thread).where(Thread.attributes.accountId.equal('a')).limit(4).offset(2),
lastModels: [
new Thread({accountId: 'a', clientId: '4', lastMessageReceivedTimestamp: 4}),
new Thread({accountId: 'a', clientId: '3', lastMessageReceivedTimestamp: 3}),
new Thread({accountId: 'a', clientId: '2', lastMessageReceivedTimestamp: 2}),
new Thread({accountId: 'a', clientId: '1', lastMessageReceivedTimestamp: 1}),
],
tests: [{
name: 'Item in set saved - new serverId, same sort value',
change: {
objectClass: Thread.name,
objects: [new Thread({accountId: 'a', serverId: 's-4', clientId: '4', lastMessageReceivedTimestamp: 4, subject: 'hello'})],
type: 'persist',
},
nextModels: [
new Thread({accountId: 'a', serverId: 's-4', clientId: '4', lastMessageReceivedTimestamp: 4, subject: 'hello'}),
new Thread({accountId: 'a', clientId: '3', lastMessageReceivedTimestamp: 3}),
new Thread({accountId: 'a', clientId: '2', lastMessageReceivedTimestamp: 2}),
new Thread({accountId: 'a', clientId: '1', lastMessageReceivedTimestamp: 1}),
],
mustUpdate: false,
mustTrigger: true,
}, {
name: 'Item in set saved - new sort value',
change: {
objectClass: Thread.name,
objects: [new Thread({accountId: 'a', clientId: '5', lastMessageReceivedTimestamp: 3.5})],
type: 'persist',
},
nextModels: [
new Thread({accountId: 'a', clientId: '4', lastMessageReceivedTimestamp: 4}),
new Thread({accountId: 'a', clientId: '5', lastMessageReceivedTimestamp: 3.5}),
new Thread({accountId: 'a', clientId: '3', lastMessageReceivedTimestamp: 3}),
new Thread({accountId: 'a', clientId: '2', lastMessageReceivedTimestamp: 2}),
],
mustUpdate: true,
mustTrigger: true,
}, {
name: 'Item saved - does not match query clauses, offset > 0',
change: {
objectClass: Thread.name,
objects: [new Thread({accountId: 'b', clientId: '5', lastMessageReceivedTimestamp: 5})],
type: 'persist',
},
nextModels: 'unchanged',
mustUpdate: true,
}, {
name: 'Item saved - matches query clauses',
change: {
objectClass: Thread.name,
objects: [new Thread({accountId: 'a', clientId: '5', lastMessageReceivedTimestamp: -2})],
type: 'persist',
},
mustUpdate: true,
}, {
name: 'Item in set saved - no longer matches query clauses',
change: {
objectClass: Thread.name,
objects: [new Thread({accountId: 'b', clientId: '4', lastMessageReceivedTimestamp: 4})],
type: 'persist',
},
nextModels: [
new Thread({accountId: 'a', clientId: '3', lastMessageReceivedTimestamp: 3}),
new Thread({accountId: 'a', clientId: '2', lastMessageReceivedTimestamp: 2}),
new Thread({accountId: 'a', clientId: '1', lastMessageReceivedTimestamp: 1}),
],
mustUpdate: true,
}, {
name: 'Item in set deleted',
change: {
objectClass: Thread.name,
objects: [new Thread({accountId: 'a', clientId: '4'})],
type: 'unpersist',
},
nextModels: [
new Thread({accountId: 'a', clientId: '3', lastMessageReceivedTimestamp: 3}),
new Thread({accountId: 'a', clientId: '2', lastMessageReceivedTimestamp: 2}),
new Thread({accountId: 'a', clientId: '1', lastMessageReceivedTimestamp: 1}),
],
mustUpdate: true,
}, {
name: 'Item not in set deleted',
change: {
objectClass: Thread.name,
objects: [new Thread({accountId: 'a', clientId: '5'})],
type: 'unpersist',
},
nextModels: 'unchanged',
mustUpdate: false,
}],
}, {
name: "query with multiple sort orders",
query: DatabaseStore.findAll(Thread).where(Thread.attributes.accountId.equal('a')).limit(4).offset(2).order([
Thread.attributes.lastMessageReceivedTimestamp.ascending(),
Thread.attributes.unread.descending(),
]),
lastModels: [
new Thread({accountId: 'a', clientId: '1', lastMessageReceivedTimestamp: 1, unread: true}),
new Thread({accountId: 'a', clientId: '2', lastMessageReceivedTimestamp: 1, unread: false}),
new Thread({accountId: 'a', clientId: '3', lastMessageReceivedTimestamp: 1, unread: false}),
new Thread({accountId: 'a', clientId: '4', lastMessageReceivedTimestamp: 2, unread: true}),
],
tests: [{
name: 'Item in set saved, secondary sort order changed',
change: {
objectClass: Thread.name,
objects: [new Thread({accountId: 'a', clientId: '3', lastMessageReceivedTimestamp: 1, unread: true})],
type: 'persist',
},
mustUpdate: true,
}],
}];
jasmine.unspy(Utils, 'generateTempId');
describe("scenarios", () =>
scenarios.forEach(scenario => {
scenario.tests.forEach(test => {
it(`with ${scenario.name}, should correctly apply ${test.name}`, () => {
const subscription = new QuerySubscription(scenario.query);
subscription._set = new MutableQueryResultSet();
subscription._set.addModelsInRange(scenario.lastModels, new QueryRange({start: 0, end: scenario.lastModels.length}));
spyOn(subscription, 'update');
spyOn(subscription, '_createResultAndTrigger');
subscription._updateInFlight = false;
subscription.applyChangeRecord(test.change);
if (test.mustUpdate) {
expect(subscription.update).toHaveBeenCalledWith({mustRefetchEntireRange: true});
} else {
if (test.nextModels === 'unchanged') {
expect(subscription._set.models()).toEqual(scenario.lastModels);
} else {
expect(subscription._set.models()).toEqual(test.nextModels);
}
}
if (test.mustTriger) {
expect(subscription._createResultAndTrigger).toHaveBeenCalled();
}
});
});
})
);
});
describe("update", () => {
beforeEach(() =>
spyOn(QuerySubscription.prototype, '_fetchRange').andCallFake(() => {
if (this._set == null) { this._set = new MutableQueryResultSet(); }
return Promise.resolve();
})
);
describe("when the query has an infinite range", () => {
it("should call _fetchRange for the entire range", () => {
const subscription = new QuerySubscription(DatabaseStore.findAll(Thread));
subscription.update();
advanceClock();
expect(subscription._fetchRange).toHaveBeenCalledWith(QueryRange.infinite(), {fetchEntireModels: true, version: 1});
});
it("should fetch full full models only when the previous set is empty", () => {
const subscription = new QuerySubscription(DatabaseStore.findAll(Thread));
subscription._set = new MutableQueryResultSet();
subscription._set.addModelsInRange([new Thread()], new QueryRange({start: 0, end: 1}));
subscription.update();
advanceClock();
expect(subscription._fetchRange).toHaveBeenCalledWith(QueryRange.infinite(), {fetchEntireModels: false, version: 1});
});
});
describe("when the query has a range", () => {
beforeEach(() => {
this.query = DatabaseStore.findAll(Thread).limit(10);
});
describe("when we have no current range", () =>
it("should call _fetchRange for the entire range and fetch full models", () => {
const subscription = new QuerySubscription(this.query);
subscription._set = null;
subscription.update();
advanceClock();
expect(subscription._fetchRange).toHaveBeenCalledWith(this.query.range(), {fetchEntireModels: true, version: 1});
})
);
describe("when we have a previous range", () => {
it("should call _fetchRange with the missingRange", () => {
const customRange = jasmine.createSpy('customRange1');
spyOn(QueryRange, 'rangesBySubtracting').andReturn([customRange]);
const subscription = new QuerySubscription(this.query);
subscription._set = new MutableQueryResultSet();
subscription._set.addModelsInRange([new Thread()], new QueryRange({start: 0, end: 1}));
advanceClock();
subscription._fetchRange.reset();
subscription._updateInFlight = false;
subscription.update();
advanceClock();
expect(subscription._fetchRange.callCount).toBe(1);
expect(subscription._fetchRange.calls[0].args).toEqual([customRange, {fetchEntireModels: true, version: 1}]);
});
it("should call _fetchRange for the entire query range when the missing range encompasses more than one range", () => {
const customRange1 = jasmine.createSpy('customRange1');
const customRange2 = jasmine.createSpy('customRange2');
spyOn(QueryRange, 'rangesBySubtracting').andReturn([customRange1, customRange2]);
const range = new QueryRange({start: 0, end: 1});
const subscription = new QuerySubscription(this.query);
subscription._set = new MutableQueryResultSet();
subscription._set.addModelsInRange([new Thread()], range);
advanceClock();
subscription._fetchRange.reset();
subscription._updateInFlight = false;
subscription.update();
advanceClock();
expect(subscription._fetchRange.callCount).toBe(1);
expect(subscription._fetchRange.calls[0].args).toEqual([this.query.range(), {fetchEntireModels: true, version: 1}]);
});
});
});
});
});

View file

@ -1,57 +0,0 @@
TestModel = require '../fixtures/db-test-model'
Attributes = require('../../src/flux/attributes').default
DatabaseSetupQueryBuilder = require('../../src/flux/stores/database-setup-query-builder').default
describe "DatabaseSetupQueryBuilder", ->
beforeEach ->
@builder = new DatabaseSetupQueryBuilder()
describe "setupQueriesForTable", ->
it "should return the queries for creating the table and the primary unique index", ->
TestModel.attributes =
'attrQueryable': Attributes.DateTime
queryable: true
modelKey: 'attrQueryable'
jsonKey: 'attr_queryable'
'attrNonQueryable': Attributes.Collection
modelKey: 'attrNonQueryable'
jsonKey: 'attr_non_queryable'
queries = @builder.setupQueriesForTable(TestModel)
expected = [
'CREATE TABLE IF NOT EXISTS `TestModel` (id TEXT PRIMARY KEY,data BLOB,attr_queryable INTEGER)',
'CREATE UNIQUE INDEX IF NOT EXISTS `TestModel_id` ON `TestModel` (`id`)'
]
for query,i in queries
expect(query).toBe(expected[i])
it "should correctly create join tables for models that have queryable collections", ->
TestModel.configureWithCollectionAttribute()
queries = @builder.setupQueriesForTable(TestModel)
expected = [
'CREATE TABLE IF NOT EXISTS `TestModel` (id TEXT PRIMARY KEY,data BLOB,client_id TEXT,server_id TEXT,other TEXT)',
'CREATE UNIQUE INDEX IF NOT EXISTS `TestModel_id` ON `TestModel` (`id`)',
'CREATE TABLE IF NOT EXISTS `TestModelCategory` (id TEXT KEY,`value` TEXT,other TEXT)'
'CREATE INDEX IF NOT EXISTS `TestModelCategory_id` ON `TestModelCategory` (`id` ASC)'
'CREATE UNIQUE INDEX IF NOT EXISTS `TestModelCategory_val_id` ON `TestModelCategory` (`value` ASC, `id` ASC)',
]
for query,i in queries
expect(query).toBe(expected[i])
it "should use the correct column type for each attribute", ->
TestModel.configureWithAllAttributes()
queries = @builder.setupQueriesForTable(TestModel)
expect(queries[0]).toBe('CREATE TABLE IF NOT EXISTS `TestModel` (id TEXT PRIMARY KEY,data BLOB,datetime INTEGER,string-json-key TEXT,boolean INTEGER,number INTEGER)')
describe "when the model provides additional sqlite config", ->
it "the setup method should return these queries", ->
TestModel.configureWithAdditionalSQLiteConfig()
spyOn(TestModel.additionalSQLiteConfig, 'setup').andCallThrough()
queries = @builder.setupQueriesForTable(TestModel)
expect(TestModel.additionalSQLiteConfig.setup).toHaveBeenCalledWith()
expect(queries.pop()).toBe('CREATE INDEX IF NOT EXISTS ThreadListIndex ON Thread(last_message_received_timestamp DESC, account_id, id)')
it "should not fail if additional config is present, but setup is undefined", ->
delete TestModel.additionalSQLiteConfig['setup']
@m = new TestModel(id: 'local-6806434c-b0cd', body: 'hello world')
expect( => @builder.setupQueriesForTable(TestModel)).not.toThrow()

View file

@ -0,0 +1,72 @@
/* eslint quote-props: 0 */
import TestModel from '../fixtures/db-test-model';
import Attributes from '../../src/flux/attributes';
import DatabaseSetupQueryBuilder from '../../src/flux/stores/database-setup-query-builder';
describe("DatabaseSetupQueryBuilder", function DatabaseSetupQueryBuilderSpecs() {
beforeEach(() => {
this.builder = new DatabaseSetupQueryBuilder();
});
describe("setupQueriesForTable", () => {
it("should return the queries for creating the table and the primary unique index", () => {
TestModel.attributes = {
'attrQueryable': Attributes.DateTime({
queryable: true,
modelKey: 'attrQueryable',
jsonKey: 'attr_queryable',
}),
'attrNonQueryable': Attributes.Collection({
modelKey: 'attrNonQueryable',
jsonKey: 'attr_non_queryable',
}),
};
const queries = this.builder.setupQueriesForTable(TestModel);
const expected = [
'CREATE TABLE IF NOT EXISTS `TestModel` (id TEXT PRIMARY KEY,data BLOB,attr_queryable INTEGER)',
'CREATE UNIQUE INDEX IF NOT EXISTS `TestModel_id` ON `TestModel` (`id`)',
];
queries.map((query, i) =>
expect(query).toBe(expected[i])
);
});
it("should correctly create join tables for models that have queryable collections", () => {
TestModel.configureWithCollectionAttribute();
const queries = this.builder.setupQueriesForTable(TestModel);
const expected = [
'CREATE TABLE IF NOT EXISTS `TestModel` (id TEXT PRIMARY KEY,data BLOB,client_id TEXT,server_id TEXT,other TEXT)',
'CREATE UNIQUE INDEX IF NOT EXISTS `TestModel_id` ON `TestModel` (`id`)',
'CREATE TABLE IF NOT EXISTS `TestModelCategory` (id TEXT KEY,`value` TEXT,other TEXT)',
'CREATE INDEX IF NOT EXISTS `TestModelCategory_id` ON `TestModelCategory` (`id` ASC)',
'CREATE UNIQUE INDEX IF NOT EXISTS `TestModelCategory_val_id` ON `TestModelCategory` (`value` ASC, `id` ASC)',
];
queries.map((query, i) =>
expect(query).toBe(expected[i])
);
});
it("should use the correct column type for each attribute", () => {
TestModel.configureWithAllAttributes();
const queries = this.builder.setupQueriesForTable(TestModel);
expect(queries[0]).toBe('CREATE TABLE IF NOT EXISTS `TestModel` (id TEXT PRIMARY KEY,data BLOB,datetime INTEGER,string-json-key TEXT,boolean INTEGER,number INTEGER)');
});
describe("when the model provides additional sqlite config", () => {
it("the setup method should return these queries", () => {
TestModel.configureWithAdditionalSQLiteConfig();
spyOn(TestModel.additionalSQLiteConfig, 'setup').andCallThrough();
const queries = this.builder.setupQueriesForTable(TestModel);
expect(TestModel.additionalSQLiteConfig.setup).toHaveBeenCalledWith();
expect(queries.pop()).toBe('CREATE INDEX IF NOT EXISTS ThreadListIndex ON Thread(last_message_received_timestamp DESC, account_id, id)');
});
it("should not fail if additional config is present, but setup is undefined", () => {
delete TestModel.additionalSQLiteConfig.setup;
this.m = new TestModel({id: 'local-6806434c-b0cd', body: 'hello world'});
expect(() => this.builder.setupQueriesForTable(TestModel)).not.toThrow();
});
});
});
});

View file

@ -1,239 +0,0 @@
_ = require 'underscore'
Label = require '../../src/flux/models/label'
Thread = require('../../src/flux/models/thread').default
TestModel = require '../fixtures/db-test-model'
ModelQuery = require('../../src/flux/models/query').default
DatabaseStore = require('../../src/flux/stores/database-store').default
testMatchers = {'id': 'b'}
testModelInstance = new TestModel(id: "1234")
testModelInstanceA = new TestModel(id: "AAA")
testModelInstanceB = new TestModel(id: "BBB")
describe "DatabaseStore", ->
beforeEach ->
TestModel.configureBasic()
DatabaseStore._atomicallyQueue = undefined
DatabaseStore._mutationQueue = undefined
DatabaseStore._inTransaction = false
spyOn(ModelQuery.prototype, 'where').andCallThrough()
spyOn(DatabaseStore, 'accumulateAndTrigger').andCallFake -> Promise.resolve()
@performed = []
# Note: We spy on _query and test all of the convenience methods that sit above
# it. None of these tests evaluate whether _query works!
jasmine.unspy(DatabaseStore, "_query")
spyOn(DatabaseStore, "_query").andCallFake (query, values=[], options={}) =>
@performed.push({query: query, values: values})
return Promise.resolve([])
describe "find", ->
it "should return a ModelQuery for retrieving a single item by Id", ->
q = DatabaseStore.find(TestModel, "4")
expect(q.sql()).toBe("SELECT `TestModel`.`data` FROM `TestModel` WHERE `TestModel`.`id` = '4' LIMIT 1")
describe "findBy", ->
it "should pass the provided predicates on to the ModelQuery", ->
matchers = {'id': 'b'}
DatabaseStore.findBy(TestModel, testMatchers)
expect(ModelQuery.prototype.where).toHaveBeenCalledWith(testMatchers)
it "should return a ModelQuery ready to be executed", ->
q = DatabaseStore.findBy(TestModel, testMatchers)
expect(q.sql()).toBe("SELECT `TestModel`.`data` FROM `TestModel` WHERE `TestModel`.`id` = 'b' LIMIT 1")
describe "findAll", ->
it "should pass the provided predicates on to the ModelQuery", ->
DatabaseStore.findAll(TestModel, testMatchers)
expect(ModelQuery.prototype.where).toHaveBeenCalledWith(testMatchers)
it "should return a ModelQuery ready to be executed", ->
q = DatabaseStore.findAll(TestModel, testMatchers)
expect(q.sql()).toBe("SELECT `TestModel`.`data` FROM `TestModel` WHERE `TestModel`.`id` = 'b' ")
describe "modelify", ->
beforeEach ->
@models = [
new Thread(clientId: 'local-A'),
new Thread(clientId: 'local-B'),
new Thread(clientId: 'local-C'),
new Thread(clientId: 'local-D', serverId: 'SERVER:D'),
new Thread(clientId: 'local-E', serverId: 'SERVER:E'),
new Thread(clientId: 'local-F', serverId: 'SERVER:F'),
new Thread(clientId: 'local-G', serverId: 'SERVER:G')
]
# Actually returns correct sets for queries, since matchers can evaluate
# themselves against models in memory
spyOn(DatabaseStore, 'run').andCallFake (query) =>
results = @models.filter((model) ->
query._matchers.every((matcher) ->
matcher.evaluate(model)
)
)
Promise.resolve(results)
describe "when given an array or input that is not an array", ->
it "resolves immediately with an empty array", ->
waitsForPromise =>
DatabaseStore.modelify(Thread, null).then (output) =>
expect(output).toEqual([])
describe "when given an array of mixed IDs, clientIDs, and models", ->
it "resolves with an array of models", ->
input = ['SERVER:F', 'local-B', 'local-C', 'SERVER:D', @models[6]]
expectedOutput = [@models[5], @models[1], @models[2], @models[3], @models[6]]
waitsForPromise =>
DatabaseStore.modelify(Thread, input).then (output) =>
expect(output).toEqual(expectedOutput)
describe "when the input is only IDs", ->
it "resolves with an array of models", ->
input = ['SERVER:D', 'SERVER:F', 'SERVER:G']
expectedOutput = [@models[3], @models[5], @models[6]]
waitsForPromise =>
DatabaseStore.modelify(Thread, input).then (output) =>
expect(output).toEqual(expectedOutput)
describe "when the input is only clientIDs", ->
it "resolves with an array of models", ->
input = ['local-A', 'local-B', 'local-C', 'local-D']
expectedOutput = [@models[0], @models[1], @models[2], @models[3]]
waitsForPromise =>
DatabaseStore.modelify(Thread, input).then (output) =>
expect(output).toEqual(expectedOutput)
describe "when the input is all models", ->
it "resolves with an array of models", ->
input = [@models[0], @models[1], @models[2], @models[3]]
expectedOutput = [@models[0], @models[1], @models[2], @models[3]]
waitsForPromise =>
DatabaseStore.modelify(Thread, input).then (output) =>
expect(output).toEqual(expectedOutput)
describe "count", ->
it "should pass the provided predicates on to the ModelQuery", ->
DatabaseStore.findAll(TestModel, testMatchers)
expect(ModelQuery.prototype.where).toHaveBeenCalledWith(testMatchers)
it "should return a ModelQuery configured for COUNT ready to be executed", ->
q = DatabaseStore.findAll(TestModel, testMatchers)
expect(q.sql()).toBe("SELECT `TestModel`.`data` FROM `TestModel` WHERE `TestModel`.`id` = 'b' ")
describe "inTransaction", ->
it "calls the provided function inside an exclusive transaction", ->
waitsForPromise =>
DatabaseStore.inTransaction( =>
DatabaseStore._query("TEST")
).then =>
expect(@performed.length).toBe 3
expect(@performed[0].query).toBe "BEGIN IMMEDIATE TRANSACTION"
expect(@performed[1].query).toBe "TEST"
expect(@performed[2].query).toBe "COMMIT"
it "preserves resolved values", ->
waitsForPromise =>
DatabaseStore.inTransaction( =>
DatabaseStore._query("TEST")
return Promise.resolve("myValue")
).then (myValue) =>
expect(myValue).toBe "myValue"
it "always fires a COMMIT, even if the body function fails", ->
waitsForPromise =>
DatabaseStore.inTransaction( =>
throw new Error("BOOO")
).catch =>
expect(@performed.length).toBe 2
expect(@performed[0].query).toBe "BEGIN IMMEDIATE TRANSACTION"
expect(@performed[1].query).toBe "COMMIT"
it "can be called multiple times and get queued", ->
waitsForPromise =>
Promise.all([
DatabaseStore.inTransaction( -> )
DatabaseStore.inTransaction( -> )
DatabaseStore.inTransaction( -> )
]).then =>
expect(@performed.length).toBe 6
expect(@performed[0].query).toBe "BEGIN IMMEDIATE TRANSACTION"
expect(@performed[1].query).toBe "COMMIT"
expect(@performed[2].query).toBe "BEGIN IMMEDIATE TRANSACTION"
expect(@performed[3].query).toBe "COMMIT"
expect(@performed[4].query).toBe "BEGIN IMMEDIATE TRANSACTION"
expect(@performed[5].query).toBe "COMMIT"
it "carries on if one of them fails, but still calls the COMMIT for the failed block", ->
caughtError = false
DatabaseStore.inTransaction( => DatabaseStore._query("ONE") )
DatabaseStore.inTransaction( => throw new Error("fail") ).catch ->
caughtError = true
DatabaseStore.inTransaction( => DatabaseStore._query("THREE") )
advanceClock(100)
expect(@performed.length).toBe 8
expect(@performed[0].query).toBe "BEGIN IMMEDIATE TRANSACTION"
expect(@performed[1].query).toBe "ONE"
expect(@performed[2].query).toBe "COMMIT"
expect(@performed[3].query).toBe "BEGIN IMMEDIATE TRANSACTION"
expect(@performed[4].query).toBe "COMMIT"
expect(@performed[5].query).toBe "BEGIN IMMEDIATE TRANSACTION"
expect(@performed[6].query).toBe "THREE"
expect(@performed[7].query).toBe "COMMIT"
expect(caughtError).toBe true
it "is actually running in series and blocks on never-finishing specs", ->
resolver = null
DatabaseStore.inTransaction( -> )
advanceClock(100)
expect(@performed.length).toBe 2
expect(@performed[0].query).toBe "BEGIN IMMEDIATE TRANSACTION"
expect(@performed[1].query).toBe "COMMIT"
DatabaseStore.inTransaction( -> new Promise (resolve, reject) -> resolver = resolve)
advanceClock(100)
blockedPromiseDone = false
DatabaseStore.inTransaction( -> ).then =>
blockedPromiseDone = true
advanceClock(100)
expect(@performed.length).toBe 3
expect(@performed[2].query).toBe "BEGIN IMMEDIATE TRANSACTION"
expect(blockedPromiseDone).toBe false
# Now that we've made our assertion about blocking, we need to clean up
# our test and actually resolve that blocked promise now, otherwise
# remaining tests won't run properly.
advanceClock(100)
resolver()
advanceClock(100)
expect(blockedPromiseDone).toBe true
advanceClock(100)
it "can be called multiple times and preserve return values", ->
waitsForPromise =>
v1 = null
v2 = null
v3 = null
Promise.all([
DatabaseStore.inTransaction( -> "a" ).then (val) -> v1 = val
DatabaseStore.inTransaction( -> "b" ).then (val) -> v2 = val
DatabaseStore.inTransaction( -> "c" ).then (val) -> v3 = val
]).then =>
expect(v1).toBe "a"
expect(v2).toBe "b"
expect(v3).toBe "c"
it "can be called multiple times and get queued", ->
waitsForPromise =>
DatabaseStore.inTransaction( -> )
.then -> DatabaseStore.inTransaction( -> )
.then -> DatabaseStore.inTransaction( -> )
.then =>
expect(@performed.length).toBe 6
expect(@performed[0].query).toBe "BEGIN IMMEDIATE TRANSACTION"
expect(@performed[1].query).toBe "COMMIT"
expect(@performed[2].query).toBe "BEGIN IMMEDIATE TRANSACTION"
expect(@performed[3].query).toBe "COMMIT"
expect(@performed[4].query).toBe "BEGIN IMMEDIATE TRANSACTION"
expect(@performed[5].query).toBe "COMMIT"

View file

@ -0,0 +1,300 @@
/* eslint quote-props: 0 */
import Thread from '../../src/flux/models/thread';
import TestModel from '../fixtures/db-test-model';
import ModelQuery from '../../src/flux/models/query';
import DatabaseStore from '../../src/flux/stores/database-store';
const testMatchers = {'id': 'b'};
describe("DatabaseStore", function DatabaseStoreSpecs() {
beforeEach(() => {
TestModel.configureBasic();
DatabaseStore._atomicallyQueue = undefined;
DatabaseStore._mutationQueue = undefined;
DatabaseStore._inTransaction = false;
spyOn(ModelQuery.prototype, 'where').andCallThrough();
spyOn(DatabaseStore, 'accumulateAndTrigger').andCallFake(() => Promise.resolve());
this.performed = [];
// Note: We spy on _query and test all of the convenience methods that sit above
// it. None of these tests evaluate whether _query works!
jasmine.unspy(DatabaseStore, "_query");
spyOn(DatabaseStore, "_query").andCallFake((query, values = []) => {
this.performed.push({query, values});
return Promise.resolve([]);
});
});
describe("find", () =>
it("should return a ModelQuery for retrieving a single item by Id", () => {
const q = DatabaseStore.find(TestModel, "4");
expect(q.sql()).toBe("SELECT `TestModel`.`data` FROM `TestModel` WHERE `TestModel`.`id` = '4' LIMIT 1");
})
);
describe("findBy", () => {
it("should pass the provided predicates on to the ModelQuery", () => {
DatabaseStore.findBy(TestModel, testMatchers);
expect(ModelQuery.prototype.where).toHaveBeenCalledWith(testMatchers);
});
it("should return a ModelQuery ready to be executed", () => {
const q = DatabaseStore.findBy(TestModel, testMatchers);
expect(q.sql()).toBe("SELECT `TestModel`.`data` FROM `TestModel` WHERE `TestModel`.`id` = 'b' LIMIT 1");
});
});
describe("findAll", () => {
it("should pass the provided predicates on to the ModelQuery", () => {
DatabaseStore.findAll(TestModel, testMatchers);
expect(ModelQuery.prototype.where).toHaveBeenCalledWith(testMatchers);
});
it("should return a ModelQuery ready to be executed", () => {
const q = DatabaseStore.findAll(TestModel, testMatchers);
expect(q.sql()).toBe("SELECT `TestModel`.`data` FROM `TestModel` WHERE `TestModel`.`id` = 'b' ");
});
});
describe("modelify", () => {
beforeEach(() => {
this.models = [
new Thread({clientId: 'local-A'}),
new Thread({clientId: 'local-B'}),
new Thread({clientId: 'local-C'}),
new Thread({clientId: 'local-D', serverId: 'SERVER:D'}),
new Thread({clientId: 'local-E', serverId: 'SERVER:E'}),
new Thread({clientId: 'local-F', serverId: 'SERVER:F'}),
new Thread({clientId: 'local-G', serverId: 'SERVER:G'}),
];
// Actually returns correct sets for queries, since matchers can evaluate
// themselves against models in memory
spyOn(DatabaseStore, 'run').andCallFake(query => {
const results = this.models.filter(model =>
query._matchers.every(matcher => matcher.evaluate(model))
);
return Promise.resolve(results);
});
});
describe("when given an array or input that is not an array", () =>
it("resolves immediately with an empty array", () =>
waitsForPromise(() => {
return DatabaseStore.modelify(Thread, null).then(output => {
expect(output).toEqual([]);
});
})
)
);
describe("when given an array of mixed IDs, clientIDs, and models", () =>
it("resolves with an array of models", () => {
const input = ['SERVER:F', 'local-B', 'local-C', 'SERVER:D', this.models[6]];
const expectedOutput = [this.models[5], this.models[1], this.models[2], this.models[3], this.models[6]];
return waitsForPromise(() => {
return DatabaseStore.modelify(Thread, input).then(output => {
expect(output).toEqual(expectedOutput);
});
});
})
);
describe("when the input is only IDs", () =>
it("resolves with an array of models", () => {
const input = ['SERVER:D', 'SERVER:F', 'SERVER:G'];
const expectedOutput = [this.models[3], this.models[5], this.models[6]];
return waitsForPromise(() => {
return DatabaseStore.modelify(Thread, input).then(output => {
expect(output).toEqual(expectedOutput);
});
});
})
);
describe("when the input is only clientIDs", () =>
it("resolves with an array of models", () => {
const input = ['local-A', 'local-B', 'local-C', 'local-D'];
const expectedOutput = [this.models[0], this.models[1], this.models[2], this.models[3]];
return waitsForPromise(() => {
return DatabaseStore.modelify(Thread, input).then(output => {
expect(output).toEqual(expectedOutput);
});
});
})
);
describe("when the input is all models", () =>
it("resolves with an array of models", () => {
const input = [this.models[0], this.models[1], this.models[2], this.models[3]];
const expectedOutput = [this.models[0], this.models[1], this.models[2], this.models[3]];
return waitsForPromise(() => {
return DatabaseStore.modelify(Thread, input).then(output => {
expect(output).toEqual(expectedOutput);
});
});
})
);
});
describe("count", () => {
it("should pass the provided predicates on to the ModelQuery", () => {
DatabaseStore.findAll(TestModel, testMatchers);
expect(ModelQuery.prototype.where).toHaveBeenCalledWith(testMatchers);
});
it("should return a ModelQuery configured for COUNT ready to be executed", () => {
const q = DatabaseStore.findAll(TestModel, testMatchers);
expect(q.sql()).toBe("SELECT `TestModel`.`data` FROM `TestModel` WHERE `TestModel`.`id` = 'b' ");
});
});
describe("inTransaction", () => {
it("calls the provided function inside an exclusive transaction", () =>
waitsForPromise(() => {
return DatabaseStore.inTransaction(() => {
return DatabaseStore._query("TEST");
}).then(() => {
expect(this.performed.length).toBe(3);
expect(this.performed[0].query).toBe("BEGIN IMMEDIATE TRANSACTION");
expect(this.performed[1].query).toBe("TEST");
expect(this.performed[2].query).toBe("COMMIT");
});
})
);
it("preserves resolved values", () =>
waitsForPromise(() => {
return DatabaseStore.inTransaction(() => {
DatabaseStore._query("TEST");
return Promise.resolve("myValue");
}).then(myValue => {
expect(myValue).toBe("myValue");
});
})
);
it("always fires a COMMIT, even if the body function fails", () =>
waitsForPromise(() => {
return DatabaseStore.inTransaction(() => {
throw new Error("BOOO");
}).catch(() => {
expect(this.performed.length).toBe(2);
expect(this.performed[0].query).toBe("BEGIN IMMEDIATE TRANSACTION");
expect(this.performed[1].query).toBe("COMMIT");
});
})
);
it("can be called multiple times and get queued", () =>
waitsForPromise(() => {
return Promise.all([
DatabaseStore.inTransaction(() => { }),
DatabaseStore.inTransaction(() => { }),
DatabaseStore.inTransaction(() => { }),
]).then(() => {
expect(this.performed.length).toBe(6);
expect(this.performed[0].query).toBe("BEGIN IMMEDIATE TRANSACTION");
expect(this.performed[1].query).toBe("COMMIT");
expect(this.performed[2].query).toBe("BEGIN IMMEDIATE TRANSACTION");
expect(this.performed[3].query).toBe("COMMIT");
expect(this.performed[4].query).toBe("BEGIN IMMEDIATE TRANSACTION");
expect(this.performed[5].query).toBe("COMMIT");
});
})
);
it("carries on if one of them fails, but still calls the COMMIT for the failed block", () => {
let caughtError = false;
DatabaseStore.inTransaction(() => DatabaseStore._query("ONE"));
DatabaseStore.inTransaction(() => { throw new Error("fail"); }).catch(() => { caughtError = true });
DatabaseStore.inTransaction(() => DatabaseStore._query("THREE"));
advanceClock(100);
expect(this.performed.length).toBe(8);
expect(this.performed[0].query).toBe("BEGIN IMMEDIATE TRANSACTION");
expect(this.performed[1].query).toBe("ONE");
expect(this.performed[2].query).toBe("COMMIT");
expect(this.performed[3].query).toBe("BEGIN IMMEDIATE TRANSACTION");
expect(this.performed[4].query).toBe("COMMIT");
expect(this.performed[5].query).toBe("BEGIN IMMEDIATE TRANSACTION");
expect(this.performed[6].query).toBe("THREE");
expect(this.performed[7].query).toBe("COMMIT");
expect(caughtError).toBe(true);
});
it("is actually running in series and blocks on never-finishing specs", () => {
let resolver = null;
DatabaseStore.inTransaction(() => { });
advanceClock(100);
expect(this.performed.length).toBe(2);
expect(this.performed[0].query).toBe("BEGIN IMMEDIATE TRANSACTION");
expect(this.performed[1].query).toBe("COMMIT");
DatabaseStore.inTransaction(() => new Promise((resolve) => { resolver = resolve }));
advanceClock(100);
let blockedPromiseDone = false;
DatabaseStore.inTransaction(() => { }).then(() => {
blockedPromiseDone = true;
});
advanceClock(100);
expect(this.performed.length).toBe(3);
expect(this.performed[2].query).toBe("BEGIN IMMEDIATE TRANSACTION");
expect(blockedPromiseDone).toBe(false);
// Now that we've made our assertion about blocking, we need to clean up
// our test and actually resolve that blocked promise now, otherwise
// remaining tests won't run properly.
advanceClock(100);
resolver();
advanceClock(100);
expect(blockedPromiseDone).toBe(true);
return advanceClock(100);
});
it("can be called multiple times and preserve return values", () =>
waitsForPromise(() => {
let v1 = null;
let v2 = null;
let v3 = null;
return Promise.all([
DatabaseStore.inTransaction(() => "a").then(val => { v1 = val }),
DatabaseStore.inTransaction(() => "b").then(val => { v2 = val }),
DatabaseStore.inTransaction(() => "c").then(val => { v3 = val }),
]).then(() => {
expect(v1).toBe("a");
expect(v2).toBe("b");
expect(v3).toBe("c");
});
})
);
it("can be called multiple times and get queued", () =>
waitsForPromise(() => {
return DatabaseStore.inTransaction(() => { })
.then(() => DatabaseStore.inTransaction(() => { }))
.then(() => DatabaseStore.inTransaction(() => { }))
.then(() => {
expect(this.performed.length).toBe(6);
expect(this.performed[0].query).toBe("BEGIN IMMEDIATE TRANSACTION");
expect(this.performed[1].query).toBe("COMMIT");
expect(this.performed[2].query).toBe("BEGIN IMMEDIATE TRANSACTION");
expect(this.performed[3].query).toBe("COMMIT");
expect(this.performed[4].query).toBe("BEGIN IMMEDIATE TRANSACTION");
expect(this.performed[5].query).toBe("COMMIT");
});
})
);
});
});

View file

@ -1,299 +0,0 @@
_ = require 'underscore'
Category = require '../../src/flux/models/category'
Thread = require('../../src/flux/models/thread').default
TestModel = require '../fixtures/db-test-model'
ModelQuery = require('../../src/flux/models/query').default
DatabaseTransaction = require('../../src/flux/stores/database-transaction').default
testMatchers = {'id': 'b'}
testModelInstance = new TestModel(id: "1234")
testModelInstanceA = new TestModel(id: "AAA")
testModelInstanceB = new TestModel(id: "BBB")
describe "DatabaseTransaction", ->
beforeEach ->
@databaseMutationHooks = []
@performed = []
@database =
_query: jasmine.createSpy('database._query').andCallFake (query, values=[], options={}) =>
@performed.push({query, values})
Promise.resolve([])
accumulateAndTrigger: jasmine.createSpy('database.accumulateAndTrigger')
mutationHooks: => @databaseMutationHooks
@transaction = new DatabaseTransaction(@database)
describe "execute", ->
describe "persistModel", ->
it "should throw an exception if the model is not a subclass of Model", ->
expect(=> @transaction.persistModel({id: 'asd', subject: 'bla'})).toThrow()
it "should call through to persistModels", ->
spyOn(@transaction, 'persistModels').andReturn Promise.resolve()
@transaction.persistModel(testModelInstance)
advanceClock()
expect(@transaction.persistModels.callCount).toBe(1)
describe "persistModels", ->
it "should call accumulateAndTrigger with a change that contains the models", ->
runs =>
@transaction.execute (t) =>
t.persistModels([testModelInstanceA, testModelInstanceB])
waitsFor =>
@database.accumulateAndTrigger.callCount > 0
runs =>
change = @database.accumulateAndTrigger.mostRecentCall.args[0]
expect(change).toEqual
objectClass: TestModel.name,
objectIds: [testModelInstanceA.id, testModelInstanceB.id]
objects: [testModelInstanceA, testModelInstanceB]
type:'persist'
it "should call through to _writeModels after checking them", ->
spyOn(@transaction, '_writeModels').andReturn Promise.resolve()
@transaction.persistModels([testModelInstanceA, testModelInstanceB])
advanceClock()
expect(@transaction._writeModels.callCount).toBe(1)
it "should throw an exception if the models are not the same class,\
since it cannot be specified by the trigger payload", ->
expect(=> @transaction.persistModels([testModelInstanceA, new Category()])).toThrow()
it "should throw an exception if the models are not a subclass of Model", ->
expect(=> @transaction.persistModels([{id: 'asd', subject: 'bla'}])).toThrow()
describe "mutationHooks", ->
beforeEach ->
@beforeShouldThrow = false
@beforeShouldReject = false
@hook =
beforeDatabaseChange: jasmine.createSpy('beforeDatabaseChange').andCallFake =>
throw new Error("beforeShouldThrow") if @beforeShouldThrow
new Promise (resolve, reject) =>
setTimeout =>
return resolve(new Error("beforeShouldReject")) if @beforeShouldReject
resolve("value")
, 1000
afterDatabaseChange: jasmine.createSpy('afterDatabaseChange').andCallFake =>
new Promise (resolve, reject) ->
setTimeout(( => resolve()), 1000)
@databaseMutationHooks.push(@hook)
@writeModelsResolve = null
spyOn(@transaction, '_writeModels').andCallFake =>
new Promise (resolve, reject) =>
@writeModelsResolve = resolve
it "should run pre-mutation hooks, wait to write models, and then run post-mutation hooks", ->
@transaction.persistModels([testModelInstanceA, testModelInstanceB])
advanceClock()
expect(@hook.beforeDatabaseChange).toHaveBeenCalledWith(
@transaction._query,
{
objects: [testModelInstanceA, testModelInstanceB]
objectIds: [testModelInstanceA.id, testModelInstanceB.id]
objectClass: testModelInstanceA.constructor.name
type: 'persist'
},
undefined
)
expect(@transaction._writeModels).not.toHaveBeenCalled()
advanceClock(1100)
advanceClock()
expect(@transaction._writeModels).toHaveBeenCalled()
expect(@hook.afterDatabaseChange).not.toHaveBeenCalled()
@writeModelsResolve()
advanceClock()
advanceClock()
expect(@hook.afterDatabaseChange).toHaveBeenCalledWith(
@transaction._query,
{
objects: [testModelInstanceA, testModelInstanceB]
objectIds: [testModelInstanceA.id, testModelInstanceB.id]
objectClass: testModelInstanceA.constructor.name
type: 'persist'
},
"value"
)
it "should carry on if a pre-mutation hook throws", ->
@beforeShouldThrow = true
@transaction.persistModels([testModelInstanceA, testModelInstanceB])
advanceClock(1000)
expect(@hook.beforeDatabaseChange).toHaveBeenCalled()
advanceClock()
advanceClock()
expect(@transaction._writeModels).toHaveBeenCalled()
it "should carry on if a pre-mutation hook rejects", ->
@beforeShouldReject = true
@transaction.persistModels([testModelInstanceA, testModelInstanceB])
advanceClock(1000)
expect(@hook.beforeDatabaseChange).toHaveBeenCalled()
advanceClock()
advanceClock()
expect(@transaction._writeModels).toHaveBeenCalled()
describe "unpersistModel", ->
it "should delete the model by id", ->
waitsForPromise =>
@transaction.execute =>
@transaction.unpersistModel(testModelInstance)
.then =>
expect(@performed.length).toBe(3)
expect(@performed[0].query).toBe("BEGIN IMMEDIATE TRANSACTION")
expect(@performed[1].query).toBe("DELETE FROM `TestModel` WHERE `id` = ?")
expect(@performed[1].values[0]).toBe('1234')
expect(@performed[2].query).toBe("COMMIT")
it "should call accumulateAndTrigger with a change that contains the model", ->
runs =>
@transaction.execute =>
@transaction.unpersistModel(testModelInstance)
waitsFor =>
@database.accumulateAndTrigger.callCount > 0
runs =>
change = @database.accumulateAndTrigger.mostRecentCall.args[0]
expect(change).toEqual({
objectClass: TestModel.name,
objectIds: [testModelInstance.id]
objects: [testModelInstance],
type:'unpersist'
})
describe "when the model has collection attributes", ->
it "should delete all of the elements in the join tables", ->
TestModel.configureWithCollectionAttribute()
waitsForPromise =>
@transaction.execute (t) =>
t.unpersistModel(testModelInstance)
.then =>
expect(@performed.length).toBe(4)
expect(@performed[0].query).toBe("BEGIN IMMEDIATE TRANSACTION")
expect(@performed[2].query).toBe("DELETE FROM `TestModelCategory` WHERE `id` = ?")
expect(@performed[2].values[0]).toBe('1234')
expect(@performed[3].query).toBe("COMMIT")
describe "when the model has joined data attributes", ->
it "should delete the element in the joined data table", ->
TestModel.configureWithJoinedDataAttribute()
waitsForPromise =>
@transaction.execute (t) =>
t.unpersistModel(testModelInstance)
.then =>
expect(@performed.length).toBe(4)
expect(@performed[0].query).toBe("BEGIN IMMEDIATE TRANSACTION")
expect(@performed[2].query).toBe("DELETE FROM `TestModelBody` WHERE `id` = ?")
expect(@performed[2].values[0]).toBe('1234')
expect(@performed[3].query).toBe("COMMIT")
describe "_writeModels", ->
it "should compose a REPLACE INTO query to save the model", ->
TestModel.configureWithCollectionAttribute()
@transaction._writeModels([testModelInstance])
expect(@performed[0].query).toBe("REPLACE INTO `TestModel` (id,data,client_id,server_id,other) VALUES (?,?,?,?,?)")
it "should save the model JSON into the data column", ->
@transaction._writeModels([testModelInstance])
expect(@performed[0].values[1]).toEqual(JSON.stringify(testModelInstance))
describe "when the model defines additional queryable attributes", ->
beforeEach ->
TestModel.configureWithAllAttributes()
@m = new TestModel
id: 'local-6806434c-b0cd'
datetime: new Date()
string: 'hello world',
boolean: true,
number: 15
it "should populate additional columns defined by the attributes", ->
@transaction._writeModels([@m])
expect(@performed[0].query).toBe("REPLACE INTO `TestModel` (id,data,datetime,string-json-key,boolean,number) VALUES (?,?,?,?,?,?)")
it "should use the JSON-form values of the queryable attributes", ->
json = @m.toJSON()
@transaction._writeModels([@m])
values = @performed[0].values
expect(values[2]).toEqual(json['datetime'])
expect(values[3]).toEqual(json['string-json-key'])
expect(values[4]).toEqual(json['boolean'])
expect(values[5]).toEqual(json['number'])
describe "when the model has collection attributes", ->
beforeEach ->
TestModel.configureWithCollectionAttribute()
@m = new TestModel(id: 'local-6806434c-b0cd', other: 'other')
@m.categories = [new Category(id: 'a'),new Category(id: 'b')]
@transaction._writeModels([@m])
it "should delete all association records for the model from join tables", ->
expect(@performed[1].query).toBe('DELETE FROM `TestModelCategory` WHERE `id` IN (\'local-6806434c-b0cd\')')
it "should insert new association records into join tables in a single query, and include queryableBy columns", ->
expect(@performed[2].query).toBe('INSERT OR IGNORE INTO `TestModelCategory` (`id`,`value`,`other`) VALUES (?,?,?),(?,?,?)')
expect(@performed[2].values).toEqual(['local-6806434c-b0cd', 'a', 'other','local-6806434c-b0cd', 'b', 'other'])
describe "model collection attributes query building", ->
beforeEach ->
TestModel.configureWithCollectionAttribute()
@m = new TestModel(id: 'local-6806434c-b0cd', other: 'other')
@m.categories = []
it "should page association records into multiple queries correctly", ->
@m.categories.push(new Category(id: "id-#{i}")) for i in [0..199]
@transaction._writeModels([@m])
collectionAttributeQueries = _.filter @performed, (i) ->
i.query.indexOf('INSERT OR IGNORE INTO `TestModelCategory`') == 0
expect(collectionAttributeQueries.length).toBe(1)
expect(collectionAttributeQueries[0].values[200*3-2]).toEqual('id-199')
it "should page association records into multiple queries correctly", ->
@m.categories.push(new Category(id: "id-#{i}")) for i in [0..200]
@transaction._writeModels([@m])
collectionAttributeQueries = _.filter @performed, (i) ->
i.query.indexOf('INSERT OR IGNORE INTO `TestModelCategory`') == 0
expect(collectionAttributeQueries.length).toBe(2)
expect(collectionAttributeQueries[0].values[200*3-2]).toEqual('id-199')
expect(collectionAttributeQueries[1].values[1]).toEqual('id-200')
it "should page association records into multiple queries correctly", ->
@m.categories.push(new Category(id: "id-#{i}")) for i in [0..201]
@transaction._writeModels([@m])
collectionAttributeQueries = _.filter @performed, (i) ->
i.query.indexOf('INSERT OR IGNORE INTO `TestModelCategory`') == 0
expect(collectionAttributeQueries.length).toBe(2)
expect(collectionAttributeQueries[0].values[200*3-2]).toEqual('id-199')
expect(collectionAttributeQueries[1].values[1]).toEqual('id-200')
expect(collectionAttributeQueries[1].values[4]).toEqual('id-201')
describe "when the model has joined data attributes", ->
beforeEach ->
TestModel.configureWithJoinedDataAttribute()
it "should not include the value to the joined attribute in the JSON written to the main model table", ->
@m = new TestModel(clientId: 'local-6806434c-b0cd', serverId: 'server-1', body: 'hello world')
@transaction._writeModels([@m])
expect(@performed[0].values).toEqual(['server-1', '{"client_id":"local-6806434c-b0cd","server_id":"server-1","id":"server-1"}', 'local-6806434c-b0cd', 'server-1'])
it "should write the value to the joined table if it is defined", ->
@m = new TestModel(id: 'local-6806434c-b0cd', body: 'hello world')
@transaction._writeModels([@m])
expect(@performed[1].query).toBe('REPLACE INTO `TestModelBody` (`id`, `value`) VALUES (?, ?)')
expect(@performed[1].values).toEqual([@m.id, @m.body])
it "should not write the value to the joined table if it undefined", ->
@m = new TestModel(id: 'local-6806434c-b0cd')
@transaction._writeModels([@m])
expect(@performed.length).toBe(1)

View file

@ -0,0 +1,387 @@
/* eslint dot-notation:0 */
import Category from '../../src/flux/models/category';
import TestModel from '../fixtures/db-test-model';
import DatabaseTransaction from '../../src/flux/stores/database-transaction';
const testModelInstance = new TestModel({id: "1234"});
const testModelInstanceA = new TestModel({id: "AAA"});
const testModelInstanceB = new TestModel({id: "BBB"});
function __range__(left, right, inclusive) {
const range = [];
const ascending = left < right;
const incr = ascending ? right + 1 : right - 1;
const end = !inclusive ? right : incr;
for (let i = left; ascending ? i < end : i > end; ascending ? i++ : i--) {
range.push(i);
}
return range;
}
describe("DatabaseTransaction", function DatabaseTransactionSpecs() {
beforeEach(() => {
this.databaseMutationHooks = [];
this.performed = [];
this.database = {
_query: jasmine.createSpy('database._query').andCallFake((query, values = []) => {
this.performed.push({query, values});
return Promise.resolve([]);
}),
accumulateAndTrigger: jasmine.createSpy('database.accumulateAndTrigger'),
mutationHooks: () => this.databaseMutationHooks,
};
this.transaction = new DatabaseTransaction(this.database);
});
describe("execute", () => {});
describe("persistModel", () => {
it("should throw an exception if the model is not a subclass of Model", () => expect(() => this.transaction.persistModel({id: 'asd', subject: 'bla'})).toThrow()
);
it("should call through to persistModels", () => {
spyOn(this.transaction, 'persistModels').andReturn(Promise.resolve());
this.transaction.persistModel(testModelInstance);
advanceClock();
expect(this.transaction.persistModels.callCount).toBe(1);
});
});
describe("persistModels", () => {
it("should call accumulateAndTrigger with a change that contains the models", () => {
runs(() => {
return this.transaction.execute(t => {
return t.persistModels([testModelInstanceA, testModelInstanceB]);
});
});
waitsFor(() => {
return this.database.accumulateAndTrigger.callCount > 0;
});
runs(() => {
const change = this.database.accumulateAndTrigger.mostRecentCall.args[0];
expect(change).toEqual({
objectClass: TestModel.name,
objectIds: [testModelInstanceA.id, testModelInstanceB.id],
objects: [testModelInstanceA, testModelInstanceB],
type: 'persist',
});
});
});
it("should call through to _writeModels after checking them", () => {
spyOn(this.transaction, '_writeModels').andReturn(Promise.resolve());
this.transaction.persistModels([testModelInstanceA, testModelInstanceB]);
advanceClock();
expect(this.transaction._writeModels.callCount).toBe(1);
});
it("should throw an exception if the models are not the same class, since it cannot be specified by the trigger payload", () =>
expect(() => this.transaction.persistModels([testModelInstanceA, new Category()])).toThrow()
);
it("should throw an exception if the models are not a subclass of Model", () =>
expect(() => this.transaction.persistModels([{id: 'asd', subject: 'bla'}])).toThrow()
);
describe("mutationHooks", () => {
beforeEach(() => {
this.beforeShouldThrow = false;
this.beforeShouldReject = false;
this.hook = {
beforeDatabaseChange: jasmine.createSpy('beforeDatabaseChange').andCallFake(() => {
if (this.beforeShouldThrow) { throw new Error("beforeShouldThrow"); }
return new Promise((resolve) => {
setTimeout(() => {
if (this.beforeShouldReject) { resolve(new Error("beforeShouldReject")); }
resolve("value");
}
, 1000);
});
}),
afterDatabaseChange: jasmine.createSpy('afterDatabaseChange').andCallFake(() => {
return new Promise((resolve) => setTimeout(() => resolve(), 1000));
}),
};
this.databaseMutationHooks.push(this.hook);
this.writeModelsResolve = null;
spyOn(this.transaction, '_writeModels').andCallFake(() => {
return new Promise((resolve) => {
this.writeModelsResolve = resolve;
});
});
});
it("should run pre-mutation hooks, wait to write models, and then run post-mutation hooks", () => {
this.transaction.persistModels([testModelInstanceA, testModelInstanceB]);
advanceClock();
expect(this.hook.beforeDatabaseChange).toHaveBeenCalledWith(
this.transaction._query,
{
objects: [testModelInstanceA, testModelInstanceB],
objectIds: [testModelInstanceA.id, testModelInstanceB.id],
objectClass: testModelInstanceA.constructor.name,
type: 'persist',
},
undefined
);
expect(this.transaction._writeModels).not.toHaveBeenCalled();
advanceClock(1100);
advanceClock();
expect(this.transaction._writeModels).toHaveBeenCalled();
expect(this.hook.afterDatabaseChange).not.toHaveBeenCalled();
this.writeModelsResolve();
advanceClock();
advanceClock();
expect(this.hook.afterDatabaseChange).toHaveBeenCalledWith(
this.transaction._query,
{
objects: [testModelInstanceA, testModelInstanceB],
objectIds: [testModelInstanceA.id, testModelInstanceB.id],
objectClass: testModelInstanceA.constructor.name,
type: 'persist',
},
"value"
);
});
it("should carry on if a pre-mutation hook throws", () => {
this.beforeShouldThrow = true;
this.transaction.persistModels([testModelInstanceA, testModelInstanceB]);
advanceClock(1000);
expect(this.hook.beforeDatabaseChange).toHaveBeenCalled();
advanceClock();
advanceClock();
expect(this.transaction._writeModels).toHaveBeenCalled();
});
it("should carry on if a pre-mutation hook rejects", () => {
this.beforeShouldReject = true;
this.transaction.persistModels([testModelInstanceA, testModelInstanceB]);
advanceClock(1000);
expect(this.hook.beforeDatabaseChange).toHaveBeenCalled();
advanceClock();
advanceClock();
expect(this.transaction._writeModels).toHaveBeenCalled();
});
});
});
describe("unpersistModel", () => {
it("should delete the model by id", () =>
waitsForPromise(() => {
return this.transaction.execute(() => {
return this.transaction.unpersistModel(testModelInstance);
})
.then(() => {
expect(this.performed.length).toBe(3);
expect(this.performed[0].query).toBe("BEGIN IMMEDIATE TRANSACTION");
expect(this.performed[1].query).toBe("DELETE FROM `TestModel` WHERE `id` = ?");
expect(this.performed[1].values[0]).toBe('1234');
expect(this.performed[2].query).toBe("COMMIT");
});
})
);
it("should call accumulateAndTrigger with a change that contains the model", () => {
runs(() => {
return this.transaction.execute(() => {
return this.transaction.unpersistModel(testModelInstance);
});
});
waitsFor(() => {
return this.database.accumulateAndTrigger.callCount > 0;
});
runs(() => {
const change = this.database.accumulateAndTrigger.mostRecentCall.args[0];
expect(change).toEqual({
objectClass: TestModel.name,
objectIds: [testModelInstance.id],
objects: [testModelInstance],
type: 'unpersist',
});
});
});
describe("when the model has collection attributes", () =>
it("should delete all of the elements in the join tables", () => {
TestModel.configureWithCollectionAttribute();
waitsForPromise(() => {
return this.transaction.execute(t => {
return t.unpersistModel(testModelInstance);
})
.then(() => {
expect(this.performed.length).toBe(4);
expect(this.performed[0].query).toBe("BEGIN IMMEDIATE TRANSACTION");
expect(this.performed[2].query).toBe("DELETE FROM `TestModelCategory` WHERE `id` = ?");
expect(this.performed[2].values[0]).toBe('1234');
expect(this.performed[3].query).toBe("COMMIT");
});
});
})
);
describe("when the model has joined data attributes", () =>
it("should delete the element in the joined data table", () => {
TestModel.configureWithJoinedDataAttribute();
waitsForPromise(() => {
return this.transaction.execute(t => {
return t.unpersistModel(testModelInstance);
})
.then(() => {
expect(this.performed.length).toBe(4);
expect(this.performed[0].query).toBe("BEGIN IMMEDIATE TRANSACTION");
expect(this.performed[2].query).toBe("DELETE FROM `TestModelBody` WHERE `id` = ?");
expect(this.performed[2].values[0]).toBe('1234');
expect(this.performed[3].query).toBe("COMMIT");
});
});
})
);
});
describe("_writeModels", () => {
it("should compose a REPLACE INTO query to save the model", () => {
TestModel.configureWithCollectionAttribute();
this.transaction._writeModels([testModelInstance]);
expect(this.performed[0].query).toBe("REPLACE INTO `TestModel` (id,data,client_id,server_id,other) VALUES (?,?,?,?,?)");
});
it("should save the model JSON into the data column", () => {
this.transaction._writeModels([testModelInstance]);
expect(this.performed[0].values[1]).toEqual(JSON.stringify(testModelInstance));
});
describe("when the model defines additional queryable attributes", () => {
beforeEach(() => {
TestModel.configureWithAllAttributes();
this.m = new TestModel({
'id': 'local-6806434c-b0cd',
'datetime': new Date(),
'string': 'hello world',
'boolean': true,
'number': 15,
});
});
it("should populate additional columns defined by the attributes", () => {
this.transaction._writeModels([this.m]);
expect(this.performed[0].query).toBe("REPLACE INTO `TestModel` (id,data,datetime,string-json-key,boolean,number) VALUES (?,?,?,?,?,?)");
});
it("should use the JSON-form values of the queryable attributes", () => {
const json = this.m.toJSON();
this.transaction._writeModels([this.m]);
const { values } = this.performed[0];
expect(values[2]).toEqual(json['datetime']);
expect(values[3]).toEqual(json['string-json-key']);
expect(values[4]).toEqual(json['boolean']);
expect(values[5]).toEqual(json['number']);
});
});
describe("when the model has collection attributes", () => {
beforeEach(() => {
TestModel.configureWithCollectionAttribute();
this.m = new TestModel({id: 'local-6806434c-b0cd', other: 'other'});
this.m.categories = [new Category({id: 'a'}), new Category({id: 'b'})];
this.transaction._writeModels([this.m]);
});
it("should delete all association records for the model from join tables", () => {
expect(this.performed[1].query).toBe('DELETE FROM `TestModelCategory` WHERE `id` IN (\'local-6806434c-b0cd\')');
});
it("should insert new association records into join tables in a single query, and include queryableBy columns", () => {
expect(this.performed[2].query).toBe('INSERT OR IGNORE INTO `TestModelCategory` (`id`,`value`,`other`) VALUES (?,?,?),(?,?,?)');
expect(this.performed[2].values).toEqual(['local-6806434c-b0cd', 'a', 'other', 'local-6806434c-b0cd', 'b', 'other']);
});
});
describe("model collection attributes query building", () => {
beforeEach(() => {
TestModel.configureWithCollectionAttribute();
this.m = new TestModel({id: 'local-6806434c-b0cd', other: 'other'});
this.m.categories = [];
});
it("should page association records into multiple queries correctly", () => {
const iterable = __range__(0, 199, true);
for (let j = 0; j < iterable.length; j++) {
const i = iterable[j];
this.m.categories.push(new Category({id: `id-${i}`}));
}
this.transaction._writeModels([this.m]);
const collectionAttributeQueries = this.performed.filter(i => i.query.indexOf('INSERT OR IGNORE INTO `TestModelCategory`') === 0
);
expect(collectionAttributeQueries.length).toBe(1);
expect(collectionAttributeQueries[0].values[(200 * 3) - 2]).toEqual('id-199');
});
it("should page association records into multiple queries correctly", () => {
const iterable = __range__(0, 200, true);
for (let j = 0; j < iterable.length; j++) {
const i = iterable[j];
this.m.categories.push(new Category({id: `id-${i}`}));
}
this.transaction._writeModels([this.m]);
const collectionAttributeQueries = this.performed.filter(i => i.query.indexOf('INSERT OR IGNORE INTO `TestModelCategory`') === 0
);
expect(collectionAttributeQueries.length).toBe(2);
expect(collectionAttributeQueries[0].values[(200 * 3) - 2]).toEqual('id-199');
expect(collectionAttributeQueries[1].values[1]).toEqual('id-200');
});
it("should page association records into multiple queries correctly", () => {
const iterable = __range__(0, 201, true);
for (let j = 0; j < iterable.length; j++) {
const i = iterable[j];
this.m.categories.push(new Category({id: `id-${i}`}));
}
this.transaction._writeModels([this.m]);
const collectionAttributeQueries = this.performed.filter(i => i.query.indexOf('INSERT OR IGNORE INTO `TestModelCategory`') === 0
);
expect(collectionAttributeQueries.length).toBe(2);
expect(collectionAttributeQueries[0].values[(200 * 3) - 2]).toEqual('id-199');
expect(collectionAttributeQueries[1].values[1]).toEqual('id-200');
expect(collectionAttributeQueries[1].values[4]).toEqual('id-201');
});
});
describe("when the model has joined data attributes", () => {
beforeEach(() => TestModel.configureWithJoinedDataAttribute());
it("should not include the value to the joined attribute in the JSON written to the main model table", () => {
this.m = new TestModel({clientId: 'local-6806434c-b0cd', serverId: 'server-1', body: 'hello world'});
this.transaction._writeModels([this.m]);
expect(this.performed[0].values).toEqual(['server-1', '{"client_id":"local-6806434c-b0cd","server_id":"server-1","id":"server-1"}', 'local-6806434c-b0cd', 'server-1']);
});
it("should write the value to the joined table if it is defined", () => {
this.m = new TestModel({id: 'local-6806434c-b0cd', body: 'hello world'});
this.transaction._writeModels([this.m]);
expect(this.performed[1].query).toBe('REPLACE INTO `TestModelBody` (`id`, `value`) VALUES (?, ?)');
expect(this.performed[1].values).toEqual([this.m.id, this.m.body]);
});
it("should not write the value to the joined table if it undefined", () => {
this.m = new TestModel({id: 'local-6806434c-b0cd'});
this.transaction._writeModels([this.m]);
expect(this.performed.length).toBe(1);
});
});
});
});