Home Manual Reference Source Test

spec/database-transaction-spec.js

  1. /* eslint dot-notation:0 */
  2. import {Database, TestModel, Category} from './fixtures';
  3. import DatabaseTransaction from '../lib/database-transaction';
  4. import DatabaseChangeRecord from '../lib/database-change-record';
  5.  
  6. const testModelInstance = new TestModel({id: "1234"});
  7. const testModelInstanceA = new TestModel({id: "AAA"});
  8. const testModelInstanceB = new TestModel({id: "BBB"});
  9.  
  10. function __range__(left, right, inclusive) {
  11. const range = [];
  12. const ascending = left < right;
  13. const incr = ascending ? right + 1 : right - 1;
  14. const end = !inclusive ? right : incr;
  15. for (let i = left; ascending ? i < end : i > end; ascending ? i++ : i--) {
  16. range.push(i);
  17. }
  18. return range;
  19. }
  20.  
  21. describe("DatabaseTransaction", function DatabaseTransactionSpecs() {
  22. beforeEach(() => {
  23. this.databaseMutationHooks = [];
  24. this.performed = [];
  25.  
  26. spyOn(Database, '_query').and.callFake((query, values = []) => {
  27. this.performed.push({query, values});
  28. return Promise.resolve([]);
  29. });
  30. spyOn(Database, 'transactionDidCommitChanges');
  31. spyOn(Database, 'mutationHooks').and.returnValue(this.databaseMutationHooks)
  32.  
  33. this.transaction = new DatabaseTransaction(Database);
  34.  
  35. jasmine.clock().install();
  36. });
  37.  
  38. afterEach(() => {
  39. jasmine.clock().uninstall();
  40. });
  41.  
  42. describe("execute", () => {});
  43.  
  44. describe("persistModel", () => {
  45. it("should throw an exception if the model is not a subclass of Model", () =>
  46. expect(() => this.transaction.persistModel({id: 'asd', subject: 'bla'})).toThrow()
  47. );
  48.  
  49. it("should call through to persistModels", () => {
  50. spyOn(this.transaction, 'persistModels').and.returnValue(Promise.resolve());
  51. this.transaction.persistModel(testModelInstance);
  52. jasmine.clock().tick();
  53. expect(this.transaction.persistModels.calls.count()).toBe(1);
  54. });
  55. });
  56.  
  57. describe("persistModels", () => {
  58. it("should call transactionDidCommitChanges with a change that contains the models", (done) => {
  59. this.transaction.execute(t => {
  60. return t.persistModels([testModelInstanceA, testModelInstanceB]);
  61. });
  62.  
  63. jasmine.waitFor(() =>
  64. Database.transactionDidCommitChanges.calls.count() > 0
  65. )
  66. .then(() => {
  67. const change = Database.transactionDidCommitChanges.calls.first().args[0];
  68. expect(change).toEqual([new DatabaseChangeRecord(Database, {
  69. objectClass: TestModel.name,
  70. objectIds: [testModelInstanceA.id, testModelInstanceB.id],
  71. objects: [testModelInstanceA, testModelInstanceB],
  72. type: 'persist',
  73. })]);
  74. done();
  75. });
  76. });
  77.  
  78. it("should call through to _writeModels after checking them", (done) => {
  79. spyOn(this.transaction, '_writeModels').and.returnValue(Promise.resolve());
  80. this.transaction.persistModels([testModelInstanceA, testModelInstanceB]);
  81. jasmine.waitFor(() => this.transaction._writeModels.calls.count() > 0).then(() => {
  82. expect(this.transaction._writeModels.calls.count()).toBe(1)
  83. done()
  84. });
  85. });
  86.  
  87. it("should throw an exception if the models are not the same class, since it cannot be specified by the trigger payload", () =>
  88. expect(() => this.transaction.persistModels([testModelInstanceA, new Category()])).toThrow()
  89. );
  90.  
  91. it("should throw an exception if the models are not a subclass of Model", () =>
  92. expect(() => this.transaction.persistModels([{id: 'asd', subject: 'bla'}])).toThrow()
  93. );
  94.  
  95. describe("mutationHooks", () => {
  96. beforeEach(() => {
  97. this.beforeShouldThrow = false;
  98. this.beforeShouldReject = false;
  99.  
  100. this.hook = {
  101. beforeDatabaseChange: jasmine.createSpy('beforeDatabaseChange').and.callFake(() => {
  102. if (this.beforeShouldThrow) { throw new Error("beforeShouldThrow"); }
  103. return new Promise((resolve) => {
  104. setTimeout(() => {
  105. if (this.beforeShouldReject) { resolve(new Error("beforeShouldReject")); }
  106. resolve("value");
  107. }
  108. , 1000);
  109. });
  110. }),
  111. afterDatabaseChange: jasmine.createSpy('afterDatabaseChange').and.callFake(() => {
  112. return new Promise((resolve) => setTimeout(() => resolve(), 1000));
  113. }),
  114. };
  115.  
  116. this.databaseMutationHooks.push(this.hook);
  117.  
  118. this.writeModelsResolve = null;
  119. spyOn(this.transaction, '_writeModels').and.callFake(() => {
  120. return new Promise((resolve) => {
  121. this.writeModelsResolve = resolve;
  122. });
  123. });
  124. });
  125.  
  126. it("should run pre-mutation hooks, wait to write models, and then run post-mutation hooks", (done) => {
  127. this.transaction.persistModels([testModelInstanceA, testModelInstanceB]);
  128.  
  129. expect(this.hook.beforeDatabaseChange).toHaveBeenCalledWith(
  130. this.transaction._query,
  131. {
  132. objects: [testModelInstanceA, testModelInstanceB],
  133. objectIds: [testModelInstanceA.id, testModelInstanceB.id],
  134. objectClass: testModelInstanceA.constructor.name,
  135. type: 'persist',
  136. },
  137. undefined
  138. );
  139. expect(this.transaction._writeModels).not.toHaveBeenCalled();
  140. jasmine.clock().tick(1000);
  141. jasmine.waitFor(() => this.transaction._writeModels.calls.count() > 0).then(() => {
  142. expect(this.hook.afterDatabaseChange).not.toHaveBeenCalled();
  143. this.writeModelsResolve();
  144.  
  145. jasmine.waitFor(() => this.hook.afterDatabaseChange.calls.count() > 0).then(() => {
  146. expect(this.hook.afterDatabaseChange).toHaveBeenCalledWith(
  147. this.transaction._query,
  148. {
  149. objects: [testModelInstanceA, testModelInstanceB],
  150. objectIds: [testModelInstanceA.id, testModelInstanceB.id],
  151. objectClass: testModelInstanceA.constructor.name,
  152. type: 'persist',
  153. },
  154. "value"
  155. );
  156. done();
  157. });
  158. });
  159. });
  160.  
  161. it("should carry on if a pre-mutation hook throws", (done) => {
  162. this.beforeShouldThrow = true;
  163. this.transaction.persistModels([testModelInstanceA, testModelInstanceB]);
  164. jasmine.clock().tick(1000);
  165. expect(this.hook.beforeDatabaseChange).toHaveBeenCalled();
  166. jasmine.waitFor(() => this.transaction._writeModels.calls.count() > 0).then(done);
  167. });
  168.  
  169. it("should carry on if a pre-mutation hook rejects", (done) => {
  170. this.beforeShouldReject = true;
  171. this.transaction.persistModels([testModelInstanceA, testModelInstanceB]);
  172. jasmine.clock().tick(1000);
  173. expect(this.hook.beforeDatabaseChange).toHaveBeenCalled();
  174. jasmine.waitFor(() => this.transaction._writeModels.calls.count() > 0).then(done);
  175. });
  176. });
  177. });
  178.  
  179. describe("unpersistModel", () => {
  180. it("should delete the model by id", (done) =>
  181. this.transaction.execute(() => {
  182. return this.transaction.unpersistModel(testModelInstance);
  183. })
  184. .then(() => {
  185. expect(this.performed.length).toBe(3);
  186. expect(this.performed[0].query).toBe("BEGIN IMMEDIATE TRANSACTION");
  187. expect(this.performed[1].query).toBe("DELETE FROM `TestModel` WHERE `id` = ?");
  188. expect(this.performed[1].values[0]).toBe('1234');
  189. expect(this.performed[2].query).toBe("COMMIT");
  190. done();
  191. })
  192. );
  193.  
  194. it("should call transactionDidCommitChanges with a change that contains the model", (done) => {
  195. this.transaction.execute(() => {
  196. return this.transaction.unpersistModel(testModelInstance);
  197. });
  198. jasmine.waitFor(() =>
  199. Database.transactionDidCommitChanges.calls.count() > 0
  200. ).then(() => {
  201. const change = Database.transactionDidCommitChanges.calls.first().args[0];
  202. expect(change).toEqual([new DatabaseChangeRecord(Database, {
  203. objectClass: TestModel.name,
  204. objectIds: [testModelInstance.id],
  205. objects: [testModelInstance],
  206. type: 'unpersist',
  207. })]);
  208. done();
  209. });
  210. });
  211.  
  212. describe("when the model has collection attributes", () =>
  213. it("should delete all of the elements in the join tables", (done) => {
  214. TestModel.configureWithCollectionAttribute();
  215. this.transaction.execute(t => {
  216. return t.unpersistModel(testModelInstance);
  217. })
  218. .then(() => {
  219. expect(this.performed.length).toBe(4);
  220. expect(this.performed[0].query).toBe("BEGIN IMMEDIATE TRANSACTION");
  221. expect(this.performed[2].query).toBe("DELETE FROM `TestModelCategory` WHERE `id` = ?");
  222. expect(this.performed[2].values[0]).toBe('1234');
  223. expect(this.performed[3].query).toBe("COMMIT");
  224. done();
  225. });
  226. })
  227.  
  228. );
  229.  
  230. describe("when the model has joined data attributes", () =>
  231. it("should delete the element in the joined data table", (done) => {
  232. TestModel.configureWithJoinedDataAttribute();
  233. this.transaction.execute(t => {
  234. return t.unpersistModel(testModelInstance);
  235. })
  236. .then(() => {
  237. expect(this.performed.length).toBe(4);
  238. expect(this.performed[0].query).toBe("BEGIN IMMEDIATE TRANSACTION");
  239. expect(this.performed[2].query).toBe("DELETE FROM `TestModelBody` WHERE `id` = ?");
  240. expect(this.performed[2].values[0]).toBe('1234');
  241. expect(this.performed[3].query).toBe("COMMIT");
  242. done();
  243. });
  244. })
  245.  
  246. );
  247. });
  248.  
  249. describe("_writeModels", () => {
  250. it("should compose a REPLACE INTO query to save the model", () => {
  251. TestModel.configureWithCollectionAttribute();
  252. this.transaction._writeModels([testModelInstance]);
  253. expect(this.performed[0].query).toBe("REPLACE INTO `TestModel` (id,data,other) VALUES (?,?,?)");
  254. });
  255.  
  256. it("should save the model JSON into the data column", () => {
  257. this.transaction._writeModels([testModelInstance]);
  258. expect(this.performed[0].values[1]).toEqual(JSON.stringify(testModelInstance));
  259. });
  260.  
  261. describe("when the model defines additional queryable attributes", () => {
  262. beforeEach(() => {
  263. TestModel.configureWithAllAttributes();
  264. this.m = new TestModel({
  265. id: 'local-6806434c-b0cd',
  266. datetime: new Date(),
  267. string: 'hello world',
  268. boolean: true,
  269. number: 15,
  270. });
  271. });
  272.  
  273. it("should populate additional columns defined by the attributes", () => {
  274. this.transaction._writeModels([this.m]);
  275. expect(this.performed[0].query).toBe("REPLACE INTO `TestModel` (id,data,datetime,string-json-key,boolean,number) VALUES (?,?,?,?,?,?)");
  276. });
  277.  
  278. it("should use the JSON-form values of the queryable attributes", () => {
  279. const json = this.m.toJSON();
  280. this.transaction._writeModels([this.m]);
  281.  
  282. const { values } = this.performed[0];
  283. expect(values[2]).toEqual(json['datetime']);
  284. expect(values[3]).toEqual(json['string-json-key']);
  285. expect(values[4]).toEqual(json['boolean']);
  286. expect(values[5]).toEqual(json['number']);
  287. });
  288. });
  289.  
  290. describe("when the model has collection attributes", () => {
  291. beforeEach(() => {
  292. TestModel.configureWithCollectionAttribute();
  293. this.m = new TestModel({id: 'local-6806434c-b0cd', other: 'other'});
  294. this.m.categories = [new Category({id: 'a'}), new Category({id: 'b'})];
  295. this.transaction._writeModels([this.m]);
  296. });
  297.  
  298. it("should delete all association records for the model from join tables", () => {
  299. expect(this.performed[1].query).toBe('DELETE FROM `TestModelCategory` WHERE `id` IN (\'local-6806434c-b0cd\')');
  300. });
  301.  
  302. it("should insert new association records into join tables in a single query, and include queryableBy columns", () => {
  303. expect(this.performed[2].query).toBe('INSERT OR IGNORE INTO `TestModelCategory` (`id`,`value`,`other`) VALUES (?,?,?),(?,?,?)');
  304. expect(this.performed[2].values).toEqual(['local-6806434c-b0cd', 'a', 'other', 'local-6806434c-b0cd', 'b', 'other']);
  305. });
  306. });
  307.  
  308. describe("model collection attributes query building", () => {
  309. beforeEach(() => {
  310. TestModel.configureWithCollectionAttribute();
  311. this.m = new TestModel({id: 'local-6806434c-b0cd', other: 'other'});
  312. this.m.categories = [];
  313. });
  314.  
  315. it("should page association records into multiple queries correctly", () => {
  316. const iterable = __range__(0, 199, true);
  317. for (let j = 0; j < iterable.length; j++) {
  318. const i = iterable[j];
  319. this.m.categories.push(new Category({id: `id-${i}`}));
  320. }
  321. this.transaction._writeModels([this.m]);
  322.  
  323. const collectionAttributeQueries = this.performed.filter(i => i.query.indexOf('INSERT OR IGNORE INTO `TestModelCategory`') === 0
  324. );
  325.  
  326. expect(collectionAttributeQueries.length).toBe(1);
  327. expect(collectionAttributeQueries[0].values[(200 * 3) - 2]).toEqual('id-199');
  328. });
  329.  
  330. it("should page association records into multiple queries correctly", () => {
  331. const iterable = __range__(0, 200, true);
  332. for (let j = 0; j < iterable.length; j++) {
  333. const i = iterable[j];
  334. this.m.categories.push(new Category({id: `id-${i}`}));
  335. }
  336. this.transaction._writeModels([this.m]);
  337.  
  338. const collectionAttributeQueries = this.performed.filter(i => i.query.indexOf('INSERT OR IGNORE INTO `TestModelCategory`') === 0
  339. );
  340.  
  341. expect(collectionAttributeQueries.length).toBe(2);
  342. expect(collectionAttributeQueries[0].values[(200 * 3) - 2]).toEqual('id-199');
  343. expect(collectionAttributeQueries[1].values[1]).toEqual('id-200');
  344. });
  345.  
  346. it("should page association records into multiple queries correctly", () => {
  347. const iterable = __range__(0, 201, true);
  348. for (let j = 0; j < iterable.length; j++) {
  349. const i = iterable[j];
  350. this.m.categories.push(new Category({id: `id-${i}`}));
  351. }
  352. this.transaction._writeModels([this.m]);
  353.  
  354. const collectionAttributeQueries = this.performed.filter(i => i.query.indexOf('INSERT OR IGNORE INTO `TestModelCategory`') === 0
  355. );
  356.  
  357. expect(collectionAttributeQueries.length).toBe(2);
  358. expect(collectionAttributeQueries[0].values[(200 * 3) - 2]).toEqual('id-199');
  359. expect(collectionAttributeQueries[1].values[1]).toEqual('id-200');
  360. expect(collectionAttributeQueries[1].values[4]).toEqual('id-201');
  361. });
  362. });
  363.  
  364. describe("when the model has joined data attributes", () => {
  365. beforeEach(() => TestModel.configureWithJoinedDataAttribute());
  366.  
  367. it("should not include the value to the joined attribute in the JSON written to the main model table", () => {
  368. this.m = new TestModel({id: 'local-6806434c-b0cd', body: 'hello world'});
  369. this.transaction._writeModels([this.m]);
  370. expect(this.performed[0].values).toEqual(['local-6806434c-b0cd', '{"id":"local-6806434c-b0cd"}']);
  371. });
  372.  
  373. it("should write the value to the joined table if it is defined", () => {
  374. this.m = new TestModel({id: 'local-6806434c-b0cd', body: 'hello world'});
  375. this.transaction._writeModels([this.m]);
  376. expect(this.performed[1].query).toBe('REPLACE INTO `TestModelBody` (`id`, `value`) VALUES (?, ?)');
  377. expect(this.performed[1].values).toEqual([this.m.id, this.m.body]);
  378. });
  379.  
  380. it("should not write the value to the joined table if it undefined", () => {
  381. this.m = new TestModel({id: 'local-6806434c-b0cd'});
  382. this.transaction._writeModels([this.m]);
  383. expect(this.performed.length).toBe(1);
  384. });
  385. });
  386. });
  387. });