From 84fd6ecebca442cea7b3b7b48cf116d362632569 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 11 Dec 2018 10:08:30 -0700 Subject: [PATCH 1/2] Add an example of a foreign key relationship. This adds a `DB` example that contains a number of tables each of which have rows that link to each other. This uses the ability to link anywhere in the store to create a factory method that creates entities elswhere in the store. In this example, the `Blog` record has a reference to a `Person` which is the author. The factory for author attribute of a blog, creates that person in the database, and then returns that person. --- examples/db.js | 142 ++++++++++++++++++++++++++++++++++++++++++++++ examples/types.js | 28 +++++++++ index.js | 2 +- package.json | 5 +- src/meta.js | 6 +- tests/db.test.js | 62 ++++++++++++++++++++ yarn.lock | 7 ++- 7 files changed, 248 insertions(+), 4 deletions(-) create mode 100644 examples/db.js create mode 100644 examples/types.js create mode 100644 tests/db.test.js diff --git a/examples/db.js b/examples/db.js new file mode 100644 index 0000000..811493b --- /dev/null +++ b/examples/db.js @@ -0,0 +1,142 @@ +import { append, map, foldl } from 'funcadelic'; +import { NumberType, StringType } from './types'; +import { create as _create } from '../index'; +import { view, At } from '../src/lens'; +import { link, valueOf, pathOf, atomOf, typeOf, metaOf, ownerOf } from '../src/meta'; + +import faker from 'faker'; + +class Person { + firstName = StringType; + lastName = StringType; +} + +class Blog { + title = StringType; + author = belongsTo(Person, "people"); +} + +class Comment {} + +function Table(Type, factory = {}) { + + return class Table { + static Type = Type; + static name = `Table<${Type.name}>`; + + nextId = NumberType; + + get length() { return Object.keys(this.records).length; } + + get latest() { return this.records[this.latestId]; } + + get latestId() { return this.nextId.state - 1; } + + get records() { + return map((value, key) => ln(Type, pathOf(this).concat(["records", key]), this), this.state.records); + } + + get state() { + return valueOf(this) || { nextId: 0, records: {}}; + } + + create(overrides = {}) { + let id = this.nextId.state; + let record = createAt(this.nextId.increment(), Type, ["records", id], { id }); + + let created = foldl((record, { key, value: attr }) => { + if (record[key]) { + let attrFn = typeof attr === 'function' ? attr : () => attr; + + // create a local link of the DB that returns itself. to pass into + // the factory function. + let db = link(_create(DB), DB, pathOf(this).slice(0, -1), atomOf(record)); + + let result = attrFn(overrides[key], db); + + let next = metaOf(result) ? link(record, Type, pathOf(record), atomOf(result)) : record; + + return next[key].set(result); + } else { + return record; + } + }, record, append(factory, overrides)); + + let owner = ownerOf(this); + return link(this, typeOf(this), pathOf(this), atomOf(created), owner.Type, owner.path); + } + }; +} + + + +class DB { + people = Table(Person, { + firstName: () => faker.name.firstName(), + lastName: () => faker.name.lastName() + }); + blogs = Table(Blog, { + title: () => faker.random.words(), + author: (attrs, db) => { + return db.people.create(attrs) + .people.latest; + } + }); + comments = Table(Comment); +} + +function createAt(parent, Type, path, value) { + return link(_create(Type), Type, pathOf(parent).concat(path), atomOf(parent)).set(value); +} + +function ln(Type, path, owner) { + return link(_create(Type), Type, path, atomOf(owner), typeOf(owner), pathOf(owner)); +} + +import Relationship from '../src/relationship'; + +function linkTo(Type, path) { + return new Relationship(resolve); + + function resolve(origin, originType, originPath /*, relationshipName */) { + + return { + Type, + path: path.reduce((path, element) => { + if (element === '..') { + return path.slice(0, -1); + } else if (element === '.') { + return path; + } else { + return path.concat(element); + } + }, originPath) + }; + } +} + +function belongsTo(T, tableName) { + return new Relationship(resolve); + + function BelongsTo(originType, originPath, foreignKey) { + return class BelongsTo extends T { + set(value) { + let origin = ln(originType, originPath, value); + let id = valueOf(value).id; + return origin.set(append(valueOf(origin), { + [foreignKey]: id + })); + } + }; + } + + function resolve(origin, originType, originPath, relationshipName) { + let foreignKey = `${relationshipName}Id`; + let id = view(At(foreignKey), valueOf(origin)); + let Type = BelongsTo(originType, originPath, foreignKey); + let { resolve } = linkTo(Type, ["..", "..", "..", tableName, "records", id]); + return resolve(origin, originType, originPath, relationshipName); + } +} + +export default _create(DB, {}); diff --git a/examples/types.js b/examples/types.js new file mode 100644 index 0000000..6c47237 --- /dev/null +++ b/examples/types.js @@ -0,0 +1,28 @@ +import { valueOf } from '../index'; + +export class NumberType { + + get state() { + return valueOf(this) || 0; + } + + initialize(value) { + if (value == null) { + return 0; + } else if (isNaN(value)) { + return this; + } else { + return Number(value); + } + } + + increment() { + return this.state + 1; + } +} + +export class StringType { + get state() { + return valueOf(this) || ''; + } +} diff --git a/index.js b/index.js index a291c01..060c353 100644 --- a/index.js +++ b/index.js @@ -2,4 +2,4 @@ import create from './src/create'; import Identity from './src/identity'; export { create, Identity }; -export { valueOf, metaOf, atomOf } from './src/meta'; +export { valueOf, metaOf, atomOf, pathOf } from './src/meta'; diff --git a/package.json b/package.json index 547bc17..6dbb74a 100644 --- a/package.json +++ b/package.json @@ -26,14 +26,17 @@ }, "devDependencies": { "@babel/core": "7.1.6", + "@babel/plugin-proposal-class-properties": "^7.1.0", + "@babel/polyfill": "^7.0.0", "@babel/preset-env": "^7.0.0", "@babel/register": "^7.0.0", - "@babel/polyfill": "^7.0.0", "babel-eslint": "^10.0.1", "coveralls": "3.0.2", "eslint": "^5.7.0", "eslint-plugin-prefer-let": "^1.0.1", "expect": "^23.4.0", + "faker": "^4.1.0", + "invariant": "^2.2.4", "mocha": "^5.2.0", "nyc": "13.1.0", "rollup": "^0.67.4", diff --git a/src/meta.js b/src/meta.js index e09758e..312db53 100644 --- a/src/meta.js +++ b/src/meta.js @@ -68,7 +68,11 @@ class Location { export const AtomOf = type(class AtomOf { atomOf(object) { - return this(object).atomOf(object); + if (object != null) { + return this(object).atomOf(object); + } else { + return undefined; + } } }); diff --git a/tests/db.test.js b/tests/db.test.js new file mode 100644 index 0000000..1159d83 --- /dev/null +++ b/tests/db.test.js @@ -0,0 +1,62 @@ +import expect from 'expect'; + +import db from '../examples/db'; +import { valueOf } from '../index'; + +describe('a referential DB', ()=> { + it('starts out empty', ()=> { + expect(db.people.length).toEqual(0); + expect(db.blogs.length).toEqual(0); + expect(db.comments.length).toEqual(0); + }); + + describe('creating a person with static attributes', ()=> { + let next; + let person; + beforeEach(()=> { + next = db.people.create({ + firstName: 'Bob', + lastName: 'Dobalina' + }); + person = next.people.latest; + }); + it('contains the newly created person', ()=> { + expect(next.people.length).toEqual(1); + expect(person).toBeDefined(); + expect(person.firstName.state).toEqual('Bob'); + expect(person.lastName.state).toEqual('Dobalina'); + }); + }); + + describe('creating a person with higher order attributes', ()=> { + let next; + let person; + beforeEach(()=> { + next = db.people.create(); + person = next.people.latest; + }); + + it('creates them with generated attributes', ()=> { + expect(person.firstName.state).not.toBe(''); + expect(person.lastName.state).not.toBe(''); + }); + }); + + describe('creating a blog post with related author', ()=> { + let next; + let blog; + + beforeEach(()=> { + next = db.blogs.create(); + blog = next.blogs.latest; + }); + it('has a related author', ()=> { + expect(blog.author).toBeDefined(); + }); + it('is the same as a person created in the people table', ()=> { + expect(next.people.latest).toBeDefined(); + expect(valueOf(next.people.latest)).toBe(valueOf(next.blogs.latest.author)); + }); + }); + +}); diff --git a/yarn.lock b/yarn.lock index f05914f..24408c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1503,6 +1503,11 @@ extsprintf@^1.2.0: resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f" integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8= +faker@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/faker/-/faker-4.1.0.tgz#1e45bbbecc6774b3c195fad2835109c6d748cc3f" + integrity sha1-HkW7vsxndLPBlfrSg1EJxtdIzD8= + fast-deep-equal@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz#c053477817c86b51daa853c81e059b733d023614" @@ -1889,7 +1894,7 @@ inquirer@^6.1.0: strip-ansi "^4.0.0" through "^2.3.6" -invariant@2.2.4, invariant@^2.2.2: +invariant@2.2.4, invariant@^2.2.2, invariant@^2.2.4: version "2.2.4" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== From 156b7d6199b621e0642eb041ae431a3b21be2a13 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 13 Dec 2018 14:23:05 -0700 Subject: [PATCH 2/2] Support factories for entity relationships. It isn't just enough to be able to link to entitities from across a store. You also need to be able to create them in concert with each other. This presents a unique difficulty in microstates where every transition is chained off of an "owner" object. How then, do you operate on objets that might not share a common ancestor? The answer is to introduce a new primitive: the transaction. This allows you to pass as many microstates into a transaction as you want, and with each operation in the transaction, the atom is kept in sync so that each microstate in a transaction is talking about the same universe. So, for example, as part of the blog creation process, we want to first create a person and then a assign that person to the blog's s author, we need to call _create_ on the people table, and then _set_ the relationship on the person. Each transaction has a subject and any number of members, that will be kept in sync for the operation wich is implemented using the monadic `flatMap` operator. E.g. ```js import Txn from 'transaction'; let txn = Txn(subject, other, yetAnother) .flatMap(([subject, other, yetAnother]) => subject.transition(other, yetAnother)) ``` this will take the `atom` from `subject` and use it as the basis for _all_ participants in the transaction. In this case `other`, and `yetAnother`. So that when we flatMap, all of them are operating against the same data. > Note: the argument to the flatMap function is the transaction itself, but it destructures to its members. Also, every participant in the transaction is scoped to itself as owner, so that subject.transition() will return subject. While rock-solid, this makes for an awkward API, so we'll still need to find out how to do the interior design to make it more pleasant. Still, I think that can come later. - [ ] use transactions in more places, specifically in a transition. - [ ] currently transaction is eager, but could be made lazy. --- examples/db.js | 152 ++++++++++++++++------------------ examples/db/belongs-to.js | 36 ++++++++ examples/db/has-many.js | 46 ++++++++++ examples/db/link-to.js | 24 ++++++ src/cached-property.js | 9 +- src/transaction.js | 43 ++++++++++ tests/cached-property.test.js | 33 ++++++++ tests/db.test.js | 26 ++++++ 8 files changed, 286 insertions(+), 83 deletions(-) create mode 100644 examples/db/belongs-to.js create mode 100644 examples/db/has-many.js create mode 100644 examples/db/link-to.js create mode 100644 src/transaction.js create mode 100644 tests/cached-property.test.js diff --git a/examples/db.js b/examples/db.js index 811493b..a03cb03 100644 --- a/examples/db.js +++ b/examples/db.js @@ -1,8 +1,10 @@ import { append, map, foldl } from 'funcadelic'; import { NumberType, StringType } from './types'; import { create as _create } from '../index'; -import { view, At } from '../src/lens'; import { link, valueOf, pathOf, atomOf, typeOf, metaOf, ownerOf } from '../src/meta'; +import Txn from '../src/transaction'; +import belongsTo from './db/belongs-to'; +import hasMany from './db/has-many'; import faker from 'faker'; @@ -14,9 +16,16 @@ class Person { class Blog { title = StringType; author = belongsTo(Person, "people"); + comments = hasMany(Comment, "comments"); } -class Comment {} +class Comment { + title = StringType; +} + +function Id(record) { + return ln(StringType, pathOf(record).concat("id"), record); +} function Table(Type, factory = {}) { @@ -33,7 +42,7 @@ function Table(Type, factory = {}) { get latestId() { return this.nextId.state - 1; } get records() { - return map((value, key) => ln(Type, pathOf(this).concat(["records", key]), this), this.state.records); + return map((value, key) => ln(Type, pathOf(this).concat(["records", key]), this), this.state.records || {}); } get state() { @@ -42,34 +51,51 @@ function Table(Type, factory = {}) { create(overrides = {}) { let id = this.nextId.state; - let record = createAt(this.nextId.increment(), Type, ["records", id], { id }); - - let created = foldl((record, { key, value: attr }) => { - if (record[key]) { - let attrFn = typeof attr === 'function' ? attr : () => attr; - - // create a local link of the DB that returns itself. to pass into - // the factory function. - let db = link(_create(DB), DB, pathOf(this).slice(0, -1), atomOf(record)); - - let result = attrFn(overrides[key], db); - - let next = metaOf(result) ? link(record, Type, pathOf(record), atomOf(result)) : record; - - return next[key].set(result); - } else { - return record; - } + let path = pathOf(this).concat(["records", id]); + let record = Txn(this.nextId.increment(), ln(Type, path)) + .flatMap(([, record]) => Txn(Id(record).set(id))); + + return foldl((txn, { key, value: attr }) => { + return txn + .flatMap(txn => { + let [ record ] = txn; + if (record[key]) { + return txn + .flatMap(([ record ]) => Txn(record[key], ln(DB, pathOf(this).slice(0, -1)))) + .flatMap(([ relationship, db ]) => { + + function attrFn() { + if (relationship.isBelongsTo || relationship.isHasMany) { + let build = factory[key]; + if (build) { + let empty = relationship.isHasMany ? [] : {}; + return build(relationship, db, overrides[key] || empty); + } else { + return null; + } + } else { + return typeof attr === 'function' ? attr(id) : attr; + } + } + + let result = attrFn(); + + if (metaOf(result)) { + return Txn(result, record); + } else { + return Txn(relationship.set(result), record); + } + }) + .flatMap(([, record]) => Txn(record)); + } else { + return txn; + } + }); }, record, append(factory, overrides)); - - let owner = ownerOf(this); - return link(this, typeOf(this), pathOf(this), atomOf(created), owner.Type, owner.path); } }; } - - class DB { people = Table(Person, { firstName: () => faker.name.firstName(), @@ -77,66 +103,32 @@ class DB { }); blogs = Table(Blog, { title: () => faker.random.words(), - author: (attrs, db) => { - return db.people.create(attrs) - .people.latest; - } + author: (author, db, attrs = {}) => Txn(db.people) + .flatMap(([ people ]) => Txn(people.create(attrs).latest, author)) + .flatMap(([ person, author ]) => Txn(author.set(person))), + comments: (comments, db, list = []) => list.reduce((txn, attrs) => { + return txn + .flatMap(([ db ]) => Txn(db.comments.create(attrs), comments)) + .flatMap(([ db, comments ]) => Txn(comments.push(db.comments.latest), db)) + .flatMap(([, db ]) => Txn(db)); + }, Txn(db)) }); - comments = Table(Comment); -} -function createAt(parent, Type, path, value) { - return link(_create(Type), Type, pathOf(parent).concat(path), atomOf(parent)).set(value); + comments = Table(Comment); } -function ln(Type, path, owner) { +export function ln(Type, path, owner = _create(Type)) { return link(_create(Type), Type, path, atomOf(owner), typeOf(owner), pathOf(owner)); } -import Relationship from '../src/relationship'; - -function linkTo(Type, path) { - return new Relationship(resolve); - - function resolve(origin, originType, originPath /*, relationshipName */) { - - return { - Type, - path: path.reduce((path, element) => { - if (element === '..') { - return path.slice(0, -1); - } else if (element === '.') { - return path; - } else { - return path.concat(element); - } - }, originPath) - }; - } -} - -function belongsTo(T, tableName) { - return new Relationship(resolve); - - function BelongsTo(originType, originPath, foreignKey) { - return class BelongsTo extends T { - set(value) { - let origin = ln(originType, originPath, value); - let id = valueOf(value).id; - return origin.set(append(valueOf(origin), { - [foreignKey]: id - })); - } - }; - } - - function resolve(origin, originType, originPath, relationshipName) { - let foreignKey = `${relationshipName}Id`; - let id = view(At(foreignKey), valueOf(origin)); - let Type = BelongsTo(originType, originPath, foreignKey); - let { resolve } = linkTo(Type, ["..", "..", "..", tableName, "records", id]); - return resolve(origin, originType, originPath, relationshipName); - } -} +// function DB(tables) { +// return class DB { +// constructor() { +// Object.keys(tables).forEach(key => { +// this[key] = tables[key] +// }); +// } +// } +// } export default _create(DB, {}); diff --git a/examples/db/belongs-to.js b/examples/db/belongs-to.js new file mode 100644 index 0000000..f5cf63f --- /dev/null +++ b/examples/db/belongs-to.js @@ -0,0 +1,36 @@ +import { StringType } from '../types'; +import Relationship from '../../src/relationship'; +import { view, At } from '../../src/lens'; +import { valueOf } from '../../src/meta'; +import linkTo from './link-to'; +import { ln } from '../db'; + +export default function belongsTo(T, tableName) { + return new Relationship(resolve); + + function BelongsTo(originType, originPath, foreignKey) { + + return class BelongsTo extends T { + static name = `BelongsTo<${T.name}>`; + + get isBelongsTo() { return true; } + + set(record) { + let path = originPath.concat(foreignKey); + return ln(StringType, path, this).set(idOf(record)); + } + }; + } + + function resolve(origin, originType, originPath, relationshipName) { + let foreignKey = `${relationshipName}Id`; + let id = view(At(foreignKey), valueOf(origin)); + let Type = BelongsTo(originType, originPath, foreignKey); + let { resolve } = linkTo(Type, ["..", "..", "..", tableName, "records", id]); + return resolve(origin, originType, originPath, relationshipName); + } +} + +export function idOf(record) { + return view(At("id"), valueOf(record)); +} diff --git a/examples/db/has-many.js b/examples/db/has-many.js new file mode 100644 index 0000000..0eb6063 --- /dev/null +++ b/examples/db/has-many.js @@ -0,0 +1,46 @@ +import Relationship from '../../src/relationship'; +import linkTo, { expandPath } from './link-to'; +import { ln } from '../db'; +import { atomOf, pathOf, valueOf } from '../../src/meta'; +import { idOf } from './belongs-to'; + +export default function hasMany(T, tableName) { + return new Relationship(resolve); + + function resolve(origin, originType, originPath, relationshipName) { + let dbpath = expandPath(["..", "..", ".."], originPath); + let Type = HasMany(T, dbpath, tableName); + let { resolve } = linkTo(Type, [relationshipName]); + return resolve(origin, originType, originPath, relationshipName); + } +} + +export function HasMany(Type, dbpath, tableName) { + return class HasMany { + static name = `HasMany<${Type.name}>`; + + get isHasMany() { return true; } + + get length() { + return (valueOf(this) || []).length; + } + + push(record) { + let value = valueOf(this) || []; + return this.set(value.concat(idOf(record))); + } + + *[Symbol.iterator]() { + let ids = valueOf(this) || []; + for (let id of ids) { + let path = dbpath.concat([tableName, "records", id]); + yield ln(Type, path, this); + } + } + }; +} + +export function collect(db, tableName) { + let Type = HasMany(db[tableName].constructor.Type, pathOf(db), tableName); + return ln(Type, atomOf(db)); +} diff --git a/examples/db/link-to.js b/examples/db/link-to.js new file mode 100644 index 0000000..519e6a6 --- /dev/null +++ b/examples/db/link-to.js @@ -0,0 +1,24 @@ +import Relationship from '../../src/relationship'; + +export default function linkTo(Type, path) { + return new Relationship(resolve); + + function resolve(origin, originType, originPath /*, relationshipName */) { + + let target = expandPath(path, originPath); + + return { Type, path: target }; + } +} + +export function expandPath(path, context) { + return path.reduce((path, element) => { + if (element === '..') { + return path.slice(0, -1); + } else if (element === '.') { + return path; + } else { + return path.concat(element); + } + }, context); +} diff --git a/src/cached-property.js b/src/cached-property.js index 711c32a..945ba23 100644 --- a/src/cached-property.js +++ b/src/cached-property.js @@ -1,13 +1,16 @@ +import { stable } from 'funcadelic'; + export default function CachedProperty(key, reify) { + + let get = stable(object => reify(object)); + let enumerable = true; let configurable = true; return { enumerable, configurable, get() { - let value = reify(this); - Object.defineProperty(this, key, { enumerable, value }); - return value; + return get(this); } }; } diff --git a/src/transaction.js b/src/transaction.js new file mode 100644 index 0000000..604e487 --- /dev/null +++ b/src/transaction.js @@ -0,0 +1,43 @@ +import { view, set } from './lens'; +import { Meta, atomOf } from './meta'; + +export default function transaction(...args) { + return new Transaction(...args); +} + +/** + * This strict monadic API is awkward to work with, but it does guarante that + * everything will be respected. + */ + +class Transaction { + + constructor(subject, ...members) { + this.atom = atomOf(subject); + this.subject = prune(subject); + this.members = members.map(member => set(Meta.atom, this.atom, prune(member))); + return set(Meta.atom, this.atom, this); + } + + flatMap(fn) { + let result = fn(this); + if (result instanceof Transaction) { + return result; + } else { + throw new Error('in Transaction#flatMap(fn), `fn` should return a Transaction, but returned a ' + result); + } + } + + log(...msgs) { + console.log(...msgs, JSON.stringify(atomOf(this), null, 2)); + return this; + } + + *[Symbol.iterator]() { + yield* [this.subject].concat(this.members); + } +} + +function prune(object) { + return set(Meta.owner, view(Meta.location, object), object); +} diff --git a/tests/cached-property.test.js b/tests/cached-property.test.js new file mode 100644 index 0000000..da1325f --- /dev/null +++ b/tests/cached-property.test.js @@ -0,0 +1,33 @@ +import expect from 'expect'; + +import { set, At } from '../src/lens'; + +import CachedProperty from '../src/cached-property'; + +describe('cached properties', ()=> { + let object; + + beforeEach(()=> { + object = Object.defineProperty({}, 'cached', CachedProperty('cached', () => ({}))); + }); + + it('returns the same object upon multiple invocations', ()=> { + expect(object.cached).toBeDefined(); + expect(object.cached).toBe(object.cached); + }); + + describe('deriving a new object from old one', ()=> { + let derived; + let cached; + beforeEach(()=> { + derived = set(At("other"), "thing", object); + cached = object.cached; + }); + it('recomputes the cached property', ()=> { + expect(derived.cached).toBeDefined(); + expect(derived.cached).not.toBe(cached); + expect(derived.cached).toBe(derived.cached); + }); + }); + +}); diff --git a/tests/db.test.js b/tests/db.test.js index 1159d83..861b04f 100644 --- a/tests/db.test.js +++ b/tests/db.test.js @@ -57,6 +57,32 @@ describe('a referential DB', ()=> { expect(next.people.latest).toBeDefined(); expect(valueOf(next.people.latest)).toBe(valueOf(next.blogs.latest.author)); }); + it('has an empty list of comments', ()=> { + let [ ...comments ] = blog.comments; + expect(comments).toEqual([]); + }); + }); + + describe('creating a blog with associated comments.', ()=> { + let next; + + beforeEach(()=> { + next = db.blogs.create({ + comments: [{ title: 'This is a good post.'}, { title: 'This is a bad post.' }] + }); + }); + + it('creates the comments in the db', ()=> { + expect(next.comments.length).toEqual(2); + }); + it('references the comments from the blog post', ()=> { + expect(next.blogs.latest.comments.length).toEqual(2); + }); + it('creats them successfully', ()=> { + let [ first, second ] = next.blogs.latest.comments; + expect(first.title.state).toEqual('This is a good post.'); + expect(second.title.state).toEqual('This is a bad post.'); + }); }); });