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,