From 630d3a29cc8189d4a7690a42c0d79c8646010861 Mon Sep 17 00:00:00 2001 From: Ben Kalman Date: Wed, 10 Aug 2016 13:44:08 -0700 Subject: [PATCH] Port AbsolutePath implementation to JS (#2325) --- js/src/absolute-path-test.js | 100 ++++++++++++++++++++++++++++++++ js/src/absolute-path.js | 107 +++++++++++++++++++++++++++++++++++ js/src/database.js | 4 +- js/src/dataset.js | 6 +- js/src/noms.js | 1 + js/src/path-test.js | 6 +- js/src/path.js | 5 ++ js/src/struct.js | 3 + 8 files changed, 228 insertions(+), 4 deletions(-) create mode 100644 js/src/absolute-path-test.js create mode 100644 js/src/absolute-path.js diff --git a/js/src/absolute-path-test.js b/js/src/absolute-path-test.js new file mode 100644 index 0000000000..bb7a0a53d9 --- /dev/null +++ b/js/src/absolute-path-test.js @@ -0,0 +1,100 @@ +// @flow + +// Copyright 2016 Attic Labs, Inc. All rights reserved. +// Licensed under the Apache License, version 2.0: +// http://www.apache.org/licenses/LICENSE-2.0 + +import {assert} from 'chai'; +import {suite, test} from 'mocha'; +import {equals} from './compare.js'; + +import {invariant, notNull} from './assert.js'; +import AbsolutePath from './absolute-path.js'; +import Commit from './commit.js'; +import {getHash} from './get-hash.js'; +import {stringLength} from './hash.js'; +import List from './list.js'; +import Set from './set.js'; +import {TestDatabase} from './test-util.js'; +import type Value from './value.js'; + +suite('AbsolutePath', () => { + test('to and from string', () => { + const t = (str: string) => { + const p = AbsolutePath.parse(str); + assert.strictEqual(str, p.toString()); + }; + + const h = getHash(42); // arbitrary hash + t(`foo.bar[#${h.toString()}]`); + t(`#${h.toString()}.bar[42]`); + }); + + test('absolute paths', async () => { + const s0 = 'foo', s1 = 'bar'; + const list = new List([s0, s1]); + const emptySet = new Set(); + + let db = new TestDatabase(); + db.writeValue(s0); + db.writeValue(s1); + db.writeValue(list); + db.writeValue(emptySet); + + db = await db.commit('ds', new Commit(list)); + const head = await db.head('ds'); + invariant(head); + + const resolvesTo = async (exp: Value | null, str: string) => { + const p = AbsolutePath.parse(str); + const act = await notNull(p).resolve(db); + if (exp === null) { + assert.strictEqual(null, act); + } else if (act === null) { + assert.isTrue(false, `Failed to resolve ${str}`); + } else { + assert.isTrue(equals(exp, act)); + } + }; + + await resolvesTo(head, 'ds'); + await resolvesTo(emptySet, 'ds.parents'); + await resolvesTo(list, 'ds.value'); + await resolvesTo(s0, 'ds.value[0]'); + await resolvesTo(s1, 'ds.value[1]'); + await resolvesTo(head, '#' + getHash(head).toString()); + await resolvesTo(list, '#' + getHash(list).toString()); + await resolvesTo(s0, `#${getHash(s0).toString()}`); + await resolvesTo(s1, `#${getHash(s1).toString()}`); + await resolvesTo(s0, `#${getHash(list).toString()}[0]`); + await resolvesTo(s1, `#${getHash(list).toString()}[1]`); + + await resolvesTo(null, 'foo'); + await resolvesTo(null, 'foo.parents'); + await resolvesTo(null, 'foo.value'); + await resolvesTo(null, 'foo.value[0]'); + await resolvesTo(null, `#${getHash('baz').toString()}`); + await resolvesTo(null, `#${getHash('baz').toString()}[0]`); + }); + + test('parse errors', () => { + const t = (path: string, exp: string) => { + let act = ''; + try { + AbsolutePath.parse(path); + } catch (e) { + assert.instanceOf(e, SyntaxError); + act = e.message; + } + assert.strictEqual(exp, act); + }; + + t('', 'Empty path'); + t('.foo', 'Invalid dataset name: .foo'); + t('.foo.bar.baz', 'Invalid dataset name: .foo.bar.baz'); + t('#', 'Invalid hash: '); + t('#abc', 'Invalid hash: abc'); + const invalidHash = new Array(stringLength).join('z'); + t(`#${invalidHash}`, `Invalid hash: ${invalidHash}`); + }); +}); diff --git a/js/src/absolute-path.js b/js/src/absolute-path.js new file mode 100644 index 0000000000..b62812aeaa --- /dev/null +++ b/js/src/absolute-path.js @@ -0,0 +1,107 @@ +// @flow + +// Copyright 2016 Attic Labs, Inc. All rights reserved. +// Licensed under the Apache License, version 2.0: +// http://www.apache.org/licenses/LICENSE-2.0 + +import {invariant} from './assert.js'; +import {datasetRe} from './dataset.js'; +import Database from './database.js'; +import Hash, {stringLength} from './hash.js'; +import Path from './path.js'; +import type Value from './value.js'; + +const datasetCapturePrefixRe = new RegExp('^(' + datasetRe.source + ')'); + +/** + * An AbsolutePath is a Path relative to either a dataset head, or a hash. + * + * E.g. in a spec like `http://demo.noms.io::foo.bar` this is the `foo.bar` component, or in + * `http://demo.noms.io::#abcdef.bar` the `#abcdef.bar` component. + */ +export default class AbsolutePath { + /** The dataset ID that `path` is in, or `''` if none. */ + dataset: string; + + /** The hash the that `path` is in, if any. */ + hash: Hash | null; + + /** Path relative to either `dataset` or `hash`. */ + path: Path; + + /** + * Returns `str` parsed as an AbsolutePath. Throws a `SyntaxError` if `str` isn't a valid path. + */ + static parse(str: string): AbsolutePath { + if (str === '') { + throw new SyntaxError('Empty path'); + } + + let dataset = ''; + let hash = null; + let pathStr = ''; + + if (str[0] === '#') { + const tail = str.slice(1); + if (tail.length < stringLength) { + throw new SyntaxError(`Invalid hash: ${tail}`); + } + + const hashStr = tail.slice(0, stringLength); + hash = Hash.parse(hashStr); + if (hash === null) { + throw new SyntaxError(`Invalid hash: ${hashStr}`); + } + + pathStr = tail.slice(stringLength); + } else { + const parts = datasetCapturePrefixRe.exec(str); + if (!parts) { + throw new SyntaxError(`Invalid dataset name: ${str}`); + } + + invariant(parts.length === 2); + dataset = parts[1]; + pathStr = str.slice(parts[0].length); + } + + if (pathStr.length === 0) { + return new AbsolutePath(dataset, hash, new Path()); + } + + const path = Path.parse(pathStr); + return new AbsolutePath(dataset, hash, path); + } + + constructor(dataset: string, hash: Hash | null, path: Path) { + this.dataset = dataset; + this.hash = hash; + this.path = path; + } + + async resolve(db: Database): Promise { + let val = null; + if (this.dataset !== '') { + val = await db.head(this.dataset); + } else if (this.hash !== null) { + val = await db.readValue(this.hash); + } else { + throw new Error('unreachable'); + } + + if (val === undefined) { + val = null; + } + return val === null ? null : this.path.resolve(val); + } + + toString(): string { + if (this.dataset !== '') { + return this.dataset + this.path.toString(); + } + if (this.hash !== null) { + return '#' + this.hash.toString() + this.path.toString(); + } + throw new Error('unreachable'); + } +} diff --git a/js/src/database.js b/js/src/database.js index cc518225ea..2805f66b40 100644 --- a/js/src/database.js +++ b/js/src/database.js @@ -44,10 +44,12 @@ export default class Database { }); } + // TODO: This should return Promise | null>. headRef(datasetID: string): Promise> { return this._datasets.then(datasets => datasets.get(datasetID)); } + // TODO: This should return Promise head(datasetID: string): Promise { return this.headRef(datasetID).then(hr => hr ? this.readValue(hr.targetHash) : null); } @@ -56,7 +58,7 @@ export default class Database { return this._datasets; } - // TODO: This should return Promise + // TODO: This should return Promise async readValue(hash: Hash): Promise { return this._vs.readValue(hash); } diff --git a/js/src/dataset.js b/js/src/dataset.js index a47de77747..725d4252a8 100644 --- a/js/src/dataset.js +++ b/js/src/dataset.js @@ -10,7 +10,11 @@ import type Database from './database.js'; import Ref from './ref.js'; import Set from './set.js'; -const idRe = /^[a-zA-Z0-9\-_/]+$/; +/** Matches any valid dataset name in a string. */ +export const datasetRe = /^[a-zA-Z0-9\-_/]+/; + +/** Matches if an entire string is a valid dataset name. */ +const idRe = new RegExp('^' + datasetRe.source + '$'); export default class Dataset { _database: Database; diff --git a/js/src/noms.js b/js/src/noms.js index 25582a7648..70576a7a91 100644 --- a/js/src/noms.js +++ b/js/src/noms.js @@ -4,6 +4,7 @@ // Licensed under the Apache License, version 2.0: // http://www.apache.org/licenses/LICENSE-2.0 +export {default as AbsolutePath} from './absolute-path.js'; export {AsyncIterator} from './async-iterator.js'; export {default as BuzHash} from './buzhash.js'; export {default as Commit} from './commit.js'; diff --git a/js/src/path-test.js b/js/src/path-test.js index 00b583a938..6b85e7d954 100644 --- a/js/src/path-test.js +++ b/js/src/path-test.js @@ -230,12 +230,14 @@ suite('Path', () => { test('parse errors', () => { const t = (s: string, expectErr: string) => { + let actualErr = ''; try { Path.parse(s); - assert.isOk(false, 'Expected error: ' + expectErr); } catch (e) { - assert.strictEqual(expectErr, e.message); + assert.instanceOf(e, SyntaxError); + actualErr = e.message; } + assert.strictEqual(expectErr, actualErr); }; t('', 'Empty path'); diff --git a/js/src/path.js b/js/src/path.js index 47696812b3..589c39403d 100644 --- a/js/src/path.js +++ b/js/src/path.js @@ -36,10 +36,15 @@ export interface Part { /** * A Path is an address to a Noms value - and unlike hashes (i.e. #abcd...) they can address inlined * values. See https://github.com/attic-labs/noms/blob/master/doc/spelling.md. + * + * E.g. in a spec like `http://demo.noms.io::foo.bar` this is the `.bar` component. */ export default class Path { _parts: Array; + /** + * Returns `str` parsed as Path. Throws a `SyntaxError` if `str` isn't a valid path. + */ static parse(str: string): Path { if (str === '') { throw new SyntaxError('Empty path'); diff --git a/js/src/struct.js b/js/src/struct.js index 6388aac9bd..bb987d8f76 100644 --- a/js/src/struct.js +++ b/js/src/struct.js @@ -18,7 +18,10 @@ import * as Bytes from './bytes.js'; type StructData = {[key: string]: Value}; +/** Matches the first valid field name in a string. */ export const fieldNameComponentRe = /^[a-zA-Z][a-zA-Z0-9_]*/; + +/** Matches if an entire string is a valid field name. */ export const fieldNameRe = new RegExp(fieldNameComponentRe.source + '$'); /**