diff --git a/bin/firebase-bolt b/bin/firebase-bolt
index bc4a856..de4ffab 100755
--- a/bin/firebase-bolt
+++ b/bin/firebase-bolt
@@ -26,13 +26,16 @@ var pkg = require('../package.json');
var VERSION_STRING = "Firebase Bolt v" + pkg.version;
+var commandName = 'bolt';
+
var opts = {
boolean: ['version', 'help'],
string: ['output'],
alias: {
- 'v': 'version',
+ 'f': 'functions',
'h': 'help',
'o': 'output',
+ 'v': 'version',
},
unknown: function(flag) {
if (flag[0] == '-') {
@@ -42,6 +45,33 @@ var opts = {
}
};
+var commands = {
+ migrate: function(args) {
+ if (args._.length != 2) {
+ log("Missing JSON file name to migrate to bolt file.");
+ process.exit(1);
+ }
+ var functionsFileName = getInputFileName(args.functions, 'bolt');
+ var functions;
+ if (functionsFileName !== undefined) {
+ readInputFile(functionsFileName, 'Bolt', function(boltText) {
+ try {
+ var symbols = bolt.parse(boltText);
+ functions = symbols.functions;
+ } catch (e) {
+ log(e.message, e.line, e.column);
+ process.exit(1);
+ }
+ });
+ }
+ var inputFileName = getInputFileName(args._[1], 'json');
+ var outputFileName = getOutputFileName(args.output, inputFileName, 'bolt');
+ readInputFile(inputFileName, 'JSON', function(jsonData) {
+ writeOutputFile(outputFileName, bolt.decodeRules(jsonData, functions));
+ });
+ }
+};
+
function main() {
var args = parseArgs(process.argv.slice(2), opts);
@@ -60,73 +90,105 @@ function main() {
usage(0);
}
+ // command file
+ if (args._.length >= 1 && commands[args._[0]] !== undefined) {
+ commandName += '-' + args._[0];
+ commands[args._[0]](args);
+ return;
+ }
+
if (args._.length > 1) {
log("Can only compile a single file.");
usage(1);
}
- // Read Bolt file from stdin
- if (args._.length === 0) {
- if (process.stdin.isTTY) {
- log("Type in a Bolt file terminated by CTRL-D.");
- }
- readFile(process.stdin, function(data) {
- if (args.output !== undefined) {
- writeTranslation(util.ensureExtension(args.output, 'json'), data);
- } else {
- console.log(translateRules(data));
- }
- });
- return;
+ var inputFileName = getInputFileName(args._[0], 'bolt');
+ var outputFileName = getOutputFileName(args.output, inputFileName, 'json');
+ readInputFile(inputFileName, 'Bolt', function(boltData) {
+ writeOutputFile(outputFileName, translateRules(boltData));
+ });
+}
+
+function getInputFileName(name, extension) {
+ // Read file from stdin
+ if (name === undefined) {
+ return undefined;
}
+ return util.ensureExtension(name, extension);
+}
- // Read Bolt file and write json file.
- var inFile = util.ensureExtension(args._[0], bolt.FILE_EXTENSION);
- var outFile;
- if (args.output) {
- outFile = util.ensureExtension(args.output, 'json');
+function getOutputFileName(name, inputFileName, extension) {
+ if (name === undefined) {
+ if (inputFileName === undefined) {
+ return undefined;
+ }
+ name = util.replaceExtension(inputFileName, extension);
} else {
- outFile = util.replaceExtension(inFile, 'json');
+ name = util.ensureExtension(name, extension);
}
- if (inFile === outFile) {
- log("Cannot overwrite input file: " + inFile);
- log("(Did you mean '" + util.replaceExtension(inFile, 'bolt') + "'?)");
+ if (name === inputFileName) {
+ log("Cannot overwrite input file: " + inputFileName);
+ log("(Did you mean '" +
+ util.replaceExtension(name, extension == 'json' ? 'bolt' : 'json') +
+ "'?)");
process.exit(1);
}
- fs.readFile(inFile, 'utf8', function(err, data) {
+ return name;
+}
+
+function readInputFile(name, kind, callback) {
+ if (name === undefined) {
+ if (process.stdin.isTTY) {
+ log("Type in a " + kind + " file terminated by CTRL-D.");
+ }
+ readStream(process.stdin, callback);
+ return;
+ }
+
+ fs.readFile(name, 'utf8', function(err, data) {
if (err) {
- log("Could not read file: " + inFile);
+ log("Could not read file: " + name);
process.exit(1);
}
- writeTranslation(outFile, data);
+ callback(data);
});
}
-function writeTranslation(outFile, data) {
- log("Generating " + outFile + "...");
- fs.writeFile(outFile, translateRules(data) + '\n', 'utf8', function(err2) {
- if (err2) {
- log("Could not write file: " + outFile);
- process.exit(1);
- }
- });
+function writeOutputFile(name, data) {
+ if (name === undefined) {
+ console.log(data + '\n');
+ } else {
+ log("Generating " + name + "...");
+ fs.writeFile(name, data + '\n', 'utf8', function(err) {
+ if (err) {
+ log("Could not write file: " + outputFile);
+ process.exit(1);
+ }
+ });
+ }
}
function usage(code) {
var cmdName = process.argv[1].split('/').slice(-1);
- console.error("Translate Firebase Bolt file into JSON rules format.\n");
+ console.error("Translate Firebase Bolt file into JSON rules format");
+ console.error(" (or use migrate sub-command to generate a Bolt file equivalent\n" +
+ " of a Firebase JSON rule set).\n");
- console.error(" Usage: " + cmdName + " [options] [file]\n");
+ console.error(" Usage: " + cmdName + " [options] [file[.bolt]]");
+ console.error(" " + cmdName + " [options] migrate [file[.json]]\n");
- console.error(" Examples: " + cmdName + " myapp.bolt --output rules.json");
- console.error(" " + cmdName + " < myapp.bolt > rules.json");
- console.error(" " + cmdName + " myapp");
- console.error(" (myapp.bolt => myapp.json)\n");
+ console.error(" Examples: " + cmdName + " rules.bolt --output rules.json");
+ console.error(" " + cmdName + " < rules.bolt > rules.json");
+ console.error(" " + cmdName + " rules");
+ console.error(" (rules.bolt => rules.json)");
+ console.error(" " + cmdName + " migrate [--functions file] rules.json");
+ console.error(" (rules.json => rules.bolt)\n");
console.error(" Options:\n");
console.error(util.formatColumns(4, [
+ ["-f --functions file", "Include file of top level Bolt functions (for migrate)."],
["-h --help", "Display this helpful message."],
- ["-o --output file", "Output to file.json."],
+ ["-o --output file", "Output to file."],
["-v --version", "Display Firebase Bolt version."],
[]
]).join('\n'));
@@ -136,7 +198,7 @@ function usage(code) {
main();
-function readFile(f, callback) {
+function readStream(f, callback) {
var input = "";
f.setEncoding('utf8');
@@ -172,7 +234,7 @@ function translateRules(input) {
}
function log(message, line, column) {
- var parts = ['bolt'];
+ var parts = [commandName];
if (line) {
util.extendArray(parts, [line, column]);
}
diff --git a/gulpfile.js b/gulpfile.js
index 8d5213f..e26f8aa 100644
--- a/gulpfile.js
+++ b/gulpfile.js
@@ -36,8 +36,16 @@ var TS_SOURCES = ['src/*.ts',
'src/test/*.ts'];
// Subset of tests required for 'gulp test'.
-var TEST_FILES = ['lib/test/generator-test.js', 'lib/test/parser-test.js',
- 'lib/test/ast-test.js', 'lib/test/util-test.js', 'lib/test/cli-test.js'];
+var TEST_FILES = [
+ 'lib/test/ast-test.js',
+ 'lib/test/cli-test.js',
+ 'lib/test/decoder-test.js',
+ 'lib/test/generator-test.js',
+ 'lib/test/matcher-test.js',
+ 'lib/test/parser-test.js',
+ 'lib/test/permutation-test.js',
+ 'lib/test/util-test.js'
+];
// Ignore ts-compile errors while watching (but not in normal builds).
var watching = false;
diff --git a/package.json b/package.json
index b5cce06..2247843 100644
--- a/package.json
+++ b/package.json
@@ -70,6 +70,7 @@
"minimist": "^1.2.0",
"mocha": "^2.2.5",
"node-uuid": "^1.4.3",
- "promise": "^7.0.4"
+ "promise": "^7.0.4",
+ "strip-json-comments": "^2.0.0"
}
}
diff --git a/samples/chat.json b/samples/chat.json
index 3832561..2492d17 100644
--- a/samples/chat.json
+++ b/samples/chat.json
@@ -7,7 +7,7 @@
".validate": "newData.val().length > 0 && newData.val().length <= 32"
},
"creator": {
- ".validate": "newData.isString() && (auth != null && newData.val() == auth.uid)"
+ ".validate": "newData.isString() && auth != null && newData.val() == auth.uid"
},
"members": {
"$key2": {
@@ -21,7 +21,7 @@
"$other": {
".validate": "false"
},
- ".write": "data.val() == null && (auth != null && $key2 == auth.uid)"
+ ".write": "data.val() == null && auth != null && $key2 == auth.uid"
}
},
"$other": {
@@ -37,7 +37,7 @@
"$postid": {
".validate": "newData.hasChildren(['from', 'message', 'created'])",
"from": {
- ".validate": "newData.isString() && (auth != null && newData.val() == auth.uid)"
+ ".validate": "newData.isString() && auth != null && newData.val() == auth.uid"
},
"message": {
".validate": "newData.isString() && newData.val().length > 0 && newData.val().length <= 140"
@@ -45,7 +45,7 @@
"$other": {
".validate": "false"
},
- ".write": "data.val() == null && (root.child('rooms').child($roomid).child('members').child(auth.uid).val() != null && !(root.child('rooms').child($roomid).child('members').child(auth.uid).child('isBanned').val() == true))",
+ ".write": "data.val() == null && root.child('rooms').child($roomid).child('members').child(auth.uid).val() != null && !(root.child('rooms').child($roomid).child('members').child(auth.uid).child('isBanned').val() == true)",
"created": {
".validate": "newData.isNumber() && newData.val() == (data.val() == null ? now : data.val())"
}
diff --git a/samples/decoded.bolt b/samples/decoded.bolt
new file mode 100644
index 0000000..095694e
--- /dev/null
+++ b/samples/decoded.bolt
@@ -0,0 +1,6 @@
+// Bolt file auto-generated from JSON file.
+path /users/$uid {
+ read() = auth != null;
+ write() = auth != null && auth.uid == $uid;
+}
+path /users/$uid/name is String;
diff --git a/samples/decoded.json b/samples/decoded.json
new file mode 100644
index 0000000..decde7c
--- /dev/null
+++ b/samples/decoded.json
@@ -0,0 +1,13 @@
+{
+ "rules": {
+ "users": {
+ "$uid": {
+ ".read": "auth != null",
+ ".write": "auth != null && auth.uid == $uid",
+ "name": {
+ ".validate": "newData.isString()"
+ }
+ }
+ }
+ }
+}
diff --git a/samples/mail.json b/samples/mail.json
index d076a62..38f8428 100644
--- a/samples/mail.json
+++ b/samples/mail.json
@@ -4,7 +4,7 @@
"$userid": {
"inbox": {
"$msg": {
- ".validate": "newData.hasChildren(['from', 'to', 'message']) && data.val() == null && (auth != null && auth.uid == newData.child('from').val())",
+ ".validate": "newData.hasChildren(['from', 'to', 'message']) && data.val() == null && auth != null && auth.uid == newData.child('from').val()",
"from": {
".validate": "newData.isString()"
},
@@ -23,7 +23,7 @@
},
"outbox": {
"$msg": {
- ".validate": "newData.hasChildren(['from', 'to', 'message']) && data.val() == null && (auth != null && auth.uid == newData.child('from').val())",
+ ".validate": "newData.hasChildren(['from', 'to', 'message']) && data.val() == null && auth != null && auth.uid == newData.child('from').val()",
"from": {
".validate": "newData.isString()"
},
diff --git a/samples/user-security.json b/samples/user-security.json
index 91199fd..bf5935b 100644
--- a/samples/user-security.json
+++ b/samples/user-security.json
@@ -21,7 +21,7 @@
"$message_id": {
".validate": "newData.hasChildren(['user', 'message', 'timestamp']) && data.val() == null",
"user": {
- ".validate": "newData.isString() && (auth != null && auth.uid == newData.val())"
+ ".validate": "newData.isString() && auth != null && auth.uid == newData.val()"
},
"message": {
".validate": "newData.isString() && newData.val().length > 0 && newData.val().length < 50"
diff --git a/src/ast-matcher.ts b/src/ast-matcher.ts
new file mode 100644
index 0000000..1db771f
--- /dev/null
+++ b/src/ast-matcher.ts
@@ -0,0 +1,425 @@
+/*
+ * Copyright 2015 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+///
+import ast = require('./ast');
+import {parseExpression} from './parseUtil';
+import util = require('./util');
+
+import {IndexPermutation, Permutation} from './permutation';
+
+let DEBUG = false;
+
+let reverseOp = {
+ '<': '>',
+ '>': '<',
+ '<=': '>=',
+ '>=': '<=',
+};
+
+/*
+ * Post-order iterator over AST nodes.
+ */
+export class Match {
+ path: {exp: ast.Exp,
+ index: number}[];
+ index: number;
+ params: ast.ExpParams;
+
+ constructor(public exp: ast.Exp) {
+ this.path = [];
+ this.index = 0;
+ this.params = {};
+ this.advance();
+ }
+
+ advance(): ast.Exp {
+ while (ast.childCount(this.exp) > this.index) {
+ this.path.push({exp: this.exp, index: this.index});
+ this.exp = ast.getChild(this.exp, this.index);
+ this.index = 0;
+ }
+ this.index = -1;
+ return this.exp;
+ }
+
+ next(): ast.Exp {
+ // Already finished enumeration.
+ if (this.exp === null) {
+ throw new Error("Continued past end of AST enumeration.");
+ }
+
+ // No more children in current - go to parent.
+ this.pop();
+ if (this.exp === null) {
+ return null;
+ }
+
+ // Parent has no more children to enumerate - return the parent
+ // and mark as visited (index == -1).
+ if (this.index >= ast.childCount(this.exp)) {
+ this.index = -1;
+ return this.exp;
+ }
+
+ return this.advance();
+ }
+
+ pop() {
+ if (this.path.length === 0) {
+ this.exp = null;
+ return;
+ }
+ let node = this.path.pop();
+ this.exp = node.exp;
+ this.index = node.index + 1;
+ }
+
+ replaceExp(replacement: ast.Exp) {
+ if (this.path.length === 0) {
+ return replacement;
+ }
+ let parentPart = this.path.slice(-1)[0];
+ let parentBool = parentPart.exp;
+ ast.setChild(replacement, parentPart.exp, parentPart.index);
+ // When a boolean expression is collapsed to a single argument - hoist the argument
+ // to the parent.
+ if (parentBool.type === 'op' &&
+ (parentBool.op === '&&' || parentBool.op === '||') &&
+ parentBool.args.length === 1) {
+ parentPart.exp = parentBool.args[0];
+ }
+ return this.path[0].exp;
+ }
+}
+
+// "[()] => "
+// E.g. "(a, b) a.val() + b => a + b"
+let descriptorRegexp = /^\s*(?:\((.*)\))?\s*(.*\S)\s*=>\s*(.*\S)\s*$/;
+
+export class Rewriter implements util.Functor {
+ constructor(public paramNames: string[],
+ public pattern: ast.Exp,
+ public replacement: ast.Exp) {
+ }
+
+ static fromDescriptor(descriptor: string): Rewriter {
+ let match = descriptorRegexp.exec(descriptor);
+ if (match === null) {
+ return null;
+ }
+ let paramNames: string[];
+ if (match[1] === undefined) {
+ paramNames = [];
+ } else {
+ paramNames = match[1].split(/,\s+/);
+ if (paramNames.length === 1 && paramNames[0] === '') {
+ paramNames = [];
+ }
+ }
+ let result = new Rewriter(paramNames,
+ parseExpression(match[2]),
+ parseExpression(match[3]));
+ return result;
+ }
+
+ static fromFunction(name: string, method: ast.Method) {
+ if (method.body.type === 'op') {
+ let op = ( method.body).op;
+ if (op === '&&' || op === '||') {
+ // f(a, b) = becomes (a, b, _x) OP _x => f(a, b) OP _x
+ let free = ast.variable('_x');
+ let params = util.copyArray(method.params);
+ params.push('_x');
+ let body = ast.copyExp(method.body);
+ body.args.push(free);
+ let result = new Rewriter(params,
+ body,
+ ast.op(op, [ast.call(ast.variable(name),
+ method.params.map(ast.variable)),
+ free]));
+ return result;
+ }
+ }
+
+ // f(a, b) = becomes (a, b) => f(a, b)
+ return new Rewriter(method.params,
+ method.body,
+ ast.call(ast.variable(name),
+ method.params.map(ast.variable)));
+ }
+
+ apply(exp: ast.Exp): ast.Exp {
+ let match: Match;
+ let limit = 50;
+
+ while (match = findExp(this.pattern, exp, this.paramNames)) {
+ if (match.exp === null) {
+ break;
+ }
+ if (limit-- <= 0) {
+ throw new Error("Too many patterns (" + ast.decodeExpression(this.pattern) +
+ ") in expression: " + ast.decodeExpression(exp));
+ }
+ let replacement = replaceVars(this.replacement, match.params);
+ let oldExp: ast.Exp;
+ if (DEBUG) {
+ oldExp = ast.copyExp(exp);
+ }
+ exp = match.replaceExp(replacement);
+ if (DEBUG) {
+ console.log("Rewrite: " + [oldExp, exp].map(ast.decodeExpression).join(' => '));
+ }
+ }
+ return exp;
+ }
+
+ toString(): string {
+ let result = '';
+ if (this.paramNames.length > 0) {
+ result += '(' + this.paramNames.join(', ') + ') ';
+ }
+ result += ast.decodeExpression(this.pattern);
+ result += ' => ';
+ result += ast.decodeExpression(this.replacement);
+ return result;
+ }
+}
+
+export function replaceVars(exp: ast.Exp, params: ast.ExpParams): ast.Exp {
+ exp = ast.deepCopy(exp);
+ let match = new Match(exp);
+
+ while (match.exp !== null) {
+ if (match.exp.type === 'var') {
+ let expVar = match.exp;
+ if (params[expVar.name] !== undefined) {
+ exp = match.replaceExp(ast.deepCopy(params[expVar.name]));
+ }
+ }
+ match.next();
+ }
+ return exp;
+}
+
+export function findExp(pattern: ast.Exp,
+ exp: ast.Exp,
+ paramNames?: string[]): Match {
+ let match = new Match(exp);
+
+ while (match.exp !== null) {
+ let params: ast.ExpParams = {};
+ if (equivalent(pattern, match.exp, paramNames, params)) {
+ match.params = params;
+ return match;
+ }
+ match.next();
+ }
+ return match;
+}
+
+/*
+ * Test for equivalence of two expressions. Allows for wildcard
+ * subexpressions (given in paramNames). When a match is found,
+ * the value of the wildcards is returnd in params.
+ */
+function equivalent(pattern: ast.Exp,
+ exp: ast.Exp,
+ paramNames?: string[],
+ params: ast.ExpParams = {}
+ ): boolean {
+ if (DEBUG) {
+ console.log([pattern, exp].map(ast.decodeExpression).join(' ~ ') + ' with {' +
+ Object.keys(params).map((name) => {
+ return name + ': ' + ast.decodeExpression(params[name]);
+ }).join(', ') +
+ '}'
+ );
+ }
+
+ if (paramNames !== undefined && pattern.type === 'var') {
+ let name = ( pattern).name;
+ if (util.arrayIncludes(paramNames, name)) {
+ if (params[name] === undefined) {
+ if (DEBUG) {
+ console.log(name + ' := ' + ast.decodeExpression(exp));
+ }
+ params[name] = ast.copyExp(exp);
+ return true;
+ } else {
+ return equivalent(params[name], exp, paramNames, params);
+ }
+ }
+ }
+
+ if (pattern.type !== exp.type) {
+ return false;
+ }
+
+ function sameChildren(anyOrder = false): boolean {
+ let patternCount = ast.childCount(pattern);
+ if (patternCount !== ast.childCount(exp)) {
+ return false;
+ }
+ for (let p = new IndexPermutation(patternCount);
+ p.current() != null;
+ p.next()) {
+ let indexes = p.current();
+ let tempParams = util.extend({}, params);
+ let i: number;
+ for (i = 0; i < patternCount; i++) {
+ if (!equivalent(ast.getChild(pattern, i),
+ ast.getChild(exp, indexes[i]),
+ paramNames, tempParams)) {
+ break;
+ }
+ }
+ if (i === patternCount) {
+ util.extend(params, tempParams);
+ return true;
+ }
+ if (!anyOrder) {
+ return false;
+ }
+ }
+ return false;
+ }
+
+ switch (pattern.type) {
+ case 'Null':
+ return true;
+
+ case 'Boolean':
+ case 'Number':
+ case 'String':
+ case 'RegExp':
+ return ast.cmpValues( pattern, exp);
+
+ case 'Array':
+ case 'call':
+ case 'ref':
+ case 'union':
+ return sameChildren();
+
+ case 'generic':
+ if (( pattern).name !== ( exp).name) {
+ return false;
+ }
+ // NYI
+ return false;
+
+ case 'op':
+ let patternOp = pattern;
+ let op = patternOp.op;
+ let expOp = exp;
+ if (op !== expOp.op) {
+ if (reverseOp[op] === expOp.op) {
+ op = expOp.op;
+ exp = ast.copyExp(expOp);
+ ( exp).args = [expOp.args[1], expOp.args[0]];
+ } else {
+ return false;
+ }
+ }
+
+ switch (patternOp.op) {
+ default:
+ return sameChildren();
+
+ case '==':
+ case '!=':
+ return sameChildren(true);
+
+ case '||':
+ case '&&':
+ // For boolean expressions if the last clause of the pattern is a "free variable", after
+ // matching remainder of pattern against a permutation of the arguments we assign the
+ // free variable to the unmatched clauses.
+ let lastArg = patternOp.args[patternOp.args.length - 1];
+ if (lastArg.type !== 'var' || !util.arrayIncludes(paramNames, lastArg.name) ||
+ params[lastArg.name] !== undefined) {
+ return sameChildren(true);
+ }
+
+ let argCount = patternOp.args.length - 1;
+ if (argCount > expOp.args.length) {
+ return false;
+ }
+ let p: Permutation;
+ for (p = new Permutation(expOp.args, argCount);
+ p.current() != null;
+ p.next()) {
+ let tempParams = util.extend({}, params);
+ var args = p.current();
+ let i: number;
+ for (i = 0; i < args.length; i++) {
+ if (!equivalent(patternOp.args[i], args[i], paramNames, tempParams)) {
+ break;
+ }
+ }
+
+ // Found a match!
+ if (i === args.length) {
+ util.extend(params, tempParams);
+ if (params[lastArg.name] !== undefined) {
+ throw new Error("Free-variable of boolean expression cannot be repeated.");
+ }
+ var extraArgs: ast.Exp[] = [];
+ expOp.args.forEach((arg) => {
+ if (!util.arrayIncludes(args, arg)) {
+ extraArgs.push(arg);
+ }
+ });
+ if (extraArgs.length === 0) {
+ params[lastArg.name] = ast.voidType();
+ } else if (extraArgs.length === 1) {
+ params[lastArg.name] = extraArgs[0];
+ } else {
+ params[lastArg.name] = ast.op(patternOp.op, extraArgs);
+ }
+ return true;
+ }
+ }
+ return false;
+ }
+ break;
+
+ case 'literal':
+ case 'var':
+ return ( pattern).name === ( exp).name;
+
+ default:
+ throw new Error("Unknown expression pattern: " + ast.decodeExpression(pattern));
+ }
+}
+
+export let simplifyRewriter = new util.MultiFunctor([
+ "(_x) !!_x => _x",
+
+ "(_a, _x) _a && _a && _x => _a && _x",
+ "(_a, _x) _a || _a || _x => _a || _x",
+
+ "(_x) true || _x => true",
+ "(_x) true && _x => _x",
+ "(_x) false || _x => _x",
+ "(_x) false && _x => false",
+
+ "(_a, _b) !(_a != _b) => _a == _b",
+
+ "(_a, _x) _a && !_a && _x => false",
+ "(_a, _x) _a || !_a || _x => true",
+
+ "(_a, _x1, _x2) _a && _x1 || _a && _x2 => _a && (_x1 || _x2)",
+].map(Rewriter.fromDescriptor));
diff --git a/src/ast.ts b/src/ast.ts
index c266561..7d545dc 100644
--- a/src/ast.ts
+++ b/src/ast.ts
@@ -37,6 +37,9 @@ export interface RegExpValue extends ExpValue {
export interface ExpNull extends Exp {
}
+export interface ExpVoid extends Exp {
+}
+
export interface ExpOp extends Exp {
op: string;
args: Exp[];
@@ -61,7 +64,11 @@ export interface ExpCall extends Exp {
args: Exp[];
}
-export type BuiltinFunction = (args: Exp[], params: { [name: string]: Exp; }) => Exp;
+export interface ExpParams {
+ [name: string]: Exp;
+}
+
+export type BuiltinFunction = (args: Exp[], params: ExpParams) => Exp;
export interface ExpBuiltin extends Exp {
fn: BuiltinFunction;
@@ -149,6 +156,10 @@ export function nullType(): ExpNull {
return { type: 'Null', valueType: 'Null' };
}
+export function voidType(): ExpVoid {
+ return { type: 'Void', valueType: 'Void' };
+}
+
export function reference(base: Exp, prop: Exp): ExpReference {
return {
type: 'ref',
@@ -303,8 +314,14 @@ export function regexp(pattern: string, modifiers = ""): RegExpValue {
};
}
-function cmpValues(v1: ExpValue, v2: ExpValue): boolean {
- return v1.type === v2.type && v1.value === v2.value;
+export function cmpValues(v1: ExpValue, v2: ExpValue): boolean {
+ if (v1.type !== v2.type || v1.value !== v2.value) {
+ return false;
+ }
+ if (v1.type === 'RegExp' && ( v1).modifiers !== ( v2).modifiers) {
+ return false;
+ }
+ return true;
}
function isOp(opType: string, exp: Exp): boolean {
@@ -340,6 +357,7 @@ function leftAssociateGen(opType: string, identityValue: ExpValue, zeroValue: Ex
return function(a: Exp[]): Exp {
var i;
+ // TODO: No longer needed - just return flattened expression in one args array.
function reducer(result, current) {
if (result === undefined) {
return current;
@@ -355,7 +373,7 @@ function leftAssociateGen(opType: string, identityValue: ExpValue, zeroValue: Ex
var result = [];
for (i = 0; i < flat.length; i++) {
- // Remove identifyValues from array.
+ // Remove identityValues from array.
if (cmpValues(flat[i], identityValue)) {
continue;
}
@@ -375,6 +393,17 @@ function leftAssociateGen(opType: string, identityValue: ExpValue, zeroValue: Ex
};
}
+// Mutates exp so nested boolean expressions are flattened.
+export function flattenOp(exp: Exp): Exp {
+ let expOp = exp;
+ if (expOp.type !== 'op' || (expOp.op !== '||' && expOp.op !== '&&')) {
+ return exp;
+ }
+
+ expOp.args = flatten(expOp.op, expOp);
+ return expOp;
+}
+
// Flatten the top level tree of op into a single flat array of expressions.
export function flatten(opType: string, exp: Exp, flat?: Exp[]): Exp[] {
var i;
@@ -395,7 +424,7 @@ export function flatten(opType: string, exp: Exp, flat?: Exp[]): Exp[] {
return flat;
}
-export function op(opType, args): ExpOp {
+export function op(opType: string, args: Exp[]): ExpOp {
return {
type: 'op', // This is (multi-argument) operator.
valueType: 'Any',
@@ -424,6 +453,10 @@ export function genericType(typeName: string, params: ExpType[]): ExpGenericType
return { type: "generic", valueType: "type", name: typeName, params: params };
}
+function isExpType(typeName): boolean {
+ return (typeName === 'type' || typeName === 'union' || typeName === 'generic');
+}
+
export class Symbols {
functions: { [name: string]: Method };
paths: { [name: string]: Path };
@@ -582,6 +615,11 @@ export function decodeExpression(exp: Exp, outerPrecedence?: number): string {
result = 'null';
break;
+ case 'Void':
+ // Missing expression!
+ result = 'void';
+ break;
+
case 'var':
case 'literal':
result = ( exp).name;
@@ -611,19 +649,24 @@ export function decodeExpression(exp: Exp, outerPrecedence?: number): string {
var rep = JS_OPS[expOp.op].rep === undefined ? expOp.op : JS_OPS[expOp.op].rep;
if (expOp.args.length === 1) {
result = rep + decodeExpression(expOp.args[0], innerPrecedence);
- } else if (expOp.args.length === 2) {
- result =
- decodeExpression(expOp.args[0], innerPrecedence) +
- ' ' + rep + ' ' +
- // All ops are left associative - so nudge the innerPrecendence
- // down on the right hand side to force () for right-associating
- // operations.
- decodeExpression(expOp.args[1], innerPrecedence + 1);
- } else if (expOp.args.length === 3) {
+ } else if (expOp.op === '?:') {
result =
decodeExpression(expOp.args[0], innerPrecedence) + ' ? ' +
decodeExpression(expOp.args[1], innerPrecedence) + ' : ' +
decodeExpression(expOp.args[2], innerPrecedence);
+ } else {
+ // All ops are left associative - so nudge the innerPrecendence
+ // down on the right hand side to force () for right-associating
+ // operations (but ignore right-associating && and || since
+ // short-circuiting makes it moot).
+ let nudge = 0;
+ result = expOp.args.map((term) => {
+ let innerResult = decodeExpression(term, innerPrecedence + nudge);
+ if (rep !== '&&' && rep !== '||') {
+ nudge = 1;
+ }
+ return innerResult;
+ }).join(' ' + rep + ' ');
}
break;
@@ -679,3 +722,135 @@ function precedenceOf(exp: Exp): number {
return result;
}
+
+export function childCount(exp: Exp): number {
+ switch (exp.type) {
+ default:
+ return 0;
+
+ case 'Array':
+ return ( exp).value.length;
+
+ case 'ref':
+ return 2;
+
+ case 'call':
+ return 1 + ( exp).args.length;
+
+ case 'op':
+ return ( exp).args.length;
+
+ case 'union':
+ return ( exp).types.length;
+
+ case 'generic':
+ return ( exp).params.length;
+ }
+}
+
+export function getChild(exp: Exp, index: number): Exp {
+ switch (exp.type) {
+ default:
+ return null;
+
+ case 'Array':
+ return ( exp).value[index];
+
+ case 'ref':
+ let expRef = exp;
+ return [expRef.base, expRef.accessor][index];
+
+ case 'call':
+ let expCall = exp;
+ if (index === 0) {
+ return expCall.ref;
+ }
+ return expCall.args[index - 1];
+
+ case 'op':
+ return ( exp).args[index];
+
+ case 'union':
+ return ( exp).types[index];
+
+ case 'generic':
+ return ( exp).params[index];
+ }
+}
+
+// Mutate the parent to set the ith child element.
+export function setChild(expChild: Exp, expParent: Exp, index: number) {
+ switch (expParent.type) {
+ default:
+ throw new Error("AST node has no children.");
+
+ case 'Array':
+ ( expParent).value[index] = expChild;
+ break;
+
+ case 'ref':
+ if (index > 1) {
+ throw new Error("Reference node has only two children.");
+ }
+ let expRef = expParent;
+ if (index === 0) {
+ expRef.base = expChild;
+ } else {
+ expRef.accessor = expChild;
+ }
+ break;
+
+ case 'call':
+ let expCall = expParent;
+ if (index === 0) {
+ if (expChild.type !== 'var' && expChild.type !== 'ref') {
+ throw new Error(errors.typeMismatch + "expected Variable or Reference (not " +
+ expChild.type + ")");
+ }
+ expCall.ref = expChild;
+ } else {
+ expCall.args[index - 1] = expChild;
+ }
+ break;
+
+ case 'op':
+ let expOp = expParent;
+ // Void indicates to removing element from parent.
+ if (expChild.type === 'Void') {
+ expOp.args.splice(index, 1);
+ if (expOp.args.length < 1) {
+ throw new Error("Removing last argument from " + expOp.op);
+ }
+ } else {
+ expOp.args[index] = expChild;
+ }
+ expParent = flattenOp(expParent);
+ break;
+
+ case 'union':
+ if (!isExpType(expChild)) {
+ throw new Error(errors.typeMismatch + "expected Type (not " + expChild.type + ")");
+ }
+ ( expParent).types[index] = expChild;
+ break;
+
+ case 'generic':
+ if (!isExpType(expChild)) {
+ throw new Error(errors.typeMismatch + "expected Type (not " + expChild.type + ")");
+ }
+ ( expParent).params[index] = expChild;
+ break;
+ }
+}
+
+export function deepCopy(exp: Exp): Exp {
+ if (exp === null) {
+ return null;
+ }
+ exp = copyExp(exp);
+ let c = childCount(exp);
+ for (let i = 0; i < c; i++) {
+ setChild(deepCopy(getChild(exp, i)), exp, i);
+ }
+ return exp;
+}
diff --git a/src/bolt.ts b/src/bolt.ts
index d308c59..1cb94c2 100644
--- a/src/bolt.ts
+++ b/src/bolt.ts
@@ -17,24 +17,22 @@
var parser = require('./rules-parser');
import generator = require('./rules-generator');
+import decoder = require('./rules-decoder');
import simulator = require('./simulator');
-import astReal = require('./ast');
-import util = require('./util');
+import parseUtil = require('./parseUtil');
+export import ast = require('./ast');
export var FILE_EXTENSION = 'bolt';
-export var parse = util.maybePromise(parser.parse);
-export var generate = util.maybePromise(generateSync);
+
+export var parse = parser.parse;
export var Generator = generator.Generator;
-export var ast = astReal;
export var decodeExpression = ast.decodeExpression;
+export var decodeRules = decoder.decodeRules;
export var rulesSuite = simulator.rulesSuite;
+export var parseExpression = parseUtil.parseExpression;
-// Usage:
-// json = bolt.generate(bolt-text)
-function generateSync(symbols: string | astReal.Symbols): generator.Validator {
- if (typeof symbols === 'string') {
- symbols = parser.parse(symbols);
- }
- var gen = new generator.Generator( symbols);
+export function generate(boltText: string): generator.Validator {
+ let symbols = parser.parse(boltText);
+ var gen = new generator.Generator(symbols);
return gen.generateRules();
}
diff --git a/src/parseUtil.ts b/src/parseUtil.ts
new file mode 100644
index 0000000..4e796ca
--- /dev/null
+++ b/src/parseUtil.ts
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2015 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+///
+
+var parser = require('./rules-parser');
+import ast = require('./ast');
+
+export function parseExpression(expression: string): ast.Exp {
+ var result = parser.parse('function f() {return ' + expression + ';}');
+ return result.functions.f.body;
+}
diff --git a/src/permutation.ts b/src/permutation.ts
new file mode 100644
index 0000000..3455248
--- /dev/null
+++ b/src/permutation.ts
@@ -0,0 +1,134 @@
+/*
+ * AST builders for Firebase Rules Language.
+ *
+ * Copyright 2015 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/*
+ * Usage:
+ *
+ * for(i = new Iterator(); i.current(); t.next()) { ... }
+ */
+export interface Iterator {
+ current(): T;
+ next(): void;
+}
+
+export class IndexPermutation implements Iterator {
+ private values: number[] = [];
+ private locations: number[] = [];
+ private remaining: number;
+
+ constructor(private n: number, private k?: number) {
+ if (k === undefined) {
+ this.k = n;
+ }
+ if (k > n) {
+ throw new Error("Illegal permutation size " + k + " > " + n);
+ }
+ for (let i = 0; i < this.k; i++) {
+ this.set(i, i);
+ }
+ this.remaining = this.getCount() - 1;
+ }
+
+ getCount(): number {
+ let count = 1;
+ for (let i = 0; i < this.k; i++) {
+ count *= this.n - i;
+ }
+ return count;
+ }
+
+ current(): number[] {
+ if (this.values === null) {
+ return null;
+ }
+ return this.values.slice();
+ }
+
+ next() {
+ if (this.remaining === 0) {
+ this.values = null;
+ }
+ if (this.values === null) {
+ return;
+ }
+ this.advance();
+ }
+
+ private advance() {
+ let location = this.k - 1;
+ for (; location >= 0; location--) {
+ let value = this.nextValue(location, this.values[location] + 1);
+ this.set(location, value);
+ if (value !== undefined) {
+ break;
+ }
+ }
+ for (location += 1; location < this.k; location++) {
+ this.set(location, this.nextValue(location, 0));
+ }
+ this.remaining -= 1;
+ }
+
+ private set(location: number, value?: number) {
+ let oldValue = this.values[location];
+ if (oldValue !== undefined) {
+ this.locations[oldValue] = undefined;
+ }
+ this.values[location] = value;
+ if (value !== undefined) {
+ this.locations[value] = location;
+ }
+ }
+
+ private nextValue(location: number, minValue: number): number {
+ for (let value = minValue; value < this.n; value++) {
+ if (this.locations[value] === undefined || this.locations[value] > location) {
+ return value;
+ }
+ }
+ return undefined;
+ }
+}
+
+export class Permutation implements Iterator {
+ collection: T[];
+ p: IndexPermutation;
+
+ constructor(collection: T[], k?: number) {
+ this.p = new IndexPermutation(collection.length, k);
+ this.collection = collection.slice();
+ }
+
+ getCount(): number {
+ return this.p.getCount();
+ }
+
+ current(): T[] {
+ let indexes = this.p.current();
+ if (indexes === null) {
+ return null;
+ }
+ return indexes.map((i) => {
+ return this.collection[i];
+ });
+ }
+
+ next() {
+ this.p.next();
+ }
+}
diff --git a/src/rules-decoder.ts b/src/rules-decoder.ts
new file mode 100644
index 0000000..48e3b11
--- /dev/null
+++ b/src/rules-decoder.ts
@@ -0,0 +1,207 @@
+/*
+ * Copyright 2015 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+///
+// import util = require('./util');
+import ast = require('./ast');
+import util = require('./util');
+import matcher = require('./ast-matcher');
+import {parseExpression} from './parseUtil';
+var stripComments = require('strip-json-comments');
+
+let typeIndicators = {
+ "this.isString()": "String",
+ "this.isNumber()": "Number",
+ "this.isBoolean()": "Boolean"
+};
+
+export function decodeRules(jsonString: string,
+ functions: {[ name: string ]: ast.Method}): string {
+ return decodeJSON(JSON.parse(cleanJSONString(jsonString)));
+}
+
+export function cleanJSONString(jsonString: string): string {
+ jsonString = stripComments(jsonString);
+ jsonString = jsonString.replace(/"([^\\"]|\\[^])*"/g, function(s) {
+ return s.replace(/\n/g, '\\n');
+ });
+ return jsonString;
+}
+
+export function decodeJSON(json: Object): string {
+ var formatter = new Formatter;
+ formatter.decodeParts('/', json['rules']);
+ return formatter.toString();
+}
+
+class PathConstraints {
+ type: ast.ExpType;
+ methods: { [name: string]: string };
+
+ constructor(typeName: string) {
+ this.type = ast.typeType('Any');
+ this.methods = {};
+ }
+}
+
+let readRewriter = matcher.Rewriter.fromDescriptor("data => this");
+
+let writeRewriter = new util.MultiFunctor([
+ "data => prior(this)",
+ "root => _priorRoot",
+ "_priorRoot => prior(root)",
+].map(matcher.Rewriter.fromDescriptor));
+
+let genRewriter = new util.MultiFunctor([
+ "newData => this",
+ "(_a, _b) _a.child(_b) => _a[_b]",
+ "(_a) _a.val() => _a",
+ "(_a) _a.exists() => _a != null",
+ "(_a, _b) _a.hasChild(_b) => _a[_b] != null",
+ "(_a, _b) _a.contains(_b) => _a.includes(_b)",
+ "(_a, _b) _a.beginsWith(_b) => _a.startsWith(_b)",
+ "(_a, _b) _a.matches(_b) => _a.test(_b)",
+].map(matcher.Rewriter.fromDescriptor));
+
+class Formatter {
+ exps: { [path: string]: PathConstraints };
+ indent: number;
+
+ constructor() {
+ this.exps = {};
+ }
+
+ decodeParts(path: string, json: Object) {
+ for (var key in json) {
+ if (key[0] === '.') {
+ this.emit(path, key.slice(1), json[key]);
+ } else {
+ this.decodeParts(childPath(path, key), json[key]);
+ }
+ }
+ }
+
+ emit(path: string, method: string, exp: string | Array) {
+ let expString = exp;
+
+ if (method !== 'indexOn') {
+ // Normalize expression
+ try {
+ let expIn = parseExpression(expString);
+ let expOut = (method === 'read' ? readRewriter : writeRewriter).apply(expIn);
+ expOut = genRewriter.apply(expOut);
+ expOut = matcher.simplifyRewriter.apply(expOut);
+ expString = ast.decodeExpression(expOut);
+ } catch (e) {
+ throw new Error("Could not parse expression: '" + expString + "' (" + e.stack + ")");
+ }
+ }
+
+ if (this.exps[path] === undefined) {
+ this.exps[path] = new PathConstraints('Any');
+ }
+
+ let pc = this.exps[path];
+
+ switch (method) {
+ case 'indexOn':
+ pc.methods['index'] = JSON.stringify(exp);
+ break;
+ case 'validate':
+ if (typeIndicators[expString]) {
+ pc.type = ast.typeType(typeIndicators[expString]);
+ } else {
+ pc.methods[method] = expString;
+ }
+ break;
+ default:
+ pc.methods[method] = expString;
+ break;
+ }
+ }
+
+ toString(): string {
+ let lines = [];
+ let paths = Object.keys(this.exps).sort();
+ let openPaths: string[] = [];
+
+ function closeOpenPaths(path: string) {
+ while (openPaths.length > 0 &&
+ (path === '' || !util.isPrefix(pathParts(currentPath()), pathParts(path)))) {
+ openPaths.pop();
+ lines.push(indent(openPaths.length) + "}");
+ }
+ }
+
+ function currentPath(): string {
+ if (openPaths.length === 0) {
+ return '';
+ }
+ return openPaths.slice(-1)[0];
+ }
+
+ for (let i = 0; i < paths.length; i++) {
+ let path = paths[i];
+ let pc = this.exps[path];
+ let isParent = i < paths.length - 1 && util.isPrefix(pathParts(path), pathParts(paths[i + 1]));
+
+ closeOpenPaths(path);
+
+ let childPath: string;
+ childPath = currentPath() === '/' ? path : path.slice(currentPath().length);
+ let line = indent(openPaths.length) + (openPaths.length === 0 ? 'path ' : '') + childPath;
+
+ if (( pc.type).name !== 'Any') {
+ line += " is " + ast.decodeExpression(pc.type);
+ }
+ if (Object.keys(pc.methods).length === 0 && !isParent) {
+ lines.push(line + ";");
+ continue;
+ }
+ lines.push(line + " {");
+ openPaths.push(path);
+ for (let method in pc.methods) {
+ lines.push(indent(openPaths.length) + method + "() = " + pc.methods[method] + ";");
+ }
+ }
+
+ closeOpenPaths('');
+
+ return lines.join('\n');
+ }
+}
+
+function childPath(path: string, child: string): string {
+ if (path.slice(-1) === '/') {
+ return path + child;
+ }
+ return path + '/' + child;
+}
+
+function pathParts(path: string): string[] {
+ if (path === undefined) {
+ return [];
+ }
+ // Remove initial slash.
+ path = path.slice(1);
+ if (path === '') {
+ return [];
+ }
+ return path.split('/');
+}
+
+function indent(n: number): string {
+ return util.repeatString(' ', 2 * n);
+}
diff --git a/src/rules-generator.ts b/src/rules-generator.ts
index b4fe7e7..61e8f03 100644
--- a/src/rules-generator.ts
+++ b/src/rules-generator.ts
@@ -602,7 +602,7 @@ export class Generator {
// - Expand snapshot references using child('ref').
// - Coerce snapshot references to values as needed.
partialEvalReal(exp: ast.Exp,
- params: { [name: string]: ast.Exp } = {},
+ params: ast.ExpParams = {},
functionCalls: { [name: string]: boolean } = {})
: ast.Exp {
var self = this;
diff --git a/src/rules-parser.pegjs b/src/rules-parser.pegjs
index 742aadf..6352bed 100644
--- a/src/rules-parser.pegjs
+++ b/src/rules-parser.pegjs
@@ -382,19 +382,27 @@ EqualityExpression
EqualityOperator = ("===" / "==") { return "=="; }
/ ("!==" / "!=") { return "!="; }
-LogicalANDExpression
- = head:EqualityExpression
- tail:(_ op:LogicalANDOperator _ exp:EqualityExpression { return {op: op, exp: exp}; })* {
- return leftAssociative(head, tail);
+LogicalANDExpression =
+ head:EqualityExpression
+ tail:(_ op:LogicalANDOperator _ exp:EqualityExpression { return exp; })* {
+ if (tail.length === 0) {
+ return head;
}
+ tail.unshift(head);
+ return ast.flattenOp(ast.op("&&", tail));
+ }
LogicalANDOperator = ("&&" / "and") { return "&&"; }
-LogicalORExpression
- = head:LogicalANDExpression
- tail:(_ op:LogicalOROperator _ exp:LogicalANDExpression { return {op: op, exp: exp}; })* {
- return leftAssociative(head, tail);
+LogicalORExpression =
+ head:LogicalANDExpression
+ tail:(_ op:LogicalOROperator _ exp:LogicalANDExpression { return exp; })* {
+ if (tail.length === 0) {
+ return head;
}
+ tail.unshift(head);
+ return ast.flattenOp(ast.op("||", tail));
+ }
LogicalOROperator = ("||" / "or") { return "||"; }
@@ -422,7 +430,12 @@ Literal
Null = "null" { return ast.nullType() }
-ArrayLiteral = "[" _ elements:ArgumentList? _ "]" { return ast.array(elements); }
+ArrayLiteral = "[" _ elements:ArgumentList? _ "]" {
+ if (elements === null) {
+ elements = [];
+ }
+ return ast.array(elements);
+}
BooleanLiteral
= "true" { return ast.boolean(true); }
diff --git a/src/simulator.ts b/src/simulator.ts
index 3e5c521..ad1bee8 100644
--- a/src/simulator.ts
+++ b/src/simulator.ts
@@ -70,8 +70,9 @@ util.methods(RulesSuite, {
self.databaseReady = resolve;
});
- var rulesJSON = bolt.generate(util.getProp(fileIO.readFile(rulesPath),
- 'content'));
+ let rulesJSON = fileIO.readFile(rulesPath).then(function(result) {
+ return bolt.generate(result.content);
+ });
self.ready = Promise.all([rulesJSON, database])
.then(self.onRulesReady.bind(self));
diff --git a/src/test/ast-test.ts b/src/test/ast-test.ts
index 34e820b..664814a 100644
--- a/src/test/ast-test.ts
+++ b/src/test/ast-test.ts
@@ -19,7 +19,6 @@ import helper = require('./test-helper');
import ast = require('../ast');
var bolt = (typeof(window) !== 'undefined' && window.bolt) || require('../bolt');
-var parse = bolt.parse;
suite("Abstract Syntax Tree (AST)", function() {
suite("Left Associative Operators (AND OR)", function() {
@@ -193,6 +192,7 @@ suite("Abstract Syntax Tree (AST)", function() {
[ "(a + b) + c", 'a + b + c' ],
[ "a + b * c" ],
[ "(a + b) * c" ],
+ [ "a * b * (d + d)" ],
[ "a < 7" ],
[ "a > 7" ],
[ "a <= 7" ],
@@ -206,7 +206,13 @@ suite("Abstract Syntax Tree (AST)", function() {
[ "a == 1 || b <= 2" ],
[ "a && b && c" ],
[ "a || b || c" ],
+ // Converts right-associatvie to left-associative for && and ||
+ [ "a && (b && c)", "a && b && c" ],
+ [ "a || (b || c)", "a || b || c" ],
+ [ "a && b && (c || d)" ],
+ [ "a || b || (c && d)", "a || b || c && d" ],
[ "a && b || c && d" ],
+ [ "(a || b) && (c || d)" ],
[ "a ? b : c", ],
[ "a || b ? c : d" ],
[ "(this + ' ').test(/\d+/)" ],
@@ -215,8 +221,7 @@ suite("Abstract Syntax Tree (AST)", function() {
helper.dataDrivenTest(tests, function(data, expect) {
// Decode to self by default
expect = expect || data;
- var result = parse('function f() {return ' + data + ';}');
- var exp = result.functions.f.body;
+ var exp = bolt.parseExpression(data);
var decode = bolt.decodeExpression(exp);
assert.equal(decode, expect);
});
diff --git a/src/test/cli-test.ts b/src/test/cli-test.ts
index 7e2fad9..9a5d38c 100644
--- a/src/test/cli-test.ts
+++ b/src/test/cli-test.ts
@@ -67,6 +67,12 @@ suite("firebase-bolt CLI", function() {
expect: {out: /^$/, err: /bolt: Could not read file: nosuchfile.bolt/} },
{ data: "two files",
expect: {out: /^$/, err: /bolt: Can only compile a single file/} },
+ { data: "migrate",
+ expect: {out: /^$/, err: /bolt-migrate: Missing JSON file name/} },
+
+ // Migrate from json file
+ { data: "migrate samples/decoded --output " + TMP_DIR + "decoded",
+ expect: {out: /^$/, err: /^bolt-migrate: Generating tmp\/decoded.bolt\.\.\.\n$/} },
];
helper.dataDrivenTest(tests, function(data, expect) {
diff --git a/src/test/decoder-test.ts b/src/test/decoder-test.ts
new file mode 100644
index 0000000..0b0da2a
--- /dev/null
+++ b/src/test/decoder-test.ts
@@ -0,0 +1,151 @@
+/*
+ * Copyright 2015 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+///
+///
+///
+
+import bolt = require('../bolt');
+import fileio = require('../file-io');
+import helper = require('./test-helper');
+import decoder = require('../rules-decoder');
+
+import chai = require('chai');
+chai.config.truncateThreshold = 1000;
+var assert = chai.assert;
+
+
+suite("JSON Rules Decoder", function() {
+ suite("Basic Samples", function() {
+ var tests = [
+ { data: { rules: {".read": "true", ".write": "true"} },
+ expect: "path / {\n read() = true;\n write() = true;\n}"
+ },
+
+ { data: { rules: { "a": { ".read": "true", ".write": "true"}} },
+ expect: "path /a {\n read() = true;\n write() = true;\n}"
+ },
+
+ { data: { rules: { "a": { ".validate": "newData.isString()"}} },
+ expect: "path /a is String;"
+ },
+
+ { data: { rules: { "a": { ".indexOn": "prop"}} },
+ expect: "path /a {\n index() = \"prop\";\n}",
+ },
+
+ { data: { rules: { "a": { ".indexOn": ["prop1", "prop2"]}} },
+ expect: "path /a {\n index() = [\"prop1\",\"prop2\"];\n}"
+ },
+
+ { data: { rules: { "a": { ".read": "true",
+ "b": { ".write": "true" }}} },
+ expect: "path /a {\n read() = true;\n /b {\n write() = true;\n }\n}"
+ },
+
+ { data: { rules: { "a": { ".read": "true",
+ "b": { ".write": "true" },
+ "c": { ".write": "false" }}} },
+ expect: "path /a {\n read() = true;\n /b {\n write() = true;\n }\n /c {\n write() = false;\n }\n}"
+ },
+
+ { data: { rules: {"a": { ".validate": "(newData.val() + '').matches(/^-?\\d+$/)" }}},
+ expect: "path /a {\n validate() = (this + '').test(/^-?\\d+$/);\n}" },
+ ];
+
+ helper.dataDrivenTest(tests, function(data, expect) {
+ var result = decoder.decodeJSON(data);
+ assert.equal(result, expect, result);
+ });
+ });
+
+ suite("Data references", function() {
+ var tests = [
+ { data: { rules: { "a": { ".read": "data.child('prop').val() > 0"}} },
+ expect: "path /a {\n read() = this.prop > 0;\n}",
+ },
+
+ { data: { rules: { "$a": { ".read": "data.child($a).val() > 0"}} },
+ expect: "path /$a {\n read() = this[$a] > 0;\n}",
+ },
+
+ { data: { rules: { "a": { ".read": "data.exists()"}} },
+ expect: "path /a {\n read() = this != null;\n}",
+ },
+
+ { data: { rules: { "a": { ".validate": "newData.val() == data.val()"}} },
+ expect: "path /a {\n validate() = this == prior(this);\n}",
+ },
+ ];
+
+ helper.dataDrivenTest(tests, function(data, expect) {
+ var result = decoder.decodeJSON(data);
+ assert.equal(result, expect);
+ });
+ });
+
+ suite("String methods", function() {
+ var tests = [
+ [ "length > 0", "length > 0" ],
+ [ "contains('x')", "includes('x')" ],
+ [ "beginsWith('x')", "startsWith('x')" ],
+ [ "endsWith('x')", "endsWith('x')" ],
+ [ "replace('a', 'b')", "replace('a', 'b')" ],
+ [ "matches(/\d+/)", "test(/\d+/)" ],
+ ];
+
+ helper.dataDrivenTest(tests, function(data, expect) {
+ let rules = { "rules": {
+ "a": { ".read": "data.val()." + data}
+ }};
+ let bolt = "path /a {\n read() = this." + expect + ";\n}";
+ var result = decoder.decodeJSON(rules);
+ assert.equal(result, bolt);
+ });
+ });
+
+ suite("Samples decoder round-trip", function() {
+ var files = ["all_access",
+ "userdoc",
+ "mail",
+ "type-extension",
+ "children",
+ "functional",
+ "user-security",
+ "generics",
+ "groups",
+ "multi-update",
+ "chat",
+ "serialized",
+ "map-scalar",
+ "regexp",
+ "decoded"
+ ];
+
+ helper.dataDrivenTest(files, function(filename) {
+ filename = 'samples/' + filename + '.json';
+ return fileio.readFile(filename)
+ .then(function(response) {
+ let json = JSON.parse(decoder.cleanJSONString(response.content));
+ let boltString = decoder.decodeJSON(json);
+ let generatedJSON = bolt.generate(boltString);
+ assert.deepEqual(generatedJSON, json);
+ })
+ .catch(function(error) {
+ assert.ok(false, error.message);
+ });
+ });
+ });
+});
diff --git a/src/test/generator-test.ts b/src/test/generator-test.ts
index 81eb5d4..422ab57 100644
--- a/src/test/generator-test.ts
+++ b/src/test/generator-test.ts
@@ -168,6 +168,10 @@ suite("Rules Generator Tests", function() {
expect: "newData.val().toUpperCase()" },
{ data: "'ababa'.test(/bab/)",
expect: "'ababa'.matches(/bab/)" },
+ { data: "('ababa' + 'c').test(/bab/)",
+ expect: "('ababa' + 'c').matches(/bab/)" },
+ { data: "(this + ' ').test(/\d+/)",
+ expect: "(newData.val() + ' ').matches(/\d+/)" },
];
helper.dataDrivenTest(tests, function(data, expect) {
@@ -319,6 +323,9 @@ suite("Rules Generator Tests", function() {
expect: {'.validate': "newData.isString() && newData.parent().val() == 'new' && root.val() == 'old'",
'x': {'.read': "root.val() == 'old'"}
} },
+
+ { data: "type T extends String { validate() = (this + ' ').test(/\d+/); }",
+ expect: {'.validate': "newData.isString() && (newData.val() + ' ').matches(/\d+/)"} },
];
helper.dataDrivenTest(tests, function(data, expect) {
diff --git a/src/test/matcher-test.ts b/src/test/matcher-test.ts
new file mode 100644
index 0000000..469c72a
--- /dev/null
+++ b/src/test/matcher-test.ts
@@ -0,0 +1,287 @@
+/*
+ * Copyright 2015 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import chai = require('chai');
+var assert = chai.assert;
+import helper = require('./test-helper');
+
+import {parseExpression} from '../parseUtil';
+import bolt = require('../bolt');
+import ast = require('../ast');
+import matcher = require('../ast-matcher');
+
+suite("AST Matching", function() {
+ suite("Values to values", () => {
+ let tests = ["false", "1", "'a'", "a", "[]", "1.2", "null", "[1,2]"];
+
+ helper.dataDrivenTest(tests, function(data, expect) {
+ let exp = parseExpression(data);
+ let match = matcher.findExp(parseExpression(data), exp);
+ assert.deepEqual(match.exp, exp);
+ });
+ });
+
+ suite("Values in expressions", () => {
+ let tests = [
+ { pattern: "false", exp: "true || false" },
+ { pattern: "a", exp: "a + 1" },
+ { pattern: "a", exp: "1 + a" },
+ { pattern: "1", exp: "2 + 3 + 1 + 5" },
+ { pattern: "'a'", exp: "2 + 3 + 'a' + 5" },
+ { pattern: "3", exp: "2 * (4 + 3)" },
+ ];
+
+ helper.dataDrivenTest(tests, function(data, expect) {
+ let pattern = parseExpression(data.pattern);
+ let match = matcher.findExp(pattern, parseExpression(data.exp));
+ assert.deepEqual(match.exp, pattern);
+ });
+ });
+
+ suite("Sub-expressions in expressions", () => {
+ let tests = [
+ { pattern: "a + 1", exp: "a + 1" },
+ { pattern: "a.test(/x/)", exp: "a + a.test(/x/)" },
+ { pattern: "a < b", exp: "a < b" },
+ { pattern: "a < b", exp: "b > a" },
+ { pattern: "a <= b", exp: "b >= a" },
+ { pattern: "a == b", exp: "a == b" },
+ { pattern: "a == b", exp: "b == a" },
+ { pattern: "a != b", exp: "a != b" },
+ { pattern: "a != b", exp: "b != a" },
+ ];
+
+ helper.dataDrivenTest(tests, function(data, expect) {
+ let pattern = parseExpression(data.pattern);
+ let match = matcher.findExp(pattern, parseExpression(data.exp));
+ assert.equal(( match.exp).op, ( match.exp).op);
+ });
+ });
+
+ suite("Sub-expressions not in expressions", () => {
+ let tests = [
+ { pattern: "a + 1", exp: "1 + a" },
+ { pattern: "a + c", exp: "c + b + b" },
+ ];
+
+ helper.dataDrivenTest(tests, function(data, expect) {
+ let pattern = parseExpression(data.pattern);
+ let match = matcher.findExp(pattern, parseExpression(data.exp));
+ assert.equal(match.exp, null);
+ });
+ });
+
+ suite("Sub-expressions with params", () => {
+ let tests = [
+ { vars: ['a'], pattern: "a + 1", exp: "a + 1" },
+ { vars: ['a'], pattern: "a + 1", exp: "x + 1" },
+ { vars: ['_x'], pattern: "true || _x", exp: "a || b || true || c" },
+ { vars: ['_x', 'x'], pattern: "x || x || _x", exp: "a || b || a" },
+ ];
+
+ helper.dataDrivenTest(tests, function(data, expect) {
+ let pattern = parseExpression(data.pattern);
+ let match = matcher.findExp(pattern,
+ parseExpression(data.exp),
+ data.vars);
+ assert.ok(match.exp !== null && ( match.exp).op === ( match.exp).op);
+ });
+ });
+
+ suite("Sub-expressions with params not present", () => {
+ let tests = [
+ { vars: ['a'], pattern: "a + 1", exp: "a + 2" },
+ { vars: ['a'], pattern: "a + 1", exp: "x + 2" },
+ // Ignore complex expression
+ // { vars: ['x'], pattern: "x || x", exp: "a || b || c" },
+ ];
+
+ helper.dataDrivenTest(tests, function(data, expect) {
+ let pattern = parseExpression(data.pattern);
+ let match = matcher.findExp(pattern,
+ parseExpression(data.exp),
+ data.vars);
+ assert.ok(match.exp == null);
+ });
+ });
+
+ suite("Re-writing descriptors", () => {
+ let tests = [
+ { data: "a => b",
+ expect: { params: [], pattern: "a", replacement: "b" } },
+ { data: "() a => b",
+ expect: { params: [], pattern: "a", replacement: "b" } },
+ { data: "()a => b",
+ expect: { params: [], pattern: "a", replacement: "b" } },
+ { data: "(a) a => b",
+ expect: { params: ['a'], pattern: "a", replacement: "b" } },
+ { data: "(a, b) a => b",
+ expect: { params: ['a', 'b'], pattern: "a", replacement: "b" } },
+ ];
+
+ helper.dataDrivenTest(tests, function(data, expect) {
+ let rule = matcher.Rewriter.fromDescriptor(data);
+ assert.deepEqual(rule.paramNames, expect.params);
+ assert.equal(ast.decodeExpression(rule.pattern), expect.pattern);
+ assert.equal(ast.decodeExpression(rule.replacement), expect.replacement);
+ });
+ });
+
+ suite("ReplaceVars", () => {
+ let tests = [
+ { data: { exp: "a + 1", params: { a: "2" } },
+ expect: "2 + 1" },
+ { data: { exp: "a", params: { a: "2" } },
+ expect: "2" },
+ { data: { exp: "a.val()", params: { a: "newData" } },
+ expect: "newData.val()" },
+ { data: { exp: "a.val() + a", params: { a: "newData" } },
+ expect: "newData.val() + newData" },
+ // .a is a property name - not a variable
+ { data: { exp: "a.a()", params: { a: "newData" } },
+ expect: "newData.a()" },
+ { data: { exp: "a.val() != b.val()", params: { a: "this", b: "prior(this)" } },
+ expect: "this.val() != prior(this).val()" },
+ ];
+
+ helper.dataDrivenTest(tests, function(data, expect) {
+ let exp = parseExpression(data.exp);
+ let params: ast.ExpParams = {};
+ Object.keys(data.params).forEach((key) => {
+ params[key] = parseExpression(data.params[key]);
+ });
+ let result = matcher.replaceVars(exp, params);
+ assert.equal(ast.decodeExpression(result), expect);
+ });
+ });
+
+ suite("Expression re-writing", () => {
+ let tests = [
+ { data: { rule: "newData => this", exp: "newData.val() != data.val()"},
+ expect: "this.val() != data.val()" },
+ { data: { rule: "data => prior(this)", exp: "newData.val() != data.val()"},
+ expect: "newData.val() != prior(this).val()" },
+ { data: { rule: "(a) a.val() => a", exp: "newData.val() != data.val()"},
+ expect: "newData != data" },
+
+ { data: { rule: "(_x) true || _x => true", exp: "one || two"},
+ expect: "one || two" },
+ { data: { rule: "(_x) true || _x => true", exp: "one || true"},
+ expect: "true" },
+ { data: { rule: "(_x) true || _x => true", exp: "one || two || true"},
+ expect: "true" },
+ { data: { rule: "(_x) true || _x => true", exp: "true || one || two"},
+ expect: "true" },
+
+ { data: { rule: "(_x) false || _x => _x", exp: "one || two"},
+ expect: "one || two" },
+ { data: { rule: "(_x) false || _x => _x", exp: "one || false"},
+ expect: "one" },
+ { data: { rule: "(_x) false || _x => _x", exp: "one || two || false"},
+ expect: "one || two" },
+ { data: { rule: "(_x) false || _x => _x", exp: "false || one || two"},
+ expect: "one || two" },
+ ];
+
+ helper.dataDrivenTest(tests, function(data, expect) {
+ let exp = parseExpression(data.exp);
+ let rule = matcher.Rewriter.fromDescriptor(data.rule);
+ let result = rule.apply(exp);
+ assert.equal(ast.decodeExpression(result), expect);
+ });
+ });
+
+ suite("Function rewriting", () => {
+ let tests = [
+ { data: { functions: "isUser(a) = auth.uid == a;",
+ exp: "auth.uid == this" },
+ expect: "isUser(this)" },
+ { data: { functions: "add(a, b) = a + b;",
+ exp: "x + y" },
+ expect: "add(x, y)" },
+ { data: { functions: "add(a, b) = a + b; sub(a, b) = a - b;",
+ exp: "x + y - z" },
+ expect: "sub(add(x, y), z)" },
+ { data: { functions: "add(a, b) = a + b; sub(a, b) = a - b;",
+ exp: "x + (y - z)" },
+ expect: "add(x, sub(y, z))" },
+ { data: { functions: "or(a, b) = a || b;",
+ exp: "x || y" },
+ expect: "or(x, y)" },
+ { data: { functions: "and(a, b) = a && b;",
+ exp: "x && y" },
+ expect: "and(x, y)" },
+ { data: { functions: "and(a, b) = a && b;",
+ exp: "x && y && z" },
+ expect: "and(and(x, y), z)" },
+ ];
+
+ helper.dataDrivenTest(tests, function(data, expect) {
+ let exp = parseExpression(data.exp);
+ let functions = bolt.parse(data.functions).functions;
+ Object.keys(functions).forEach((name) => {
+ let rule = matcher.Rewriter.fromFunction(name, functions[name]);
+ exp = rule.apply(exp);
+ });
+ assert.equal(ast.decodeExpression(exp), expect);
+ });
+ });
+
+ suite("Expression simplification", () => {
+ let tests = [
+ [ "a && a", "a" ],
+ [ "a || a", "a" ],
+ [ "a && b || a && b", "a && b" ],
+
+ [ "a && b && c && d && e", "a && b && c && d && e"],
+ [ "a && a && c && d && e", "a && c && d && e"],
+ [ "a && b && a && d && e", "a && b && d && e"],
+ [ "a && b && c && a && e", "a && b && c && e"],
+ [ "a && b && c && d && a", "a && b && c && d"],
+
+ [ "a || !!(a || c)", "a || c" ],
+
+ [ "hang && b || c && d && e",
+ "hang && b || c && d && e" ],
+
+ [ "a || b || true || c", "true"],
+ [ "a && b && true && c", "a && b && c"],
+
+ [ "a || b || false || c", "a || b || c"],
+ [ "a && b && false && c", "false"],
+
+ [ "!a", "!a" ],
+ [ "!!a", "a" ],
+ [ "!!!a", "!a" ],
+ [ "!!!!a", "a" ],
+
+ [ "a || !a", "true" ],
+ [ "a && !a", "false" ],
+
+ [ "a && b || a && c", "a && (b || c)" ],
+ [ "a && b && (c || d)", "a && b && (c || d)" ],
+ [ "a && b && c || a && b && d", "a && b && (c || d)" ],
+
+ [ "a && b && d || c && a && e", "a && (b && d || c && e)" ],
+// [ "/* bug */ b && d && a || c && a && e", "a && (b && d || c && e)" ],
+ ];
+
+ helper.dataDrivenTest(tests, function(data, expect) {
+ let exp = parseExpression(data);
+ let result = matcher.simplifyRewriter.apply(exp);
+ assert.equal(ast.decodeExpression(result), expect);
+ });
+ });
+});
diff --git a/src/test/parser-test.ts b/src/test/parser-test.ts
index 2a7db13..edf30e2 100644
--- a/src/test/parser-test.ts
+++ b/src/test/parser-test.ts
@@ -68,6 +68,7 @@ suite("Rules Parser Tests", function() {
[ "+3", ast.number(3) ],
[ "-3", ast.number(-3) ],
[ "0x2", ast.number(2) ],
+ [ "[]", ast.array([]) ],
[ "[1, 2, 3]", ast.array([ast.number(1), ast.number(2), ast.number(3)]) ],
[ "\"string\"", ast.string("string") ],
[ "'string'", ast.string("string") ],
@@ -80,8 +81,8 @@ suite("Rules Parser Tests", function() {
];
helper.dataDrivenTest(tests, function(data, expect) {
- var result = parse("function f() { return " + data + ";}");
- assert.deepEqual(result.functions.f.body, expect);
+ let exp = bolt.parseExpression(data);
+ assert.deepEqual(exp, expect);
});
});
@@ -124,13 +125,19 @@ suite("Rules Parser Tests", function() {
ast.lte(ast.variable('b'), ast.number(2))) ],
[ "a == 1 || b <= 2", ast.or(ast.eq(ast.variable('a'), ast.number(1)),
ast.lte(ast.variable('b'), ast.number(2))) ],
- // Left associative (even though execution is short-circuited!
- [ "a && b && c", ast.and(ast.and(ast.variable('a'),
- ast.variable('b')),
- ast.variable('c')) ],
- [ "a || b || c", ast.or(ast.or(ast.variable('a'),
- ast.variable('b')),
- ast.variable('c')) ],
+ // Normal left associative && and ||
+ [ "a && b && c", ast.op('&&', [ast.variable('a'),
+ ast.variable('b'),
+ ast.variable('c')]) ],
+ [ "a || b || c", ast.op('||', [ast.variable('a'),
+ ast.variable('b'),
+ ast.variable('c')]) ],
+ [ "a && (b && c)", ast.op('&&', [ast.variable('a'),
+ ast.variable('b'),
+ ast.variable('c')]) ],
+ [ "a || (b || c)", ast.op('||', [ast.variable('a'),
+ ast.variable('b'),
+ ast.variable('c')]) ],
// && over || precendence
[ "a && b || c && d", ast.or(ast.and(ast.variable('a'),
ast.variable('b')),
@@ -143,8 +150,11 @@ suite("Rules Parser Tests", function() {
];
helper.dataDrivenTest(tests, function(data, expect) {
- var result = parse("function f() { return " + data + ";}");
- assert.deepEqual(result.functions.f.body, expect);
+ let exp = bolt.parseExpression(data);
+ assert.deepEqual(exp, expect);
+
+ exp = ast.deepCopy(exp);
+ assert.deepEqual(exp, expect);
});
});
diff --git a/src/test/permutation-test.ts b/src/test/permutation-test.ts
new file mode 100644
index 0000000..9c0e419
--- /dev/null
+++ b/src/test/permutation-test.ts
@@ -0,0 +1,91 @@
+/*
+ * Copyright 2015 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import chai = require('chai');
+var assert = chai.assert;
+import helper = require('./test-helper');
+
+import {IndexPermutation, Permutation} from '../permutation';
+
+suite("Permutations", () => {
+ suite("IndexPermutation", () => {
+ let tests = [
+ { data: [1],
+ expect: { values: [[0]], count: 1 } },
+ { data: [1, 1],
+ expect: { values: [[0]], count: 1 } },
+ { data: [2],
+ expect: { values: [[0, 1], [1, 0]], count: 2 } },
+ { data: [3],
+ expect: { values: [[0, 1, 2],
+ [0, 2, 1],
+ [1, 0, 2],
+ [1, 2, 0],
+ [2, 0, 1],
+ [2, 1, 0]],
+ count: 6 } },
+ { data: [3, 1],
+ expect: { values: [[0], [1], [2]],
+ count: 3 } },
+ { data: [3, 2],
+ expect: { values: [[0, 1],
+ [0, 2],
+ [1, 0],
+ [1, 2],
+ [2, 0],
+ [2, 1]],
+ count: 6 } },
+ ];
+
+ helper.dataDrivenTest(tests, function(data, expect) {
+ let p = new IndexPermutation(data[0], data[1]);
+ assert.equal(p.getCount(), expect.count);
+ let results = [];
+ while (p.current() !== null) {
+ results.push(p.current());
+ p.next();
+ }
+ assert.deepEqual(results, expect.values);
+ });
+ });
+
+ suite("Permutation", () => {
+ let tests = [
+ { data: { c: ['a'], k: undefined },
+ expect: { values: [['a']], count: 1 } },
+ { data: { c: ['a', 'b'], k: undefined },
+ expect: { values: [['a', 'b'], ['b', 'a']], count: 2 } },
+ { data: { c: ['a', 'b', 'c'], k: 2 },
+ expect: { values: [['a', 'b'],
+ ['a', 'c'],
+ ['b', 'a'],
+ ['b', 'c'],
+ ['c', 'a'],
+ ['c', 'b']],
+ count: 6 } },
+ ];
+
+ helper.dataDrivenTest(tests, function(data, expect) {
+ let p = new Permutation(data.c, data.k);
+ assert.equal(p.getCount(), expect.count);
+ let results = [];
+ while (p.current() !== null) {
+ results.push(p.current());
+ p.next();
+ }
+ assert.deepEqual(results, expect.values);
+ });
+ });
+});
diff --git a/src/test/test-helper.ts b/src/test/test-helper.ts
index 5b669a2..c59553e 100644
--- a/src/test/test-helper.ts
+++ b/src/test/test-helper.ts
@@ -51,7 +51,7 @@ export function dataDrivenTest(tests, testIt, formatter?) {
data = util.extend({}, data);
delete data.expect;
}
- expect = tests[i].expect || tests[i][1];
+ expect = firstDefined(tests[i].expect, tests[i][1]);
label = tests[i].label;
if (label === undefined) {
if (expect !== undefined) {
@@ -97,3 +97,12 @@ export function expFormat(x) {
}
return JSON.stringify(x);
}
+
+function firstDefined(...args: any[]) {
+ for (let i = 0; i < args.length; i++) {
+ if (args[i] !== undefined) {
+ return args[i];
+ }
+ }
+ return undefined;
+}
diff --git a/src/test/util-test.ts b/src/test/util-test.ts
index 31604eb..bf3c0a2 100644
--- a/src/test/util-test.ts
+++ b/src/test/util-test.ts
@@ -20,7 +20,7 @@ import helper = require('./test-helper');
var util = require('../util');
suite("Util", function() {
- suite("pruneEmptyChildren", function() {
+ suite("pruneEmptyChildren", () => {
function T() {
this.x = 'dummy';
}
@@ -36,9 +36,25 @@ suite("Util", function() {
[ {a: {a: {a: {}, b: 1}}}, {a: {a: {b: 1}}} ],
];
- helper.dataDrivenTest(tests, function(data, expect) {
+ helper.dataDrivenTest(tests, (data, expect) => {
util.pruneEmptyChildren(data);
assert.deepEqual(data, expect);
});
});
+
+ suite("commonPrefix", () => {
+ var tests = [
+ [ ["abc", "acd"], "a" ],
+ [ ["abc", "def"], "" ],
+ [ ["", "abc"], "" ],
+ [ [[1, 2, 3], [1, 3, 4]], [1] ],
+ [ [[1, 2, 3], [5, 3, 4]], [] ],
+ [ [[], [5, 3, 4]], [] ],
+ ];
+
+ helper.dataDrivenTest(tests, (data, expect) => {
+ let result = util.commonPrefix(data[0], data[1]);
+ assert.deepEqual(result, expect);
+ });
+ });
});
diff --git a/src/util.ts b/src/util.ts
index 30d4b62..0d2e9d9 100644
--- a/src/util.ts
+++ b/src/util.ts
@@ -246,7 +246,7 @@ export function formatColumns(indent: number, lines: string[][]): string[] {
return result;
}
-function repeatString(s: string, n: number): string {
+export function repeatString(s: string, n: number): string {
return new Array(n + 1).join(s);
}
@@ -257,3 +257,48 @@ function fillString(s: string, n: number): string {
}
return s;
}
+
+// String or Array conform
+// TODO: Yuck! Replace with polymorphic this when upgrade to TS 1.7.
+export interface ArrayLike> {
+ length: number;
+ slice: (start: number, end: number) => T;
+}
+
+export function commonPrefix>(s1: T, s2: T): T {
+ let last = commonPrefixSize(s1, s2);
+ return s1.slice(0, last);
+}
+
+export function isPrefix>(prefix: T, other: T): boolean {
+ return commonPrefixSize(prefix, other) === prefix.length;
+}
+
+export function commonPrefixSize>(s1: T, s2: T): number {
+ let last = Math.min(s1.length, s2.length);
+ for (let i = 0; i < last; i++) {
+ if (s1[i] !== s2[i]) {
+ return i;
+ }
+ }
+ return last;
+}
+
+// An object that looks like a function (has apply() function over
+// values of type T).
+export interface Functor {
+ apply(t: T): T;
+}
+
+// Combine a sequence of Functors into one.
+export class MultiFunctor implements Functor {
+ constructor(public funcs: Functor[]) {
+ }
+
+ apply(t: T): T {
+ this.funcs.forEach((f) => {
+ t = f.apply(t);
+ });
+ return t;
+ }
+}
diff --git a/tools/compare-sample b/tools/compare-sample
index 02532e6..5836da3 100755
--- a/tools/compare-sample
+++ b/tools/compare-sample
@@ -4,4 +4,11 @@
set -e
cd $PROJ_DIR/samples
+echo "Compare $1.bolt => $1.json:"
diff <(firebase-bolt < $1.bolt | flatten-json.py) <(flatten-json.py < $1.json)
+
+firebase-bolt migrate $1.json --output ../tmp/$1.bolt
+firebase-bolt ../tmp/$1.bolt
+
+echo -e "\nCompare decoded $.json roundtrip:"
+diff <(flatten-json.py < $1.json) <(flatten-json.py < ../tmp/$1.json)
diff --git a/tslint.json b/tslint.json
index 5ea7358..5145b1a 100644
--- a/tslint.json
+++ b/tslint.json
@@ -4,7 +4,7 @@
"comment-format": [true, "check-space"],
"curly": true,
"eofline": true,
- "forin": true,
+ "forin": false,
"indent": [true, "spaces"],
"label-position": true,
"label-undefined": true,