Skip to content

Commit

Permalink
Rollback Relationships
Browse files Browse the repository at this point in the history
This commit:

1. Allows one to rollback belongsTo and hasMany relationships.
2. Reintroduces dirtyRecordFor*Change hooks on the adapter that allow
   one to customize when a record becomes dirty.
3. Added 'removeDeletedFromRelationshipsPriorToSave' flag to Adapter
   that allows one to opt back into the old deleted record from many
   array behavior (pre emberjs#3539).
4. Adds bin/build.js to build a standalone version of ember-data.

Known issues:

1. Rolling back a hasMany relationship from the parent side of the
   relationship does not work (doing the same from the child side works
   fine). See test that is commented out below as well as the discussion
   at the end of emberjs#2881#issuecomment-204634262

   This was previously emberjs#2881 and is related to emberjs#3698
  • Loading branch information
mmpestorich committed Sep 18, 2020
1 parent 286c25e commit 8e6823f
Show file tree
Hide file tree
Showing 21 changed files with 1,951 additions and 37 deletions.
172 changes: 172 additions & 0 deletions bin/build.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/* eslint-disable */
const chalk = require('chalk');
const concat = require('broccoli-concat');
const interrupt = require('ember-cli/lib/utilities/will-interrupt-process');
const path = require('path');
const globSync = require('glob').sync;
const mergeTrees = require('ember-cli/lib/broccoli/merge-trees');

interrupt.capture(process);

const Builder = require('ember-cli/lib/models/builder');
const BroccoliPlugin = require('broccoli-plugin');
const EmberAddon = require('ember-cli/lib/broccoli/ember-addon');
const Funnel = require('broccoli-funnel');
const Project = require('ember-cli/lib/models/project');

process.env['EMBER_ENV'] = 'development';
process.env['EMBER_CLI_DELAYED_TRANSPILATION'] = true;

class _EmptyNode extends BroccoliPlugin {
constructor(options = {}) {
super([], { annotation: options.annotation });
}

build() {
return Promise.resolve();
}

isEmpty(node) {
return this === node;
}
}

const EmptyNode = new _EmptyNode();
mergeTrees._overrideEmptyTree(EmptyNode);

const cwd = process.cwd();
const pkg = require(path.join(cwd, 'package.json'));
const out = 'ember-data';

const workspaces = {};
if (pkg.workspaces) {
for (const pattern of pkg.workspaces) {
Object.assign(
workspaces,
globSync(path.join(pattern, 'package.json'), { cwd }).reduce((hash, file) => {
const abs = path.join(cwd, file);
const pkg = require(abs);
hash[pkg.name] = { path: path.dirname(abs), pkg };
return hash;
}, {})
);
}
} else {
Object.assign(workspaces, { [pkg.name]: { path: cwd, pkg } });
}

EmberAddon.prototype.toTree = function(additionalTrees) {
//TODO MMP Enable addon templates...

const addonBundles = this.project.addons.reduce((bundles, addon) => {
if (addon.name in workspaces) {
const bundle = Object.create(null);
bundle.name = addon.name;
bundle.root = addon.root;
bundle.addonTree = addon.treeFor('addon');
//bundle.appTree = addon.treeFor('app');
bundles.push(bundle);
}
return bundles;
}, []);

//let appTree = mergeTrees(addonBundles.map(({ appTree }) => appTree));
let appTree = new Funnel('app', { // app
destDir: out,
getDestinationPath(relativePath) {
let prefixes = ['initializers/', 'instance-initializers/'];
for (const prefix of prefixes) {
let index = relativePath.indexOf(prefix);
if (index === 0) return `${prefix}/initializer.js`;
}
return relativePath;
},
});

let addonTree = mergeTrees(addonBundles.map(({ addonTree }) => addonTree), { overwrite: true });
//addonTree = this._compileAddonTree(addonTree, { skipTemplates: false });
addonTree = new Funnel(addonTree, { destDir: 'addon-tree-output' }); // addon-tree-output

this._defaultPackager.name = out; // ensure packager uses addon name
let sources = mergeTrees([addonTree, appTree]); // addon-tree-output + app
let processed = mergeTrees([
this._defaultPackager.applyCustomTransforms(sources),
this._defaultPackager.processJavascript(sources)
], { overwrite: true });
let combined = concat(processed, { // ember-data.[js|map]
inputFiles: ['addon-tree-output/**/*.js', `${out}/**/*.js`],
headerFiles: [],
footerFiles: [],
outputFile: `${out}.js`,
separator: '\n;',
sourceMapConfig: this.options.sourcemaps,
});
return this.addonPostprocessTree('all', combined);
};

const project = Project.closestSync(workspaces[out].path);
const ui = project.ui;
const builder = new Builder({
environment: 'development',
outputPath: '../../dist/',
project,
ui,
});
const addon = project.addons[0].app;

//main
function build(addon, promise) {
ui.writeLine(chalk.green(`Building ${addon.name}`));

let annotation = {
type: 'initial',
reason: 'build',
primaryFile: null,
changedFiles: [],
};

let onInterrupt;

promise = promise
.then(() => {
interrupt.addHandler(onInterrupt);
return builder
.build(null, annotation)
.then(r => {
const time = r.graph.buildState.selfTime;
ui.writeLine(
chalk.green(`Built ${addon.name} successfully in ${time} ms. Stored in "${builder.outputPath}".`)
);
return r;
})
.finally(() => {
ui.stopProgress();
});
})
.then(() => {
interrupt.removeHandler(onInterrupt);
})
.catch(e => {
ui.writeLine(chalk.red(`Failed to build ${addon.name}.`));
ui.writeError(e);
});

onInterrupt = () => Promise.resolve(promise);

return promise;
}
// builder.tree = tree;
// builder.builder = new (require('broccoli').Builder)(tree);
let promise = Promise.resolve();
// for (const key in bundles) {
// noinspection JSUnfilteredForInLoop
// const b = bundles[key];
const p = promise;
promise = promise.then(() => build(addon, p));
// }
promise
.catch(e => ui.writeError(e))
.finally(() => {
builder.cleanup();
process.exit();
});
121 changes: 121 additions & 0 deletions packages/-ember-data/tests/integration/record-array-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,72 @@ module('unit/record-array - RecordArray', function(hooks) {
assert.equal(get(recordArray, 'length'), 0, 'record is removed from the array when it is saved');
});

test('a loaded record is removed from a record array when it is deleted (remove deleted prior to save)', async function(assert) {
assert.expect(5);
this.owner.register(
'adapter:application',
Adapter.extend({
removeDeletedFromRelationshipsPriorToSave: true,
deleteRecord() {
return resolve({ data: null });
},
shouldBackgroundReloadRecord() {
return false;
},
})
);

store.push({
data: [
{
type: 'person',
id: '1',
attributes: {
name: 'Scumbag Dale',
},
},
{
type: 'person',
id: '2',
attributes: {
name: 'Scumbag Katz',
},
},
{
type: 'person',
id: '3',
attributes: {
name: 'Scumbag Bryn',
},
},
{
type: 'tag',
id: '1',
},
],
});

let scumbag = store.peekRecord('person', 1);
let tag = store.peekRecord('tag', 1);

tag.get('people').addObject(scumbag);

assert.equal(get(scumbag, 'tag'), tag, "precond - the scumbag's tag has been set");

let people = tag.get('people');

assert.equal(get(people, 'length'), 1, 'precond - record array has one item');
assert.equal(get(people.objectAt(0), 'name'), 'Scumbag Dale', 'item at index 0 is record with id 1');

await scumbag.deleteRecord();

assert.equal(get(people, 'length'), 0, 'record is removed from the record array');

await scumbag.save();

assert.equal(get(people, 'length'), 0, 'record is still removed from the array when it is saved');
});

test("a loaded record is not removed from a record array when it is deleted even if the belongsTo side isn't defined", async function(assert) {
class Person extends Model {
@attr()
Expand Down Expand Up @@ -382,6 +448,61 @@ module('unit/record-array - RecordArray', function(hooks) {
assert.equal(tool.get('person'), scumbag, 'the tool still belongs to the record');
});

test("a loaded record is not removed from both the record array and from the belongs to, even if the belongsTo side isn't defined (remove deleted prior to save)", async function(assert) {
assert.expect(4);
this.owner.register(
'adapter:application',
Adapter.extend({
removeDeletedFromRelationshipsPriorToSave: true,
deleteRecord() {
return Promise.resolve({ data: null });
},
})
);

store.push({
data: [
{
type: 'person',
id: '1',
attributes: {
name: 'Scumbag Tom',
},
},
{
type: 'tag',
id: '1',
relationships: {
people: {
data: [{ type: 'person', id: '1' }],
},
},
},
{
type: 'tool',
id: '1',
relationships: {
person: {
data: { type: 'person', id: '1' },
},
},
},
],
});

let scumbag = store.peekRecord('person', 1);
let tag = store.peekRecord('tag', 1);
let tool = store.peekRecord('tool', 1);

assert.equal(tag.get('people.length'), 1, 'person is in the record array');
assert.equal(tool.get('person'), scumbag, 'the tool belongs to the person');

scumbag.deleteRecord();

assert.equal(tag.get('people.length'), 0, 'person is not in the record array');
assert.equal(tool.get('person'), null, 'the tool does not belong to the person');
});

// GitHub Issue #168
test('a newly created record is removed from a record array when it is deleted', async function(assert) {
let recordArray = store.peekAll('person');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ class TestRecordData {
isAttrDirty(key: string) {
return false;
}
isRelationshipDirty(key: string) {
return false;
}
removeFromInverseRelationships(isNew: boolean) {}

_initRecordCreateOptions(options) {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1185,6 +1185,52 @@ module('integration/relationship/belongs_to Belongs-To Relationships', function(
assert.equal(book.get('author'), author, 'Book has an author after rollback attributes');
});

test('Rollbacking for a deleted record restores implicit relationship - async (remove deleted prior to save)', function(assert) {
let store = this.owner.lookup('service:store');
let adapter = store.adapterFor('application');
adapter.removeDeletedFromRelationshipsPriorToSave = true;
Book.reopen({
author: DS.belongsTo('author', { async: true }),
});
let book, author;
run(function() {
book = store.push({
data: {
id: '1',
type: 'book',
attributes: {
name: "Stanley's Amazing Adventures",
},
relationships: {
author: {
data: {
id: '2',
type: 'author',
},
},
},
},
});
author = store.push({
data: {
id: '2',
type: 'author',
attributes: {
name: 'Stanley',
},
},
});
});
run(() => {
author.deleteRecord();
author.rollback();
book.get('author').then(fetchedAuthor => {
assert.equal(fetchedAuthor, author, 'Book has an author after rollback');
});
});
adapter.removeDeletedFromRelationshipsPriorToSave = false;
});

testInDebug('Passing a model as type to belongsTo should not work', function(assert) {
assert.expect(1);

Expand Down
Loading

0 comments on commit 8e6823f

Please sign in to comment.