Skip to content

Commit

Permalink
feat: full support for Transforms
Browse files Browse the repository at this point in the history
  • Loading branch information
samuelgozi committed Apr 13, 2020
1 parent 4022ef4 commit a32c11c
Show file tree
Hide file tree
Showing 4 changed files with 245 additions and 20 deletions.
59 changes: 53 additions & 6 deletions src/Reference.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Query from './Query.js';
import Document from './Document.js';
import List from './List.js';
import { trimPath, isDocPath, objectToQuery, maskFromObject, encode } from './utils.js';
import { trimPath, isDocPath, objectToQuery, maskFromObject, encode, getKeyPaths } from './utils.js';

export default class Reference {
constructor(path, db) {
Expand Down Expand Up @@ -68,18 +68,60 @@ export default class Reference {
return this.isCollection ? new List(data, this, options) : new Document(data, this.db);
}

/**
* Helper that handles Transforms in objects.
* If the object has a transform then a transaction will be made,
* and a promise for the resulting document will be returned.
* Else, if it doesn't have any Transforms then we return the parsed
* document and let the caller handle the request.
*
* @param {object} obj The object representing the Firebase document.
* @param {boolean} update True if intended to update an existing document.
* @private
*/
handleTransforms(obj, update = false) {
if (typeof obj !== 'object') throw Error(`"${update ? 'update' : 'set'}" received no arguments`);
const transforms = [];
const doc = encode(obj, transforms);

if (transforms.length === 0) return doc;

if (this.isCollection && transforms.length)
throw Error("Transforms can't be used when creating documents with server generated IDs");

const tx = this.db.transaction();
doc.name = this.name;
tx.writes.push(
{
update: doc,
updateMask: update ? { fieldPaths: getKeyPaths(obj) } : undefined,
currentDocument: update ? { exists: true } : undefined
},
{
transform: {
document: this.name,
fieldTransforms: transforms
}
}
);
return tx.commit().then(() => this.get());
}

/**
* Create a new document or overwrites an existing one matching this reference.
* Will throw is the reference points to a collection.
* @returns {Document} The newly created/updated document.
*/
async set(object = {}) {
async set(obj) {
const doc = this.handleTransforms(obj);
if (doc instanceof Promise) return await doc;

return new Document(
await this.db.fetch(this.endpoint, {
// If this is a path to a specific document use
// patch instead, else, create a new document.
method: this.isCollection ? 'POST' : 'PATCH',
body: JSON.stringify(encode(object))
body: JSON.stringify(doc)
}),
this.db
);
Expand All @@ -90,13 +132,18 @@ export default class Reference {
* Will throw is the reference points to a collection.
* @returns {Document} The updated document.
*/
async update(object = {}) {
async update(obj, mustExist = true) {
if (this.isCollection) throw Error("Can't update a collection");

const doc = this.handleTransforms(obj, true);

if (doc instanceof Promise) return await doc;
if (mustExist) doc.currentDocument = { exists: true };

return new Document(
await this.db.fetch(this.endpoint + maskFromObject(object), {
await this.db.fetch(this.endpoint + maskFromObject(obj), {
method: 'PATCH',
body: JSON.stringify(encode(object))
body: JSON.stringify(doc)
}),
this.db
);
Expand Down
22 changes: 22 additions & 0 deletions src/Transform.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,29 @@ const transformsMap = {
removeFromArray: ['removeAllFromArray', Array.isArray]
};

/**
* Represents a value that is the result of an operation
* made by the Firebase server. For example `serverTimestamp`
* cant be known in the client, as it evaluates in the server.
*
* The valid types are:
* - `serverTimestamp`: Is replaces by the server with the time the request was processed.
* - `increment`: The server will increment this field by the given amount.
* - `max`: Sets the field to the maximum of its current value and the given value.
* - `min`: Sets the field to the minimum of its current value and the given value.
* - `appendToArray`: Append the given elements in order if they are not already
* present in the current field value. If the field is not an array, or if the
* field does not yet exist, it is first set to the empty array.
* - `removeFromArray`: Remove all of the given elements from the array in
* the field. If the field is not an array, or if the field does not yet exist,
* it is set to the empty array.
*/
export default class Transform {
/**
* @param {'serverTimestamp'|'increment'|'max'|'min'|'appendToArray'|'removeFromArray'} name The name of the Transform.
* @param {number|any[]} value when applicable, the value will be used.
* for example when using `increment` the value will be the number to increment by.
*/
constructor(name, value) {
if (!(name in transformsMap)) throw Error(`Invalid transform name: "${name}"`);
const [transformName, validator] = transformsMap[name];
Expand Down
13 changes: 5 additions & 8 deletions src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ export function getKeyPaths(object, parentPath) {
for (const key in object) {
const keyPath = parentPath ? `${parentPath}.${key}` : key;

if (object[key] instanceof Transform) continue;

if (typeof object[key] === 'object' && !Array.isArray(object[key])) {
mask = mask.concat(getKeyPaths(object[key], keyPath));
continue;
Expand All @@ -114,9 +116,8 @@ export function getKeyPaths(object, parentPath) {
* @param {Object} object
*/
export function maskFromObject(object = {}) {
return getKeyPaths(object)
.map(p => `updateMask.fieldPaths=${p}`)
.join('&');
const paths = getKeyPaths(object);
return paths.length === 0 ? '' : paths.map(p => `updateMask.fieldPaths=${p}`).join('&');
}

/**
Expand Down Expand Up @@ -230,11 +231,7 @@ export function encodeValue(value, transforms, parentPath) {
export function encode(object, transforms, parentPath) {
const keys = Object.keys(object);

// If the object has no keys, then we don't
// need to add a 'fields' property.
// I'm not sure this matters, if I knew it didn't
// I would remove this if statement.
if (keys.length === 0) return object;
if (keys.length === 0) return {};

const map = { fields: {} };

Expand Down
171 changes: 165 additions & 6 deletions test/Reference.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Reference from '../src/Reference.js';
import List from '../src/List.js';
import Query from '../src/Query.js';
import Database from '../src/index.js';
import Transform from '../src/Transform.js';

const db = new Database({ projectId: 'projectId' });
const rawDoc = JSON.stringify({
Expand Down Expand Up @@ -193,11 +194,15 @@ describe('Get', () => {

describe('Set', () => {
describe('Requests the correct endpoint', () => {
test('Throws when no argument is provided', async () => {
await expect(new Reference('col/doc', db).set()).rejects.toThrow('"set" received no arguments');
});

test('New document(collection endpoint)', async () => {
fetch.resetMocks();
fetch.mockResponse(rawDoc);

await new Reference('col/doc/col', db).set();
await new Reference('col/doc/col', db).set({});
const mockCall = fetch.mock.calls[0];

expect(mockCall[0]).toEqual(`${db.endpoint}/col/doc/col`);
Expand All @@ -208,7 +213,7 @@ describe('Set', () => {
fetch.resetMocks();
fetch.mockResponse(rawDoc);

await new Reference('col/doc', db).set();
await new Reference('col/doc', db).set({});
const mockCall = fetch.mock.calls[0];

expect(mockCall[0]).toEqual(`${db.endpoint}/col/doc`);
Expand Down Expand Up @@ -237,18 +242,96 @@ describe('Set', () => {
expect(body).toEqual('{"fields":{"one":{"stringValue":"one"}}}');
});
});

describe('Transforms', () => {
test('Throws when called on collection with a Transform', async () => {
fetch.resetMocks();
fetch.mockResponses('{}', rawDoc);

const promise = new Reference('col', db).set({
one: 'one',
two: 'two',
tran: new Transform('serverTimestamp')
});

expect(promise).rejects.toThrow("Transforms can't be used when creating documents with server generated IDs");
});

test('Makes the correct requests', async () => {
fetch.resetMocks();
fetch.mockResponses('{}', rawDoc);

await new Reference('col/doc', db).set({
one: 'one',
two: 'two',
tran: new Transform('serverTimestamp')
});

expect(fetch.mock.calls.length).toEqual(2);
expect(fetch.mock.calls[0][0]).toEqual(db.endpoint + ':commit');
});

test('Transaction includes correct body', async () => {
fetch.resetMocks();
fetch.mockResponses('{}', rawDoc);

const ref = new Reference('col/doc', db);

await ref.set({
one: 'one',
two: 'two',
tran: new Transform('serverTimestamp')
});

const given = JSON.parse(fetch.mock.calls[0][1].body);
const expected = {
writes: [
{
update: {
name: ref.name,
fields: {
one: {
stringValue: 'one'
},
two: {
stringValue: 'two'
}
}
}
},
{
transform: {
document: ref.name,
fieldTransforms: [
{
fieldPath: 'tran',
setToServerValue: 'REQUEST_TIME'
}
]
}
}
]
};

expect(given).toEqual(expected);
});
});
});

describe('Update', () => {
test('Throws when the reference points to a collection', () => {
expect(new Reference('/col', db).update()).rejects.toThrow("Can't update a collection");
test('Throws when no argument is provided', async () => {
await expect(new Reference('col/doc', db).update()).rejects.toThrow('"update" received no arguments');
});

test('Throws when the reference points to a collection', async () => {
await expect(new Reference('/col', db).update({})).rejects.toThrow("Can't update a collection");
});

test('Requests the correct endpoint', async () => {
fetch.resetMocks();
fetch.mockResponse(rawDoc);

await new Reference('/col/doc', db).update();
await new Reference('/col/doc', db).update({});

const mockCall = fetch.mock.calls[0];

Expand All @@ -263,7 +346,83 @@ describe('Update', () => {
await new Reference('col/doc', db).update({ one: 'one' });
const body = fetch.mock.calls[0][1].body;

expect(body).toEqual('{"fields":{"one":{"stringValue":"one"}}}');
expect(body).toEqual(
JSON.stringify({
fields: {
one: { stringValue: 'one' }
},
currentDocument: {
exists: true
}
})
);
});

describe('Transforms', () => {
test('Makes the correct requests', async () => {
fetch.resetMocks();
fetch.mockResponses('{}', rawDoc);

await new Reference('col/doc', db).update({
one: 'one',
two: 'two',
tran: new Transform('serverTimestamp')
});

expect(fetch.mock.calls.length).toEqual(2);
expect(fetch.mock.calls[0][0]).toEqual(db.endpoint + ':commit');
});

test('Transaction includes correct body', async () => {
fetch.resetMocks();
fetch.mockResponses('{}', rawDoc);

const ref = new Reference('col/doc', db);

await ref.update({
one: 'one',
two: 'two',
tran: new Transform('serverTimestamp')
});

const given = JSON.parse(fetch.mock.calls[0][1].body);
const expected = {
writes: [
{
update: {
name: ref.name,
fields: {
one: {
stringValue: 'one'
},
two: {
stringValue: 'two'
}
}
},
currentDocument: {
exists: true
},
updateMask: {
fieldPaths: ['one', 'two']
}
},
{
transform: {
document: ref.name,
fieldTransforms: [
{
fieldPath: 'tran',
setToServerValue: 'REQUEST_TIME'
}
]
}
}
]
};

expect(given).toEqual(expected);
});
});
});

Expand Down

0 comments on commit a32c11c

Please sign in to comment.