diff options
| author | toasted-nutbread <toasted-nutbread@users.noreply.github.com> | 2020-03-15 22:23:32 -0400 | 
|---|---|---|
| committer | GitHub <noreply@github.com> | 2020-03-15 22:23:32 -0400 | 
| commit | 7c5b64f9a43e9d2ca0c6d38cb5089be04c4c00df (patch) | |
| tree | ed0274170739a3a45a07f0ad524ec4a92b38db0e | |
| parent | 6182b53142cb99d7f52cf99a3c25bb4e688ba447 (diff) | |
| parent | 4b699a6b46869d5766f331d863aea38374ece50a (diff) | |
Merge pull request #406 from toasted-nutbread/object-property-accessor
Object property accessor
| -rw-r--r-- | ext/mixed/js/object-property-accessor.js | 244 | ||||
| -rw-r--r-- | package.json | 2 | ||||
| -rw-r--r-- | test/test-object-property-accessor.js | 289 | 
3 files changed, 534 insertions, 1 deletions
| diff --git a/ext/mixed/js/object-property-accessor.js b/ext/mixed/js/object-property-accessor.js new file mode 100644 index 00000000..108afc0d --- /dev/null +++ b/ext/mixed/js/object-property-accessor.js @@ -0,0 +1,244 @@ +/* + * Copyright (C) 2016-2020  Alex Yatskov <alex@foosoft.net> + * Author: Alex Yatskov <alex@foosoft.net> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program.  If not, see <https://www.gnu.org/licenses/>. + */ + +/** + * Class used to get and set generic properties of an object by using path strings. + */ +class ObjectPropertyAccessor { +    constructor(target, setter=null) { +        this._target = target; +        this._setter = (typeof setter === 'function' ? setter : null); +    } + +    getProperty(pathArray, pathLength) { +        let target = this._target; +        const ii = typeof pathLength === 'number' ? Math.min(pathArray.length, pathLength) : pathArray.length; +        for (let i = 0; i < ii; ++i) { +            const key = pathArray[i]; +            if (!ObjectPropertyAccessor.hasProperty(target, key)) { +                throw new Error(`Invalid path: ${ObjectPropertyAccessor.getPathString(pathArray.slice(0, i + 1))}`); +            } +            target = target[key]; +        } +        return target; +    } + +    setProperty(pathArray, value) { +        if (pathArray.length === 0) { +            throw new Error('Invalid path'); +        } + +        const target = this.getProperty(pathArray, pathArray.length - 1); +        const key = pathArray[pathArray.length - 1]; +        if (!ObjectPropertyAccessor.isValidPropertyType(target, key)) { +            throw new Error(`Invalid path: ${ObjectPropertyAccessor.getPathString(pathArray)}`); +        } + +        if (this._setter !== null) { +            this._setter(target, key, value, pathArray); +        } else { +            target[key] = value; +        } +    } + +    static getPathString(pathArray) { +        const regexShort = /^[a-zA-Z_][a-zA-Z0-9_]*$/; +        let pathString = ''; +        let first = true; +        for (let part of pathArray) { +            switch (typeof part) { +                case 'number': +                    if (Math.floor(part) !== part || part < 0) { +                        throw new Error('Invalid index'); +                    } +                    part = `[${part}]`; +                    break; +                case 'string': +                    if (!regexShort.test(part)) { +                        const escapedPart = part.replace(/["\\]/g, '\\$&'); +                        part = `["${escapedPart}"]`; +                    } else { +                        if (!first) { +                            part = `.${part}`; +                        } +                    } +                    break; +                default: +                    throw new Error(`Invalid type: ${typeof part}`); +            } +            pathString += part; +            first = false; +        } +        return pathString; +    } + +    static getPathArray(pathString) { +        const pathArray = []; +        let state = 'empty'; +        let quote = 0; +        let value = ''; +        let escaped = false; +        for (const c of pathString) { +            const v = c.codePointAt(0); +            switch (state) { +                case 'empty': // Empty +                case 'id-start': // Expecting identifier start +                    if (v === 0x5b) { // '[' +                        if (state === 'id-start') { +                            throw new Error(`Unexpected character: ${c}`); +                        } +                        state = 'open-bracket'; +                    } else if ( +                        (v >= 0x41 && v <= 0x5a) || // ['A', 'Z'] +                        (v >= 0x61 && v <= 0x7a) || // ['a', 'z'] +                        v === 0x5f // '_' +                    ) { +                        state = 'id'; +                        value += c; +                    } else { +                        throw new Error(`Unexpected character: ${c}`); +                    } +                    break; +                case 'id': // Identifier +                    if ( +                        (v >= 0x41 && v <= 0x5a) || // ['A', 'Z'] +                        (v >= 0x61 && v <= 0x7a) || // ['a', 'z'] +                        (v >= 0x30 && v <= 0x39) || // ['0', '9'] +                        v === 0x5f // '_' +                    ) { +                        value += c; +                    } else if (v === 0x5b) { // '[' +                        pathArray.push(value); +                        value = ''; +                        state = 'open-bracket'; +                    } else if (v === 0x2e) { // '.' +                        pathArray.push(value); +                        value = ''; +                        state = 'id-start'; +                    } else { +                        throw new Error(`Unexpected character: ${c}`); +                    } +                    break; +                case 'open-bracket': // Open bracket +                    if (v === 0x22 || v === 0x27) { // '"' or '\'' +                        quote = v; +                        state = 'string'; +                    } else if (v >= 0x30 && v <= 0x39) { // ['0', '9'] +                        state = 'number'; +                        value += c; +                    } else { +                        throw new Error(`Unexpected character: ${c}`); +                    } +                    break; +                case 'string': // Quoted string +                    if (escaped) { +                        value += c; +                        escaped = false; +                    } else if (v === 0x5c) { // '\\' +                        escaped = true; +                    } else if (v !== quote) { +                        value += c; +                    } else { +                        state = 'close-bracket'; +                    } +                    break; +                case 'number': // Number +                    if (v >= 0x30 && v <= 0x39) { // ['0', '9'] +                        value += c; +                    } else if (v === 0x5d) { // ']' +                        pathArray.push(Number.parseInt(value, 10)); +                        value = ''; +                        state = 'next'; +                    } else { +                        throw new Error(`Unexpected character: ${c}`); +                    } +                    break; +                case 'close-bracket': // Expecting closing bracket after quoted string +                    if (v === 0x5d) { // ']' +                        pathArray.push(value); +                        value = ''; +                        state = 'next'; +                    } else { +                        throw new Error(`Unexpected character: ${c}`); +                    } +                    break; +                case 'next': // Expecting . or [ +                    if (v === 0x5b) { // '[' +                        state = 'open-bracket'; +                    } else if (v === 0x2e) { // '.' +                        state = 'id-start'; +                    } else { +                        throw new Error(`Unexpected character: ${c}`); +                    } +                    break; +            } +        } +        switch (state) { +            case 'empty': +            case 'next': +                break; +            case 'id': +                pathArray.push(value); +                value = ''; +                break; +            default: +                throw new Error('Path not terminated correctly'); +        } +        return pathArray; +    } + +    static hasProperty(object, property) { +        switch (typeof property) { +            case 'string': +                return ( +                    typeof object === 'object' && +                    object !== null && +                    !Array.isArray(object) && +                    Object.prototype.hasOwnProperty.call(object, property) +                ); +            case 'number': +                return ( +                    Array.isArray(object) && +                    property >= 0 && +                    property < object.length && +                    property === Math.floor(property) +                ); +            default: +                return false; +        } +    } + +    static isValidPropertyType(object, property) { +        switch (typeof property) { +            case 'string': +                return ( +                    typeof object === 'object' && +                    object !== null && +                    !Array.isArray(object) +                ); +            case 'number': +                return ( +                    Array.isArray(object) && +                    property >= 0 && +                    property === Math.floor(property) +                ); +            default: +                return false; +        } +    } +} diff --git a/package.json b/package.json index eb449ea9..23f0eb25 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@      "scripts": {          "test": "npm run test-lint && npm run test-code",          "test-lint": "eslint . && node ./test/lint/global-declarations.js", -        "test-code": "node ./test/test-schema.js && node ./test/test-dictionary.js && node ./test/test-database.js && node ./test/test-document.js" +        "test-code": "node ./test/test-schema.js && node ./test/test-dictionary.js && node ./test/test-database.js && node ./test/test-document.js && node ./test/test-object-property-accessor.js"      },      "repository": {          "type": "git", diff --git a/test/test-object-property-accessor.js b/test/test-object-property-accessor.js new file mode 100644 index 00000000..47d2e451 --- /dev/null +++ b/test/test-object-property-accessor.js @@ -0,0 +1,289 @@ +/* + * Copyright (C) 2020  Alex Yatskov <alex@foosoft.net> + * Author: Alex Yatskov <alex@foosoft.net> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program.  If not, see <https://www.gnu.org/licenses/>. + */ + +const assert = require('assert'); +const {VM} = require('./yomichan-vm'); + +const vm = new VM({}); +vm.execute('mixed/js/object-property-accessor.js'); +const ObjectPropertyAccessor = vm.get('ObjectPropertyAccessor'); + + +function createTestObject() { +    return { +        0: null, +        value1: { +            value2: {}, +            value3: [], +            value4: null +        }, +        value5: [ +            {}, +            [], +            null +        ] +    }; +} + + +function testGetProperty1() { +    const object = createTestObject(); +    const accessor = new ObjectPropertyAccessor(object); + +    const data = [ +        [[], object], +        [['0'], object['0']], +        [['value1'], object.value1], +        [['value1', 'value2'], object.value1.value2], +        [['value1', 'value3'], object.value1.value3], +        [['value1', 'value4'], object.value1.value4], +        [['value5'], object.value5], +        [['value5', 0], object.value5[0]], +        [['value5', 1], object.value5[1]], +        [['value5', 2], object.value5[2]] +    ]; + +    for (const [pathArray, expected] of data) { +        assert.strictEqual(accessor.getProperty(pathArray), expected); +    } +} + +function testGetProperty2() { +    const object = createTestObject(); +    const accessor = new ObjectPropertyAccessor(object); + +    const data = [ +        [[0], 'Invalid path: [0]'], +        [['0', 'invalid'], 'Invalid path: ["0"].invalid'], +        [['invalid'], 'Invalid path: invalid'], +        [['value1', 'invalid'], 'Invalid path: value1.invalid'], +        [['value1', 'value2', 'invalid'], 'Invalid path: value1.value2.invalid'], +        [['value1', 'value2', 0], 'Invalid path: value1.value2[0]'], +        [['value1', 'value3', 'invalid'], 'Invalid path: value1.value3.invalid'], +        [['value1', 'value3', 0], 'Invalid path: value1.value3[0]'], +        [['value1', 'value4', 'invalid'], 'Invalid path: value1.value4.invalid'], +        [['value1', 'value4', 0], 'Invalid path: value1.value4[0]'], +        [['value5', 'length'], 'Invalid path: value5.length'], +        [['value5', 0, 'invalid'], 'Invalid path: value5[0].invalid'], +        [['value5', 0, 0], 'Invalid path: value5[0][0]'], +        [['value5', 1, 'invalid'], 'Invalid path: value5[1].invalid'], +        [['value5', 1, 0], 'Invalid path: value5[1][0]'], +        [['value5', 2, 'invalid'], 'Invalid path: value5[2].invalid'], +        [['value5', 2, 0], 'Invalid path: value5[2][0]'], +        [['value5', 2, 0, 'invalid'], 'Invalid path: value5[2][0]'], +        [['value5', 2.5], 'Invalid index'] +    ]; + +    for (const [pathArray, message] of data) { +        assert.throws(() => accessor.getProperty(pathArray), {message}); +    } +} + + +function testSetProperty1() { +    const object = createTestObject(); +    const accessor = new ObjectPropertyAccessor(object); + +    const testValue = {}; +    const data = [ +        ['0'], +        ['value1', 'value2'], +        ['value1', 'value3'], +        ['value1', 'value4'], +        ['value1'], +        ['value5', 0], +        ['value5', 1], +        ['value5', 2], +        ['value5'] +    ]; + +    for (const pathArray of data) { +        accessor.setProperty(pathArray, testValue); +        assert.strictEqual(accessor.getProperty(pathArray), testValue); +    } +} + +function testSetProperty2() { +    const object = createTestObject(); +    const accessor = new ObjectPropertyAccessor(object); + +    const testValue = {}; +    const data = [ +        [[0], 'Invalid path: [0]'], +        [['0', 'invalid'], 'Invalid path: ["0"].invalid'], +        [['value1', 'value2', 0], 'Invalid path: value1.value2[0]'], +        [['value1', 'value3', 'invalid'], 'Invalid path: value1.value3.invalid'], +        [['value1', 'value4', 'invalid'], 'Invalid path: value1.value4.invalid'], +        [['value1', 'value4', 0], 'Invalid path: value1.value4[0]'], +        [['value5', 1, 'invalid'], 'Invalid path: value5[1].invalid'], +        [['value5', 2, 'invalid'], 'Invalid path: value5[2].invalid'], +        [['value5', 2, 0], 'Invalid path: value5[2][0]'], +        [['value5', 2, 0, 'invalid'], 'Invalid path: value5[2][0]'], +        [['value5', 2.5], 'Invalid index'] +    ]; + +    for (const [pathArray, message] of data) { +        assert.throws(() => accessor.setProperty(pathArray, testValue), {message}); +    } +} + + +function testGetPathString1() { +    const data = [ +        [[], ''], +        [[0], '[0]'], +        [['escape\\'], '["escape\\\\"]'], +        [['\'quote\''], '["\'quote\'"]'], +        [['"quote"'], '["\\"quote\\""]'], +        [['part1', 'part2'], 'part1.part2'], +        [['part1', 'part2', 3], 'part1.part2[3]'], +        [['part1', 'part2', '3'], 'part1.part2["3"]'], +        [['part1', 'part2', '3part'], 'part1.part2["3part"]'], +        [['part1', 'part2', '3part', 'part4'], 'part1.part2["3part"].part4'], +        [['part1', 'part2', '3part', '4part'], 'part1.part2["3part"]["4part"]'] +    ]; + +    for (const [pathArray, expected] of data) { +        assert.strictEqual(ObjectPropertyAccessor.getPathString(pathArray), expected); +    } +} + +function testGetPathString2() { +    const data = [ +        [[1.5], 'Invalid index'], +        [[null], 'Invalid type: object'] +    ]; + +    for (const [pathArray, message] of data) { +        assert.throws(() => ObjectPropertyAccessor.getPathString(pathArray), {message}); +    } +} + + +function testGetPathArray1() { +    const data = [ +        ['', []], +        ['[0]', [0]], +        ['["escape\\\\"]', ['escape\\']], +        ['["\'quote\'"]', ['\'quote\'']], +        ['["\\"quote\\""]', ['"quote"']], +        ['part1.part2', ['part1', 'part2']], +        ['part1.part2[3]', ['part1', 'part2', 3]], +        ['part1.part2["3"]', ['part1', 'part2', '3']], +        ['part1.part2[\'3\']', ['part1', 'part2', '3']], +        ['part1.part2["3part"]', ['part1', 'part2', '3part']], +        ['part1.part2[\'3part\']', ['part1', 'part2', '3part']], +        ['part1.part2["3part"].part4', ['part1', 'part2', '3part', 'part4']], +        ['part1.part2[\'3part\'].part4', ['part1', 'part2', '3part', 'part4']], +        ['part1.part2["3part"]["4part"]', ['part1', 'part2', '3part', '4part']], +        ['part1.part2[\'3part\'][\'4part\']', ['part1', 'part2', '3part', '4part']] +    ]; + +    for (const [pathString, expected] of data) { +        vm.assert.deepStrictEqual(ObjectPropertyAccessor.getPathArray(pathString), expected); +    } +} + +function testGetPathArray2() { +    const data = [ +        ['?', 'Unexpected character: ?'], +        ['.', 'Unexpected character: .'], +        ['0', 'Unexpected character: 0'], +        ['part1.[0]', 'Unexpected character: ['], +        ['part1?', 'Unexpected character: ?'], +        ['[part1]', 'Unexpected character: p'], +        ['[0a]', 'Unexpected character: a'], +        ['["part1"x]', 'Unexpected character: x'], +        ['[\'part1\'x]', 'Unexpected character: x'], +        ['["part1"]x', 'Unexpected character: x'], +        ['[\'part1\']x', 'Unexpected character: x'], +        ['part1..part2', 'Unexpected character: .'], + +        ['[', 'Path not terminated correctly'], +        ['part1.', 'Path not terminated correctly'], +        ['part1[', 'Path not terminated correctly'], +        ['part1["', 'Path not terminated correctly'], +        ['part1[\'', 'Path not terminated correctly'], +        ['part1[""', 'Path not terminated correctly'], +        ['part1[\'\'', 'Path not terminated correctly'], +        ['part1[0', 'Path not terminated correctly'], +        ['part1[0].', 'Path not terminated correctly'] +    ]; + +    for (const [pathString, message] of data) { +        assert.throws(() => ObjectPropertyAccessor.getPathArray(pathString), {message}); +    } +} + + +function testHasProperty() { +    const data = [ +        [{}, 'invalid', false], +        [{}, 0, false], +        [{valid: 0}, 'valid', true], +        [{null: 0}, null, false], +        [[], 'invalid', false], +        [[], 0, false], +        [[0], 0, true], +        [[0], null, false], +        ['string', 0, false], +        ['string', 'length', false], +        ['string', null, false] +    ]; + +    for (const [object, property, expected] of data) { +        assert.strictEqual(ObjectPropertyAccessor.hasProperty(object, property), expected); +    } +} + +function testIsValidPropertyType() { +    const data = [ +        [{}, 'invalid', true], +        [{}, 0, false], +        [{valid: 0}, 'valid', true], +        [{null: 0}, null, false], +        [[], 'invalid', false], +        [[], 0, true], +        [[0], 0, true], +        [[0], null, false], +        ['string', 0, false], +        ['string', 'length', false], +        ['string', null, false] +    ]; + +    for (const [object, property, expected] of data) { +        assert.strictEqual(ObjectPropertyAccessor.isValidPropertyType(object, property), expected); +    } +} + + +function main() { +    testGetProperty1(); +    testGetProperty2(); +    testSetProperty1(); +    testSetProperty2(); +    testGetPathString1(); +    testGetPathString2(); +    testGetPathArray1(); +    testGetPathArray2(); +    testHasProperty(); +    testIsValidPropertyType(); +} + + +if (require.main === module) { main(); } |