diff --git a/examples/db.js b/examples/db.js new file mode 100644 index 0000000..a03cb03 --- /dev/null +++ b/examples/db.js @@ -0,0 +1,134 @@ +import { append, map, foldl } from 'funcadelic'; +import { NumberType, StringType } from './types'; +import { create as _create } from '../index'; +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'; + +class Person { + firstName = StringType; + lastName = StringType; +} + +class Blog { + title = StringType; + author = belongsTo(Person, "people"); + comments = hasMany(Comment, "comments"); +} + +class Comment { + title = StringType; +} + +function Id(record) { + return ln(StringType, pathOf(record).concat("id"), record); +} + +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 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)); + } + }; +} + +class DB { + people = Table(Person, { + firstName: () => faker.name.firstName(), + lastName: () => faker.name.lastName() + }); + blogs = Table(Blog, { + title: () => faker.random.words(), + 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); +} + +export function ln(Type, path, owner = _create(Type)) { + return link(_create(Type), Type, path, atomOf(owner), typeOf(owner), pathOf(owner)); +} + +// 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/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/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/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/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 new file mode 100644 index 0000000..861b04f --- /dev/null +++ b/tests/db.test.js @@ -0,0 +1,88 @@ +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)); + }); + 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.'); + }); + }); + +}); 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==