├── .npmignore ├── src ├── index.ts ├── shared.ts ├── index.umd.ts ├── select │ ├── limit.ts │ ├── orderby.ts │ ├── groupby.ts │ ├── where.ts │ └── index.ts ├── sql-parser │ ├── index.d.ts │ └── firesql.pegjs ├── utils.ts ├── firesql.ts └── rx │ └── index.ts ├── tslint.json ├── config ├── firebase.json ├── firestore.rules └── firestore.indexes.json ├── pegjs.config.json ├── .gitignore ├── jest.config.js ├── .opensource └── project.json ├── .editorconfig ├── test ├── README.md ├── query.test.ts ├── helpers │ └── utils.ts ├── select │ ├── rx-select.test.ts │ ├── select.test.ts │ └── where.test.ts ├── firesql.test.ts └── rxquery.test.ts ├── tools ├── tests │ ├── emulator │ │ ├── serve.js │ │ ├── demo.js │ │ └── index.ts │ ├── mute-warning.ts │ ├── task-list.ts │ ├── load-test-data.ts │ ├── schema.json │ ├── test-setup.ts │ └── data.json ├── tsconfig.json ├── utils.ts ├── gh-pages-publish.ts ├── run-tests.ts ├── semantic-release-prepare.ts └── prepare-release.ts ├── tsconfig.json ├── LICENSE ├── rollup.config.js ├── package.json └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | stuff/ 2 | tools/ 3 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './firesql'; 2 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint-config-standard", "tslint-config-prettier"], 3 | "semicolon": [true, "always"] 4 | } 5 | -------------------------------------------------------------------------------- /config/firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "firestore": { 3 | "rules": "firestore.rules", 4 | "indexes": "firestore.indexes.json" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/shared.ts: -------------------------------------------------------------------------------- 1 | export interface FireSQLOptions { 2 | includeId?: boolean | string; 3 | } 4 | 5 | export interface QueryOptions extends FireSQLOptions {} 6 | -------------------------------------------------------------------------------- /pegjs.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "input": "src/sql-parser/firesql.pegjs", 3 | "output": "src/sql-parser/index.js", 4 | "format": "commonjs", 5 | "cache": false 6 | } 7 | -------------------------------------------------------------------------------- /src/index.umd.ts: -------------------------------------------------------------------------------- 1 | export * from './firesql'; 2 | import './rx'; 3 | 4 | // Polyfills 5 | import 'core-js/features/array/includes'; 6 | import 'core-js/features/number/is-nan'; 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | *.log 4 | .vscode 5 | .idea 6 | out/ 7 | release/ 8 | stuff/ 9 | .rpt2_cache 10 | config/test.config.json 11 | src/sql-parser/original.pegjs 12 | src/sql-parser/index.js 13 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | '.ts': 'ts-jest' 4 | }, 5 | testEnvironment: 'node', 6 | testRegex: '(/__tests__/.*|\\.(test|spec))\\.ts$', 7 | moduleFileExtensions: ['ts', 'js'], 8 | }; 9 | -------------------------------------------------------------------------------- /.opensource/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "FireSQL", 3 | "platforms": ["Node", "Web"], 4 | "content": "README.md", 5 | "related": ["firebase/firebase-js-sdk"], 6 | "pages": { 7 | "test/README.md": "Tests" 8 | }, 9 | "tabs": [] 10 | } 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | #root = true 2 | 3 | [*] 4 | indent_style = space 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | max_line_length = 100 10 | indent_size = 2 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /test/README.md: -------------------------------------------------------------------------------- 1 | ## Setting up the test environment 2 | 3 | 1. Run `yarn test:setup` 4 | 2. Choose the project you want to use for these tests 5 | 3. Read the on-screen information carefully, and then confirm to proceed. 6 | 7 | ## Launching the tests 8 | 9 | Once your test environment is set up you can launch the test suite by running `yarn test` -------------------------------------------------------------------------------- /tools/tests/emulator/serve.js: -------------------------------------------------------------------------------- 1 | const firebaseTools = require('firebase-tools'); 2 | 3 | let serving = firebaseTools.serve({ 4 | only: 'firestore' 5 | }); 6 | 7 | process.on('SIGINT', () => cleanExit('SIGINT')); 8 | process.on('SIGTERM', () => cleanExit('SIGTERM')); 9 | 10 | const cleanExit = async function(signal) { 11 | await serving; 12 | process.exit(); 13 | }; 14 | -------------------------------------------------------------------------------- /config/firestore.rules: -------------------------------------------------------------------------------- 1 | service cloud.firestore { 2 | match /databases/{database}/documents { 3 | match /nonExistantCollection/{foo} { 4 | allow read, write: if true; 5 | } 6 | 7 | match /shops/{shopId} { 8 | allow read, write: if true; 9 | 10 | match /products/{productId} { 11 | allow read, write: if true; 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tools/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "target": "es5", 5 | "module": "commonjs", 6 | "lib": ["es2015", "es2016", "es2017", "dom"], 7 | "strict": true, 8 | "noImplicitAny": true, 9 | "strictNullChecks": true, 10 | "allowSyntheticDefaultImports": true, 11 | "experimentalDecorators": true, 12 | "emitDecoratorMetadata": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tools/utils.ts: -------------------------------------------------------------------------------- 1 | export function loadJSONFile(fileName: string): { [k: string]: any } | null { 2 | let data: { [k: string]: any } | null = null; 3 | 4 | try { 5 | data = require(fileName); 6 | } catch (err) { 7 | // console.log( 8 | // chalk.bgRed.bold(' ERROR ') + ` Couldn't load file ${fileName}` 9 | // ); 10 | console.log(`{bold {bgRed ERROR } Couldn't load file ${fileName}}`); 11 | } 12 | 13 | return data; 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "target": "es5", 5 | "module": "es2015", 6 | "lib": ["es2015", "es2016", "es2017", "dom"], 7 | "strict": true, 8 | "sourceMap": true, 9 | "declaration": true, 10 | "noImplicitAny": true, 11 | "strictNullChecks": true, 12 | "allowSyntheticDefaultImports": true, 13 | "experimentalDecorators": true, 14 | "emitDecoratorMetadata": true, 15 | "outDir": "out" 16 | }, 17 | "include": ["src"] 18 | } 19 | -------------------------------------------------------------------------------- /tools/tests/mute-warning.ts: -------------------------------------------------------------------------------- 1 | const stderrWrite = process.stderr._write; 2 | 3 | export function muteDeprecationWarning() { 4 | process.stderr._write = function(chunk, encoding, callback) { 5 | const regex = /DeprecationWarning: grpc.load:/; 6 | 7 | if (regex.test(chunk)) { 8 | callback(); 9 | } else { 10 | stderrWrite.apply(this, (arguments as unknown) as [ 11 | any, 12 | string, 13 | Function 14 | ]); 15 | } 16 | }; 17 | 18 | return function unmute() { 19 | process.stderr._write = stderrWrite; 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /src/select/limit.ts: -------------------------------------------------------------------------------- 1 | import { assert, astValueToNative } from '../utils'; 2 | import { SQL_Value } from '../sql-parser'; 3 | 4 | export function applyLimit( 5 | queries: firebase.firestore.Query[], 6 | astLimit: SQL_Value 7 | ): firebase.firestore.Query[] { 8 | assert( 9 | astLimit.type === 'number', 10 | "LIMIT has to be a number." 11 | ); 12 | const limit = astValueToNative(astLimit) as number; 13 | return queries.map(query => query.limit(limit)); 14 | } 15 | 16 | export function applyLimitLocally( 17 | docs: firebase.firestore.DocumentData[], 18 | astLimit: SQL_Value 19 | ): firebase.firestore.DocumentData[] { 20 | const limit = astValueToNative(astLimit) as number; 21 | docs.splice(limit); 22 | return docs; 23 | } 24 | -------------------------------------------------------------------------------- /tools/gh-pages-publish.ts: -------------------------------------------------------------------------------- 1 | const { cd, exec, echo, touch } = require("shelljs") 2 | const { readFileSync } = require("fs") 3 | const url = require("url") 4 | 5 | let repoUrl 6 | let pkg = JSON.parse(readFileSync("package.json") as any) 7 | if (typeof pkg.repository === "object") { 8 | if (!pkg.repository.hasOwnProperty("url")) { 9 | throw new Error("URL does not exist in repository section") 10 | } 11 | repoUrl = pkg.repository.url 12 | } else { 13 | repoUrl = pkg.repository 14 | } 15 | 16 | let parsedUrl = url.parse(repoUrl) 17 | let repository = (parsedUrl.host || "") + (parsedUrl.path || "") 18 | let ghToken = process.env.GH_TOKEN 19 | 20 | echo("Deploying docs!!!") 21 | cd("docs") 22 | touch(".nojekyll") 23 | exec("git init") 24 | exec("git add .") 25 | exec('git config user.name "Josep Sayol"') 26 | exec('git config user.email "josep.sayol@gmail.com"') 27 | exec('git commit -m "docs(docs): update gh-pages"') 28 | exec( 29 | `git push --force --quiet "https://${ghToken}@${repository}" master:gh-pages` 30 | ) 31 | echo("Docs deployed!!") 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 Josep Sayol 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /tools/tests/task-list.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | const logUpdate = require('log-update'); 3 | 4 | const frames = ['\u2014', '\\', '|', '/']; 5 | 6 | let task = ''; 7 | let interval: any = null; 8 | 9 | export function showTask(str: string): { done: Function } { 10 | if (interval || task.length > 0) { 11 | done(); 12 | } 13 | 14 | let i = 1; 15 | task = str; 16 | 17 | logUpdate(`${frames[0]} ${task} ...`); 18 | 19 | interval = setInterval(() => { 20 | const frame = frames[i++ % frames.length]; 21 | logUpdate(chalk`{bold {grey ${frame}}} ${task} ...`); 22 | }, 80); 23 | 24 | return { done }; 25 | } 26 | 27 | function done(result?: string) { 28 | clearInterval(interval); 29 | 30 | if (task.length > 0) { 31 | let log = chalk`{green {bold \u2713}} ${task}`; 32 | 33 | if (result !== void 0) { 34 | log += chalk` {grey ${result}}`; 35 | } 36 | 37 | logUpdate(log); 38 | task = ''; 39 | } 40 | 41 | logUpdate.done(); 42 | interval = null; 43 | } 44 | 45 | export function cleanTasks() { 46 | logUpdate.done(); 47 | } 48 | -------------------------------------------------------------------------------- /tools/tests/load-test-data.ts: -------------------------------------------------------------------------------- 1 | import * as firebase from 'firebase'; 2 | 3 | export async function loadTestDataset( 4 | ref: firebase.firestore.DocumentReference, 5 | data: TestCollection[] 6 | ): Promise { 7 | if (!Array.isArray(data)) { 8 | throw new Error('Test data needs to be an array of collections.'); 9 | } 10 | 11 | for (let collection of data) { 12 | await loadCollection(ref, collection); 13 | } 14 | } 15 | 16 | function loadCollection( 17 | docRef: firebase.firestore.DocumentReference, 18 | col: TestCollection 19 | ): Promise { 20 | const colRef = docRef.collection(col.collection); 21 | return Promise.all(col.docs.map(doc => loadDocument(colRef, doc))); 22 | } 23 | 24 | function loadDocument( 25 | colRef: firebase.firestore.CollectionReference, 26 | doc: TestDocument 27 | ): Promise { 28 | const docRef = colRef.doc(doc.key); 29 | 30 | return Promise.all([ 31 | docRef.set(doc.data), 32 | ...(doc.collections || []).map(col => loadCollection(docRef, col)) 33 | ]); 34 | } 35 | 36 | export interface TestCollection { 37 | collection: string; 38 | docs: TestDocument[]; 39 | } 40 | 41 | export interface TestDocument { 42 | key: string; 43 | data: { [k: string]: any }; 44 | collections: TestCollection[]; 45 | } 46 | -------------------------------------------------------------------------------- /config/firestore.indexes.json: -------------------------------------------------------------------------------- 1 | { 2 | "indexes": [ 3 | { 4 | "collectionGroup": "shops", 5 | "queryScope": "COLLECTION", 6 | "fields": [ 7 | { 8 | "fieldPath": "category", 9 | "order": "ASCENDING" 10 | }, 11 | { 12 | "fieldPath": "rating", 13 | "order": "ASCENDING" 14 | } 15 | ] 16 | }, 17 | { 18 | "collectionGroup": "shops", 19 | "queryScope": "COLLECTION", 20 | "fields": [ 21 | { 22 | "fieldPath": "contact.state", 23 | "order": "ASCENDING" 24 | }, 25 | { 26 | "fieldPath": "rating", 27 | "order": "ASCENDING" 28 | } 29 | ] 30 | } 31 | ], 32 | "fieldOverrides": [ 33 | { 34 | "collectionGroup": "products", 35 | "fieldPath": "price", 36 | "indexes": [ 37 | { 38 | "order": "ASCENDING", 39 | "queryScope": "COLLECTION" 40 | }, 41 | { 42 | "order": "DESCENDING", 43 | "queryScope": "COLLECTION" 44 | }, 45 | { 46 | "arrayConfig": "CONTAINS", 47 | "queryScope": "COLLECTION" 48 | }, 49 | { 50 | "order": "ASCENDING", 51 | "queryScope": "COLLECTION_GROUP" 52 | } 53 | ] 54 | } 55 | ] 56 | } 57 | -------------------------------------------------------------------------------- /tools/run-tests.ts: -------------------------------------------------------------------------------- 1 | import { resolve as resolvePath } from 'path'; 2 | import * as jest from 'jest-cli'; 3 | 4 | const testConfigFile = resolvePath(__dirname, '../config/test.config.json'); 5 | 6 | const readConfig = new Promise(resolve => { 7 | let config; 8 | 9 | try { 10 | config = require(testConfigFile); 11 | resolve(config); 12 | } catch (err) { 13 | // Config file doesn't exist yet, lets run the test setup script 14 | process.env['FIRESQL_TEST_SETUP_FROM_RUN'] = 'yes'; 15 | require('./tests/test-setup.ts') 16 | .default.then(() => { 17 | try { 18 | config = require(testConfigFile); 19 | resolve(config); 20 | } catch (err2) { 21 | // Ok, they really don't want to setup the test environment 🤷 22 | resolve(); 23 | } 24 | }) 25 | .catch(() => { 26 | // Nothing to do, I guess 27 | }); 28 | } 29 | }); 30 | 31 | readConfig 32 | .then(async (config?: any) => { 33 | if (config) { 34 | if (config.type === 'local') { 35 | require('./tests/emulator'); 36 | } else { 37 | await jest.run([ 38 | '--verbose', 39 | '--config', 40 | resolvePath(__dirname, '../jest.config.js'), 41 | '--rootDir', 42 | resolvePath(__dirname, '../') 43 | ]); 44 | } 45 | } 46 | }) 47 | .catch(() => { /* The promise never rejects */ }); 48 | -------------------------------------------------------------------------------- /tools/tests/emulator/demo.js: -------------------------------------------------------------------------------- 1 | // demo.js 2 | 3 | if (!process.send) { 4 | parent(); 5 | } else { 6 | child(); 7 | } 8 | 9 | function parent() { 10 | const { fork } = require('child_process'); 11 | 12 | console.log('Starting emulator.'); 13 | const proc = fork('demo.js', [], { 14 | stdio: ['inherit', 'pipe', 'pipe', 'ipc'] 15 | }); 16 | 17 | proc.stdout.on('data', async data => { 18 | const stdout = data.toString().trim(); 19 | console.log('[SERVE-LOG]', stdout); 20 | 21 | if (/^API endpoint: /.test(stdout)) { 22 | console.log('Emulator is running.'); 23 | 24 | /** Run tests with @firebase/testing using the emulator ... **/ 25 | await runTests(); 26 | 27 | /** ... and then kill it when we're done **/ 28 | console.log('Done testing, closing the emulator.'); 29 | proc.kill('SIGINT'); 30 | } 31 | }); 32 | 33 | proc.stderr.on('data', async data => { 34 | const stderr = data.toString().trim(); 35 | console.log('[SERVE-ERR]', stderr); 36 | }); 37 | 38 | proc.on('exit', message => { 39 | console.log('Emulator closed.'); 40 | }); 41 | } 42 | 43 | function child() { 44 | const firebaseTools = require('firebase-tools'); 45 | const serving = firebaseTools.serve({ 46 | only: 'firestore' 47 | }); 48 | 49 | process.on('SIGINT', async () => { 50 | // Wait for serve to finish 51 | await serving; 52 | 53 | // And exit 54 | process.exit(); 55 | }); 56 | } 57 | 58 | async function runTests() { 59 | console.log('Testing 1, 2, 3.'); 60 | } 61 | -------------------------------------------------------------------------------- /src/select/orderby.ts: -------------------------------------------------------------------------------- 1 | import { assert } from '../utils'; 2 | 3 | export function applyOrderBy( 4 | queries: firebase.firestore.Query[], 5 | astOrderBy: any[] 6 | ): firebase.firestore.Query[] { 7 | astOrderBy.forEach(orderBy => { 8 | assert( 9 | orderBy.expr.type === 'column_ref', 10 | 'ORDER BY only supports ordering by field names.' 11 | ); 12 | 13 | queries = queries.map(query => 14 | query.orderBy(orderBy.expr.column, orderBy.type.toLowerCase()) 15 | ); 16 | }); 17 | 18 | return queries; 19 | } 20 | 21 | export function applyOrderByLocally( 22 | docs: firebase.firestore.DocumentData[], 23 | astOrderBy: any[] 24 | ): firebase.firestore.DocumentData[] { 25 | return docs.sort((doc1, doc2) => { 26 | return astOrderBy.reduce((result, orderBy) => { 27 | if (result !== 0) { 28 | // We already found a way to sort these 2 documents, so there's 29 | // no need to keep going. This doesn't actually break out of the 30 | // reducer but prevents doing any further unnecessary and 31 | // potentially expensive comparisons. 32 | return result; 33 | } 34 | 35 | const field = orderBy.expr.column; 36 | 37 | if (doc1[field] < doc2[field]) { 38 | result = -1; 39 | } else if (doc1[field] > doc2[field]) { 40 | result = 1; 41 | } else { 42 | result = 0; 43 | } 44 | 45 | if (orderBy.type === 'DESC' && result !== 0) { 46 | result = -result; 47 | } 48 | 49 | return result; 50 | }, 0); 51 | }); 52 | } 53 | -------------------------------------------------------------------------------- /tools/semantic-release-prepare.ts: -------------------------------------------------------------------------------- 1 | const path = require("path") 2 | const { fork } = require("child_process") 3 | const colors = require("colors") 4 | 5 | const { readFileSync, writeFileSync } = require("fs") 6 | const pkg = JSON.parse( 7 | readFileSync(path.resolve(__dirname, "..", "package.json")) 8 | ) 9 | 10 | pkg.scripts.prepush = "npm run test:prod && npm run build" 11 | pkg.scripts.commitmsg = "commitlint -E GIT_PARAMS" 12 | 13 | writeFileSync( 14 | path.resolve(__dirname, "..", "package.json"), 15 | JSON.stringify(pkg, null, 2) 16 | ) 17 | 18 | // Call husky to set up the hooks 19 | fork(path.resolve(__dirname, "..", "node_modules", "husky", "bin", "install")) 20 | 21 | console.log() 22 | console.log(colors.green("Done!!")) 23 | console.log() 24 | 25 | if (pkg.repository.url.trim()) { 26 | console.log(colors.cyan("Now run:")) 27 | console.log(colors.cyan(" npm install -g semantic-release-cli")) 28 | console.log(colors.cyan(" semantic-release-cli setup")) 29 | console.log() 30 | console.log( 31 | colors.cyan('Important! Answer NO to "Generate travis.yml" question') 32 | ) 33 | console.log() 34 | console.log( 35 | colors.gray( 36 | 'Note: Make sure "repository.url" in your package.json is correct before' 37 | ) 38 | ) 39 | } else { 40 | console.log( 41 | colors.red( 42 | 'First you need to set the "repository.url" property in package.json' 43 | ) 44 | ) 45 | console.log(colors.cyan("Then run:")) 46 | console.log(colors.cyan(" npm install -g semantic-release-cli")) 47 | console.log(colors.cyan(" semantic-release-cli setup")) 48 | console.log() 49 | console.log( 50 | colors.cyan('Important! Answer NO to "Generate travis.yml" question') 51 | ) 52 | } 53 | 54 | console.log() 55 | -------------------------------------------------------------------------------- /test/query.test.ts: -------------------------------------------------------------------------------- 1 | import { FireSQL } from '../src/firesql'; 2 | import { initFirestore } from './helpers/utils'; 3 | 4 | let firestore: firebase.firestore.Firestore; 5 | let fireSQL: FireSQL; 6 | 7 | beforeAll(() => { 8 | firestore = initFirestore(); 9 | fireSQL = new FireSQL(firestore); 10 | }); 11 | 12 | describe('Method query()', () => { 13 | it('returns a Promise', () => { 14 | const returnValue = fireSQL.query('SELECT * FROM nonExistantCollection'); 15 | // tslint:disable-next-line: no-floating-promises 16 | expect(returnValue).toBeInstanceOf(Promise); 17 | }); 18 | 19 | it('expects one non-empty string argument', async () => { 20 | expect.assertions(3); 21 | 22 | try { 23 | await (fireSQL as any).query(); 24 | } catch (err) { 25 | expect(err).not.toBeUndefined(); 26 | } 27 | 28 | try { 29 | await (fireSQL as any).query(''); 30 | } catch (err) { 31 | expect(err).not.toBeUndefined(); 32 | } 33 | 34 | try { 35 | await (fireSQL as any).query(42); 36 | } catch (err) { 37 | expect(err).not.toBeUndefined(); 38 | } 39 | }); 40 | 41 | it('accepts options as second argument', async () => { 42 | expect.assertions(1); 43 | 44 | const returnValue = fireSQL.query('SELECT * FROM nonExistantCollection', { 45 | includeId: true 46 | }); 47 | 48 | // tslint:disable-next-line: no-floating-promises 49 | expect(returnValue).toBeInstanceOf(Promise); 50 | 51 | try { 52 | await returnValue; 53 | } catch (err) { 54 | // We're testing that query() doesn't throw, so 55 | // this assertion shouldn't be reached. 56 | expect(true).toBe(false); 57 | } 58 | }); 59 | 60 | it('throws when SQL has syntax errors', async () => { 61 | expect.assertions(2); 62 | 63 | try { 64 | await fireSQL.query('not a valid query'); 65 | } catch (err) { 66 | expect(err).toBeInstanceOf(Error); 67 | expect(err).toHaveProperty('name', 'SyntaxError'); 68 | } 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /tools/prepare-release.ts: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const fs = require('fs-extra'); 3 | const pkg = require('../package.json'); 4 | const rimraf = require('rimraf'); 5 | 6 | function filePath(file: string): string { 7 | return path.resolve(__dirname, '..', file); 8 | } 9 | 10 | rimraf.sync('./release'); 11 | 12 | fs.copySync(filePath('README.md'), filePath('release/README.md')); 13 | fs.copySync(filePath('LICENSE'), filePath('release/LICENSE')); 14 | fs.copySync(filePath('out'), filePath('release')); 15 | // fs.copySync(filePath('out/types'), filePath('release/types')); 16 | 17 | // fs.copySync( 18 | // filePath('out/firesql.es5.js'), 19 | // filePath('release/firesql.es5.js') 20 | // ); 21 | 22 | // fs.copySync( 23 | // filePath('out/firesql.es5.js.map'), 24 | // filePath('release/firesql.es5.js.map') 25 | // ); 26 | 27 | fs.copySync( 28 | filePath('out/firesql.umd.js'), 29 | filePath('release/firesql.umd.js') 30 | ); 31 | 32 | fs.copySync( 33 | filePath('out/firesql.umd.js.map'), 34 | filePath('release/firesql.umd.js.map') 35 | ); 36 | 37 | fs.copySync( 38 | filePath('src/sql-parser/index.js'), 39 | filePath('release/sql-parser/index.js') 40 | ); 41 | 42 | fs.copySync( 43 | filePath('src/sql-parser/index.d.ts'), 44 | filePath('release/types/sql-parser/index.d.ts') 45 | ); 46 | 47 | const newPkg = { 48 | name: 'firesql', 49 | version: pkg.version, 50 | description: pkg.description, 51 | keywords: pkg.keywords, 52 | main: pkg.main.replace(/^out\//, ''), 53 | // module: pkg.module.replace(/^out\//, ''), 54 | typings: pkg.typings.replace(/^out\//, ''), 55 | author: pkg.author, 56 | repository: pkg.repository, 57 | license: pkg.license, 58 | engines: pkg.engines, 59 | scripts: pkg.scripts, 60 | dependencies: pkg.dependencies, 61 | peerDependencies: pkg.peerDependencies 62 | }; 63 | 64 | fs.writeFileSync( 65 | filePath('release/package.json'), 66 | JSON.stringify(newPkg, null, 2) 67 | ); 68 | 69 | console.log(`Prepared release: ${newPkg.version}`); 70 | console.log('\n'); 71 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from 'rollup-plugin-node-resolve'; 2 | import commonjs from 'rollup-plugin-commonjs'; 3 | import sourceMaps from 'rollup-plugin-sourcemaps'; 4 | import camelCase from 'lodash.camelcase'; 5 | import typescript from 'rollup-plugin-typescript2'; 6 | import { uglify } from 'rollup-plugin-uglify'; 7 | 8 | const pkg = require('./package.json'); 9 | 10 | const libraryName = 'firesql'; 11 | 12 | const createConfig = ({ umd = false, input, output, external } = {}) => ({ 13 | input, 14 | output, 15 | external: [ 16 | ...Object.keys(umd ? {} : pkg.dependencies || {}), 17 | ...Object.keys(pkg.peerDependencies || {}), 18 | 'firebase/app', 19 | 'firebase/firestore', 20 | 'rxjs/operators', 21 | 'rxfire/firestore', 22 | ...(external || []) 23 | ], 24 | watch: { 25 | include: 'src/**' 26 | }, 27 | plugins: [ 28 | // Compile TypeScript files 29 | typescript({ useTsconfigDeclarationDir: true }), 30 | 31 | // Allow node_modules resolution, so you can use 'external' to control 32 | // which external modules to include in the bundle 33 | // https://github.com/rollup/rollup-plugin-node-resolve#usage 34 | resolve(), 35 | 36 | // Allow bundling cjs modules (unlike webpack, rollup doesn't understand cjs) 37 | commonjs({ extensions: ['.js', '.jsx'] }), 38 | 39 | // Uglify UMD bundle for smaller size 40 | umd && 41 | uglify({ 42 | mangle: { 43 | properties: { 44 | keep_quoted: true, 45 | regex: /_$|^_/ 46 | } 47 | }, 48 | compress: { 49 | passes: 3 50 | } 51 | }), 52 | 53 | // Resolve source maps to the original source 54 | sourceMaps() 55 | ] 56 | }); 57 | 58 | export default [ 59 | // createConfig({ 60 | // input: 'src/index.ts', 61 | // output: { file: pkg.module, format: 'es', sourcemap: true } 62 | // }), 63 | createConfig({ 64 | input: 'src/index.umd.ts', 65 | umd: true, 66 | output: { 67 | file: 'out/firesql.umd.js', 68 | format: 'umd', 69 | name: camelCase(libraryName), 70 | globals: { 71 | 'firebase/app': 'firebase', 72 | 'firebase/firestore': 'firebase', 73 | 'rxjs': '*', 74 | 'rxjs/operators': '*', 75 | 'rxfire/firestore': '*', 76 | }, 77 | sourcemap: true, 78 | footer: 79 | 'var FireSQL = (typeof firesql !== "undefined") && firesql.FireSQL;' 80 | } 81 | }) 82 | ]; 83 | -------------------------------------------------------------------------------- /src/select/groupby.ts: -------------------------------------------------------------------------------- 1 | import { SQL_GroupBy } from '../sql-parser'; 2 | import { assert, safeGet, contains, DocumentData, ValueOf } from '../utils'; 3 | 4 | export function applyGroupByLocally( 5 | documents: DocumentData[], 6 | astGroupBy: SQL_GroupBy[] 7 | ): GroupedDocuments { 8 | assert(astGroupBy.length > 0, 'GROUP BY needs at least 1 group.'); 9 | 10 | let group: ValueOf = new DocumentsGroup(); 11 | group.documents = documents; 12 | 13 | astGroupBy.forEach(groupBy => { 14 | assert( 15 | groupBy.type === 'column_ref', 16 | 'GROUP BY only supports grouping by field names.' 17 | ); 18 | group = applySingleGroupBy(group, groupBy); 19 | }); 20 | 21 | return (group as any) as GroupedDocuments; 22 | } 23 | 24 | function applySingleGroupBy( 25 | documents: ValueOf, 26 | groupBy: SQL_GroupBy 27 | ): GroupedDocuments { 28 | const groupedDocs: GroupedDocuments = {}; 29 | 30 | if (documents instanceof DocumentsGroup) { 31 | // We just have a list of documents 32 | const numDocs = documents.documents.length; 33 | 34 | for (let i = 0; i < numDocs; i++) { 35 | const doc = documents.documents[i]; 36 | 37 | // Since we're going to use the value as an object key, always 38 | // coherce it to a string in case it's some other type. 39 | const groupValue = String(safeGet(doc, groupBy.column)); 40 | 41 | if (!contains(groupedDocs, groupValue)) { 42 | groupedDocs[groupValue] = new DocumentsGroup(); 43 | } 44 | 45 | (groupedDocs[groupValue] as DocumentsGroup).documents.push(doc); 46 | } 47 | 48 | return groupedDocs; 49 | } else { 50 | // We have documents that have already been grouped with another field 51 | const currentGroups = Object.keys(documents); 52 | currentGroups.forEach(group => { 53 | groupedDocs[group] = applySingleGroupBy(documents[group], groupBy); 54 | }); 55 | return groupedDocs; 56 | } 57 | } 58 | 59 | export class DocumentsGroup { 60 | documents: DocumentData[] = []; 61 | aggr: GroupAggregateValues = { 62 | sum: {}, 63 | avg: {}, 64 | min: {}, 65 | max: {}, 66 | total: {} 67 | }; 68 | 69 | constructor(public key?: string) {} 70 | } 71 | 72 | export interface GroupedDocuments { 73 | [key: string]: GroupedDocuments | DocumentsGroup; 74 | } 75 | 76 | export interface GroupAggregateValues { 77 | sum: { [k: string]: number }; 78 | avg: { [k: string]: number }; 79 | min: { [k: string]: number | string }; 80 | max: { [k: string]: number | string }; 81 | total: { [k: string]: number }; 82 | } 83 | -------------------------------------------------------------------------------- /src/sql-parser/index.d.ts: -------------------------------------------------------------------------------- 1 | export declare class SyntaxError extends Error { 2 | constructor( 3 | message: string, 4 | expected: string, 5 | found: string, 6 | offset: number, 7 | line: number, 8 | column: number 9 | ); 10 | name: 'SyntaxError'; 11 | message: string; 12 | expected: string; 13 | found: string; 14 | offset: number; 15 | line: number; 16 | column: number; 17 | } 18 | 19 | export interface SQL_ValueBool { 20 | type: 'bool'; 21 | value: boolean; 22 | } 23 | 24 | export interface SQL_ValueNumber { 25 | type: 'number'; 26 | value: number | string; // Sometimes the number is in a string *shrug* 27 | } 28 | 29 | export interface SQL_ValueString { 30 | type: 'string'; 31 | value: string; 32 | } 33 | 34 | export interface SQL_ValueNull { 35 | type: 'null'; 36 | value: null; 37 | } 38 | 39 | export type SQL_Value = 40 | | SQL_ValueBool 41 | | SQL_ValueNumber 42 | | SQL_ValueString 43 | | SQL_ValueNull; 44 | 45 | export interface SQL_BinaryExpression { 46 | type: 'binary_expr'; 47 | operator: 48 | | '=' 49 | | '<' 50 | | '<=' 51 | | '>' 52 | | '>=' 53 | | 'IS' 54 | | 'IN' 55 | | 'AND' 56 | | 'OR' 57 | | 'NOT' 58 | | 'LIKE' 59 | | 'BETWEEN' 60 | | 'CONTAINS' 61 | | 'NOT CONTAINS'; 62 | left: SQL_Expression; 63 | right: SQL_Expression; 64 | } 65 | 66 | type SQL_Expression = ( 67 | | SQL_BinaryExpression 68 | | SQL_ColumnRef 69 | | SQL_Value 70 | | SQL_AggrFunction) & { 71 | paren: void | true; 72 | }; 73 | 74 | export interface SQL_Select { 75 | type: 'select'; 76 | distinct: 'DISTINCT' | null; 77 | columns: SQL_SelectColumn[] | '*'; 78 | from: SQL_SelectFrom; 79 | where: SQL_Expression; 80 | groupby: SQL_GroupBy[]; 81 | orderby: SQL_OrderBy[]; 82 | limit: SQL_Value; 83 | params: any[]; 84 | _next: SQL_AST; 85 | } 86 | 87 | export interface SQL_SelectColumn { 88 | expr: SQL_Expression; 89 | as: string | null; 90 | } 91 | 92 | export interface SQL_ColumnRef { 93 | type: 'column_ref'; 94 | table: string; 95 | column: string; 96 | } 97 | 98 | export interface SQL_AggrFunction { 99 | type: 'aggr_func'; 100 | name: 'SUM' | 'MIN' | 'MAX' | 'AVG' | 'COUNT'; 101 | field: string; 102 | } 103 | 104 | export interface SQL_SelectFrom { 105 | parts: string[]; 106 | as: string | null; 107 | group: boolean; 108 | } 109 | 110 | export type SQL_GroupBy = SQL_ColumnRef; 111 | 112 | export interface SQL_OrderBy { 113 | expr: SQL_Expression; 114 | type: 'ASC' | 'DESC'; 115 | } 116 | 117 | export type SQL_AST = SQL_Select; // TODO: add INSERT, DELETE, etc. 118 | 119 | export function parse(input: string): SQL_AST; 120 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { SQL_Value, SQL_AggrFunction } from './sql-parser'; 2 | 3 | export type DocumentData = { [field: string]: any }; 4 | 5 | export type ValueOf = T[keyof T]; 6 | 7 | export const DOCUMENT_KEY_NAME = '__name__'; 8 | 9 | export function assert(condition: boolean, message: string) { 10 | if (!condition) { 11 | throw new Error(message); 12 | } 13 | } 14 | 15 | export function contains(obj: object, prop: string): boolean { 16 | return Object.prototype.hasOwnProperty.call(obj, prop); 17 | } 18 | 19 | export function safeGet(obj: any, prop: string): any { 20 | if (contains(obj, prop)) return obj[prop]; 21 | } 22 | 23 | export function deepGet(obj: any, path: string): any { 24 | let value = obj; 25 | const props = path.split('.'); 26 | 27 | props.some(prop => { 28 | value = safeGet(value, prop); 29 | 30 | // By using "some" instead of "forEach", we can return 31 | // true whenever we want to break out of the loop. 32 | return typeof value === void 0; 33 | }); 34 | 35 | return value; 36 | } 37 | 38 | export function astValueToNative( 39 | astValue: SQL_Value 40 | ): boolean | string | number | null { 41 | let value: boolean | string | number | null; 42 | 43 | switch (astValue.type) { 44 | case 'bool': 45 | case 'null': 46 | case 'string': 47 | value = astValue.value; 48 | break; 49 | case 'number': 50 | value = Number(astValue.value); 51 | break; 52 | default: 53 | throw new Error('Unsupported value type in WHERE clause.'); 54 | } 55 | 56 | return value; 57 | } 58 | /** 59 | * Adapted from: https://github.com/firebase/firebase-ios-sdk/blob/14dd9dc2704038c3bf702426439683cee4dc941a/Firestore/core/src/firebase/firestore/util/string_util.cc#L23-L40 60 | */ 61 | export function prefixSuccessor(prefix: string): string { 62 | // We can increment the last character in the string and be done 63 | // unless that character is 255 (0xff), in which case we have to erase the 64 | // last character and increment the previous character, unless that 65 | // is 255, etc. If the string is empty or consists entirely of 66 | // 255's, we just return the empty string. 67 | let limit = prefix; 68 | while (limit.length > 0) { 69 | const index = limit.length - 1; 70 | if (limit[index] === '\xff') { 71 | limit = limit.slice(0, -1); 72 | } else { 73 | limit = 74 | limit.substr(0, index) + 75 | String.fromCharCode(limit.charCodeAt(index) + 1); 76 | break; 77 | } 78 | } 79 | return limit; 80 | } 81 | 82 | export function nameOrAlias( 83 | name: string, 84 | alias: string | null, 85 | aggrFn?: SQL_AggrFunction 86 | ): string { 87 | if (alias !== null && alias.length > 0) { 88 | return alias; 89 | } 90 | 91 | if (!aggrFn) { 92 | return name; 93 | } 94 | 95 | return `${aggrFn.name}(${name})`; 96 | } 97 | -------------------------------------------------------------------------------- /test/helpers/utils.ts: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase/app'; 2 | import 'firebase/firestore'; 3 | 4 | let firestore: firebase.firestore.Firestore; 5 | 6 | export function initFirestore(): firebase.firestore.Firestore { 7 | if (firestore) { 8 | return firestore; 9 | } 10 | 11 | const emulatorProjectId = process.env['FIRESQL_TEST_PROJECT_ID']; 12 | 13 | if (typeof emulatorProjectId === 'string') { 14 | // Using the local emulator 15 | const emulatorHost = process.env['FIRESQL_TEST_EMULATOR_HOST']; 16 | const app = firebase.initializeApp({ 17 | projectId: emulatorProjectId 18 | }); 19 | 20 | firestore = app.firestore(); 21 | firestore.settings({ 22 | host: emulatorHost, 23 | ssl: false 24 | }); 25 | } else { 26 | try { 27 | firestore = firebase.app().firestore(); 28 | } catch (err) { 29 | const { project } = require('../../config/test.config.json'); 30 | const app = firebase.initializeApp(project); 31 | firestore = app.firestore(); 32 | } 33 | } 34 | 35 | return firestore; 36 | } 37 | 38 | 39 | /* 40 | import admin from 'firebase-admin'; 41 | import firebase from 'firebase/app'; 42 | import 'firebase/firestore'; 43 | 44 | let firestore: firebase.firestore.Firestore; 45 | // let adminFirestore: admin.firestore.Firestore; 46 | 47 | export function initFirestore(): firebase.firestore.Firestore { 48 | if (firestore) { 49 | return firestore; 50 | } 51 | 52 | firestore = _initFirestore(firebase); 53 | 54 | return firestore; 55 | } 56 | 57 | // export function initAdminFirestore(): admin.firestore.Firestore { 58 | // if (adminFirestore) { 59 | // return adminFirestore; 60 | // } 61 | 62 | // adminFirestore = _initFirestore(admin); 63 | 64 | // return adminFirestore; 65 | // } 66 | 67 | function _initFirestore< 68 | T extends firebase.firestore.Firestore | admin.firestore.Firestore 69 | >(namespace: typeof firebase | typeof admin): T { 70 | const emulatorProjectId = process.env['FIRESQL_TEST_PROJECT_ID']; 71 | let firestoreObject: firebase.firestore.Firestore; 72 | 73 | if (typeof emulatorProjectId === 'string') { 74 | // Using the local emulator 75 | const emulatorHost = process.env['FIRESQL_TEST_EMULATOR_HOST']; 76 | const app = (namespace as typeof firebase).initializeApp({ 77 | projectId: emulatorProjectId 78 | }); 79 | 80 | firestoreObject = app.firestore(); 81 | firestoreObject.settings({ 82 | host: emulatorHost, 83 | ssl: false 84 | }); 85 | } else { 86 | try { 87 | firestoreObject = (namespace as typeof firebase).firestore(); 88 | } catch (err) { 89 | const { project } = require('../../config/test.config.json'); 90 | const app = (namespace as typeof firebase).initializeApp(project); 91 | firestoreObject = app.firestore(); 92 | } 93 | } 94 | 95 | return firestoreObject as T; 96 | } 97 | */ -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "firesql-src", 3 | "version": "2.0.1", 4 | "description": "Query Cloud Firestore for Firebase using SQL", 5 | "keywords": [], 6 | "main": "out/firesql.js", 7 | "typings": "out/types/index.d.ts", 8 | "author": "Josep Sayol ", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/jsayol/FireSQL" 12 | }, 13 | "license": "MIT", 14 | "engines": { 15 | "node": ">=8.0.0" 16 | }, 17 | "scripts": { 18 | "lint": "tslint --project tsconfig.json -t codeFrame 'src/**/*.ts' 'test/**/*.ts'", 19 | "prebuild": "yarn run pegjs && rimraf out", 20 | "build": "tsc --module commonjs && rollup -c rollup.config.js", 21 | "start": "rollup -c rollup.config.js -w", 22 | "test": "ts-node --project tools/tsconfig.json tools/run-tests.ts", 23 | "test:emulator": "ts-node --project tools/tsconfig.json tools/tests/emulator/index.ts", 24 | "test:watch": "jest --verbose --watch", 25 | "test:prod": "yarn run lint && yarn run test --coverage --no-cache", 26 | "test:setup": "ts-node --project tools/tsconfig.json tools/tests/test-setup.ts", 27 | "ts-node": "ts-node", 28 | "release:prepare": "yarn run build && ts-node tools/prepare-release.ts", 29 | "npm:publish": "cd release && npm publish", 30 | "pegjs": "pegjs --extra-options-file pegjs.config.json" 31 | }, 32 | "prettier": { 33 | "semi": true, 34 | "singleQuote": true, 35 | "printWidth": 80 36 | }, 37 | "devDependencies": { 38 | "@firebase/testing": "^0.5.3", 39 | "@types/core-js": "^2.5.0", 40 | "@types/jest": "^23.3.9", 41 | "@types/jest-cli": "^23.6.0", 42 | "@types/node": "^10.11.0", 43 | "chalk": "^2.4.1", 44 | "colors": "^1.3.2", 45 | "cross-env": "^5.2.0", 46 | "firebase": "^6.0.0", 47 | "firebase-tools": "^6.1.0", 48 | "fs-extra": "^7.0.1", 49 | "inquirer": "^6.2.0", 50 | "jest": "^23.6.0", 51 | "jest-config": "^23.6.0", 52 | "lodash.camelcase": "^4.3.0", 53 | "log-update": "^2.3.0", 54 | "pegjs": "0.11.0-master.30f3260", 55 | "prettier": "^1.14.3", 56 | "prompt": "^1.0.0", 57 | "prompt-input": "^3.0.0", 58 | "rimraf": "^2.6.2", 59 | "rollup": "^0.66.2", 60 | "rollup-plugin-commonjs": "^9.1.8", 61 | "rollup-plugin-node-resolve": "^3.4.0", 62 | "rollup-plugin-sourcemaps": "^0.4.2", 63 | "rollup-plugin-typescript2": "^0.17.0", 64 | "rollup-plugin-uglify": "^6.0.0", 65 | "rxfire": "^3.0.10", 66 | "rxjs": "^6.3.3", 67 | "ts-jest": "^23.10.2", 68 | "ts-node": "^7.0.1", 69 | "tslint": "^5.11.0", 70 | "tslint-config-prettier": "^1.15.0", 71 | "tslint-config-standard": "^8.0.1", 72 | "typescript": "^3.0.3" 73 | }, 74 | "dependencies": { 75 | "core-js": "^3.0.1" 76 | }, 77 | "peerDependencies": { 78 | "firebase": "*", 79 | "rxfire": "*", 80 | "rxjs": "*" 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /test/select/rx-select.test.ts: -------------------------------------------------------------------------------- 1 | // import admin from 'firebase-admin'; 2 | import { FireSQL } from '../../src/firesql'; 3 | import { initFirestore, /*, initAdminFirestore*/ } from '../helpers/utils'; 4 | import { first } from 'rxjs/operators'; 5 | import '../../src/rx'; 6 | 7 | // let adminFirestore: admin.firestore.Firestore; 8 | let firestore: firebase.firestore.Firestore; 9 | let fireSQL: FireSQL; 10 | 11 | beforeAll(() => { 12 | // adminFirestore = initAdminFirestore(); 13 | firestore = initFirestore(); 14 | fireSQL = new FireSQL(firestore); 15 | }); 16 | 17 | describe('rxQuery() SELECT', () => { 18 | it('filters duplicate documents from combined queries', done => { 19 | expect.assertions(3); 20 | 21 | let doneSteps = 0; 22 | const checkDone = () => { 23 | if (++doneSteps === 3) { 24 | done(); 25 | } 26 | }; 27 | 28 | const query1$ = fireSQL.rxQuery(` 29 | SELECT * 30 | FROM shops 31 | WHERE category = 'Toys' 32 | `); 33 | 34 | query1$.pipe(first()).subscribe(docs => { 35 | expect(docs).toHaveLength(3); 36 | checkDone(); 37 | }); 38 | 39 | const query2$ = fireSQL.rxQuery(` 40 | SELECT * 41 | FROM shops 42 | WHERE rating > 3 43 | `); 44 | 45 | query2$.pipe(first()).subscribe(docs => { 46 | expect(docs).toHaveLength(20); 47 | checkDone(); 48 | }); 49 | 50 | const query3$ = fireSQL.rxQuery(` 51 | SELECT * 52 | FROM shops 53 | WHERE category = 'Toys' OR rating > 3 54 | `); 55 | 56 | query3$.pipe(first()).subscribe(docs => { 57 | expect(docs).toHaveLength(21); // rather than 23 (3 + 20) 58 | checkDone(); 59 | }); 60 | }); 61 | 62 | // it('returns the correct documents using firebase-admin', done => { 63 | // expect.assertions(3); 64 | 65 | // const docs$ = new FireSQL( 66 | // adminFirestore.doc('shops/2DIHCbOMkKz0YcrKUsRf6kgF') 67 | // ).rxQuery('SELECT * FROM products'); 68 | 69 | // docs$.pipe(first()).subscribe(docs => { 70 | // expect(docs).toBeInstanceOf(Array); 71 | // expect(docs).toHaveLength(4); 72 | // expect(docs).toEqual([ 73 | // { 74 | // // doc 3UXchxNEyXZ0t1URO6DrIlFZ 75 | // name: 'Juice - Lagoon Mango', 76 | // price: 488.61, 77 | // stock: 2 78 | // }, 79 | // { 80 | // // doc IO6DPA52DMRylKlOlUFkoWza 81 | // name: 'Veal - Bones', 82 | // price: 246.07, 83 | // stock: 2 84 | // }, 85 | // { 86 | // // doc NNJ7ziylrHGcejJpY9p6drqM 87 | // name: 'Juice - Apple, 500 Ml', 88 | // price: 49.75, 89 | // stock: 2 90 | // }, 91 | // { 92 | // // doc jpF9MHHfw8XyfZm2ukvfEXZK 93 | // name: 'Graham Cracker Mix', 94 | // price: 300.42, 95 | // stock: 9 96 | // } 97 | // ]); 98 | 99 | // done(); 100 | // }); 101 | // }); 102 | }); 103 | -------------------------------------------------------------------------------- /test/firesql.test.ts: -------------------------------------------------------------------------------- 1 | // import admin from 'firebase-admin'; 2 | import firebase from 'firebase/app'; 3 | import { FireSQL } from '../src/firesql'; 4 | import { initFirestore/*, initAdminFirestore*/ } from './helpers/utils'; 5 | 6 | let firestore: firebase.firestore.Firestore; 7 | // let adminFirestore: admin.firestore.Firestore; 8 | 9 | beforeAll(() => { 10 | firestore = initFirestore(); 11 | // adminFirestore = initAdminFirestore(); 12 | }); 13 | 14 | describe('FireSQL basic API', () => { 15 | it('is instantiable with Firestore', () => { 16 | expect(new FireSQL(firestore)).toBeInstanceOf(FireSQL); 17 | }); 18 | 19 | it('is instantiable with DocumentReference', () => { 20 | expect(new FireSQL(firestore.doc('testing/doc'))).toBeInstanceOf(FireSQL); 21 | }); 22 | 23 | // it('is instantiable with firebase-admin Firestore', () => { 24 | // expect(new FireSQL(adminFirestore)).toBeInstanceOf(FireSQL); 25 | // }); 26 | 27 | // it('is instantiable with firebase-admin DocumentReference', () => { 28 | // expect(new FireSQL(adminFirestore.doc('testing/doc'))).toBeInstanceOf( 29 | // FireSQL 30 | // ); 31 | // }); 32 | 33 | it('is instantiable with Firestore and options', () => { 34 | const options = { includeId: true }; 35 | const fireSQL = new FireSQL(firestore, options); 36 | 37 | expect(fireSQL).toBeInstanceOf(FireSQL); 38 | expect(fireSQL.options).toEqual(options); 39 | expect(() => fireSQL.ref).not.toThrow(); 40 | expect(fireSQL.ref.path).toBe(''); 41 | }); 42 | 43 | it('is instantiable with DocumentReference and options', () => { 44 | const options = { includeId: true }; 45 | const docRef = firestore.doc('a/b'); 46 | const fireSQL = new FireSQL(docRef, options); 47 | 48 | expect(fireSQL).toBeInstanceOf(FireSQL); 49 | expect(fireSQL.options).toEqual(options); 50 | expect(() => fireSQL.ref).not.toThrow(); 51 | expect(fireSQL.ref.path).toBe('a/b'); 52 | }); 53 | 54 | // it('is instantiable with firebase-admin Firestore and options', () => { 55 | // const options = { includeId: true }; 56 | // const fireSQL = new FireSQL(adminFirestore, options); 57 | 58 | // expect(fireSQL).toBeInstanceOf(FireSQL); 59 | // expect(fireSQL.options).toEqual(options); 60 | // expect(() => fireSQL.ref).not.toThrow(); 61 | // expect(fireSQL.ref.path).toBe(''); 62 | // }); 63 | 64 | // it('is instantiable with firebase-admin DocumentReference and options', () => { 65 | // const options = { includeId: true }; 66 | // const docRef = adminFirestore.doc('a/b'); 67 | // const fireSQL = new FireSQL(docRef, options); 68 | 69 | // expect(fireSQL).toBeInstanceOf(FireSQL); 70 | // expect(fireSQL.options).toEqual(options); 71 | // expect(() => fireSQL.ref).not.toThrow(); 72 | // expect(fireSQL.ref.path).toBe('a/b'); 73 | // }); 74 | 75 | it('has query() method', () => { 76 | expect(typeof new FireSQL(firestore).query).toBe('function'); 77 | }); 78 | 79 | it("doesn't have rxQuery() method", () => { 80 | // We haven't imported "firesql/rx" so rxQuery shouldn't exist 81 | expect((new FireSQL(firestore) as any).rxQuery).toBeUndefined(); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /test/rxquery.test.ts: -------------------------------------------------------------------------------- 1 | import { FireSQL } from '../src/firesql'; 2 | import { initFirestore } from './helpers/utils'; 3 | import { Observable } from 'rxjs'; 4 | import '../src/rx'; 5 | 6 | let firestore: firebase.firestore.Firestore; 7 | let fireSQL: FireSQL; 8 | 9 | beforeAll(() => { 10 | firestore = initFirestore(); 11 | fireSQL = new FireSQL(firestore); 12 | }); 13 | 14 | afterAll(async () => { 15 | // Cleanup 16 | await fireSQL.ref 17 | .collection('shops/p0H5osOFWCPlT1QthpXUnnzI/products') 18 | .doc('rxQueryTest') 19 | .delete() 20 | .catch(() => void 0); // We don't want any failure here to affect the tests 21 | await firestore.app.delete(); 22 | }); 23 | 24 | describe('Method rxQuery()', () => { 25 | it('FireSQL has rxQuery() method', () => { 26 | expect(typeof fireSQL.rxQuery).toBe('function'); 27 | }); 28 | 29 | it('returns an Observable', () => { 30 | const returnValue = fireSQL.rxQuery('SELECT * FROM nonExistantCollection'); 31 | expect(returnValue).toBeInstanceOf(Observable); 32 | }); 33 | 34 | it('expects one non-empty string argument', async () => { 35 | expect.assertions(3); 36 | 37 | try { 38 | (fireSQL as any).rxQuery(); 39 | } catch (err) { 40 | expect(err).toBeDefined(); 41 | } 42 | 43 | try { 44 | (fireSQL as any).rxQuery(''); 45 | } catch (err) { 46 | expect(err).toBeDefined(); 47 | } 48 | 49 | try { 50 | (fireSQL as any).rxQuery(42); 51 | } catch (err) { 52 | expect(err).toBeDefined(); 53 | } 54 | }); 55 | 56 | it('accepts options as second argument', async () => { 57 | expect.assertions(1); 58 | 59 | try { 60 | const returnValue = fireSQL.rxQuery( 61 | 'SELECT * FROM nonExistantCollection', 62 | { 63 | includeId: true 64 | } 65 | ); 66 | expect(returnValue).toBeInstanceOf(Observable); 67 | } catch (err) { 68 | // We're testing that query() doesn't throw, so 69 | // this assertion shouldn't be reached. 70 | expect(true).toBe(false); 71 | } 72 | }); 73 | 74 | it('throws when SQL has syntax errors', async () => { 75 | expect.assertions(2); 76 | 77 | try { 78 | fireSQL.rxQuery('not a valid query'); 79 | } catch (err) { 80 | expect(err).toBeInstanceOf(Error); 81 | expect(err).toHaveProperty('name', 'SyntaxError'); 82 | } 83 | }); 84 | 85 | test('Observable emits when data changes', done => { 86 | expect.assertions(2); 87 | 88 | const docRef = fireSQL.ref 89 | .collection('shops/p0H5osOFWCPlT1QthpXUnnzI/products') 90 | .doc('rxQueryTest'); 91 | const initialData = { 92 | test: 'Testing rxQuery()', 93 | value: 42 94 | }; 95 | docRef.set(initialData); 96 | 97 | let emits = 0; 98 | 99 | const query$ = new FireSQL(firestore.doc('shops/p0H5osOFWCPlT1QthpXUnnzI')) 100 | .rxQuery(` 101 | SELECT * 102 | FROM products 103 | WHERE __name__ = "rxQueryTest" 104 | `); 105 | 106 | const subsctiprion = query$.subscribe(docs => { 107 | if (emits++ === 0) { 108 | expect(docs).toEqual([initialData]); 109 | docRef.update({ rating: 123 }); 110 | } else { 111 | expect(docs).toEqual([{ ...initialData, rating: 123 }]); 112 | subsctiprion.unsubscribe(); 113 | done(); 114 | } 115 | }); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /src/firesql.ts: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase/app'; 2 | import 'firebase/firestore'; 3 | import { FireSQLOptions, QueryOptions } from './shared'; 4 | import { parse } from './sql-parser'; 5 | import { DocumentData, assert } from './utils'; 6 | import { select_ } from './select'; 7 | 8 | export class FireSQL { 9 | private _ref: firebase.firestore.DocumentReference; 10 | 11 | constructor(ref: FirestoreOrDocument, private _options: FireSQLOptions = {}) { 12 | /* 13 | We initially used `instanceof` to determine the object type, but that 14 | only allowed using the client SDK. Doing it this way we can support 15 | both the client and the admin SDKs. 16 | */ 17 | if (typeof (ref as any).doc === 'function') { 18 | // It's an instance of firebase.firestore.Firestore 19 | try { 20 | this._ref = (ref as firebase.firestore.Firestore).doc('/'); 21 | } catch (err) { 22 | // If the Firestore instance we get is from the Admin SDK, it throws 23 | // an error if we call `.doc("/")` on it. In that case we just treat 24 | // it as a firebase.firestore.DocumentReference 25 | this._ref = ref as firebase.firestore.DocumentReference; 26 | } 27 | } else if (typeof (ref as any).collection === 'function') { 28 | // It's an instance of firebase.firestore.DocumentReference 29 | this._ref = ref as firebase.firestore.DocumentReference; 30 | } else { 31 | throw new Error( 32 | 'The first parameter needs to be a Firestore object ' + 33 | ' or a Firestore document reference .' 34 | ); 35 | } 36 | } 37 | 38 | get ref(): firebase.firestore.DocumentReference { 39 | return this._ref; 40 | } 41 | 42 | get firestore(): firebase.firestore.Firestore { 43 | return this._ref.firestore; 44 | } 45 | 46 | get options(): FireSQLOptions { 47 | return this._options; 48 | } 49 | 50 | query(sql: string, options?: QueryOptions): Promise; 51 | query(sql: string, options?: QueryOptions): Promise; 52 | async query( 53 | sql: string, 54 | options: QueryOptions = {} 55 | ): Promise { 56 | assert( 57 | // tslint:disable-next-line: strict-type-predicates 58 | typeof sql === 'string' && sql.length > 0, 59 | 'query() expects a non-empty string.' 60 | ); 61 | const ast = parse(sql); 62 | 63 | if (ast.type === 'select') { 64 | return select_(this._ref, ast, { ...this._options, ...options }); 65 | } else { 66 | throw new Error( 67 | `"${(ast.type as string).toUpperCase()}" statements are not supported.` 68 | ); 69 | } 70 | } 71 | 72 | toJSON(): object { 73 | return { 74 | ref: this._ref, 75 | options: this._options 76 | }; 77 | } 78 | } 79 | 80 | export type FirestoreOrDocument = 81 | | firebase.firestore.Firestore 82 | | firebase.firestore.DocumentReference 83 | | AdminFirestore 84 | | AdminDocumentReference; 85 | 86 | /** 87 | * An interface representing the basics we need from the 88 | * admin.firestore.Firestore class. 89 | * We use it like this to avoid having to require "firebase-admin". 90 | */ 91 | interface AdminFirestore { 92 | collection(collectionPath: string): any; 93 | doc(documentPath: string): any; 94 | } 95 | 96 | /** 97 | * An interface representing the basics we need from the 98 | * admin.firestore.DocumentReference class. 99 | * We use it like this to avoid having to require "firebase-admin". 100 | */ 101 | interface AdminDocumentReference { 102 | collection(collectionPath: string): any; 103 | get(options?: any): Promise; 104 | } 105 | -------------------------------------------------------------------------------- /tools/tests/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 139970, 3 | "num_rows": 1, 4 | "file_format": "json", 5 | "name": "My Saved Schema", 6 | "array": true, 7 | "columns": [ 8 | { 9 | "name": "$collection:shops", 10 | "null_percentage": 0, 11 | "type": "JSON Array", 12 | "minItems": 50, 13 | "maxItems": 50, 14 | "formula": "" 15 | }, 16 | { 17 | "name": "$collection:shops.$id", 18 | "null_percentage": 0, 19 | "type": "Character Sequence", 20 | "format": "************************", 21 | "formula": "" 22 | }, 23 | { 24 | "name": "$collection:shops.name", 25 | "null_percentage": 0, 26 | "type": "Fake Company Name", 27 | "formula": "" 28 | }, 29 | { 30 | "name": "$collection:shops.category", 31 | "null_percentage": 5, 32 | "type": "Department (Retail)", 33 | "formula": "" 34 | }, 35 | { 36 | "name": "$collection:shops.slogan", 37 | "null_percentage": 50, 38 | "type": "Slogan", 39 | "formula": "" 40 | }, 41 | { 42 | "name": "$collection:shops.rating", 43 | "null_percentage": 0, 44 | "type": "Number", 45 | "min": 0, 46 | "max": 5, 47 | "decimals": 1, 48 | "formula": "" 49 | }, 50 | { 51 | "name": "$collection:shops.tags[0-4]", 52 | "null_percentage": 0, 53 | "type": "Buzzword", 54 | "formula": "" 55 | }, 56 | { 57 | "name": "$collection:shops.manager.name", 58 | "null_percentage": 10, 59 | "type": "Full Name", 60 | "formula": "" 61 | }, 62 | { 63 | "name": "$collection:shops.manager.phone", 64 | "null_percentage": 10, 65 | "type": "Phone", 66 | "format": "###-###-####", 67 | "formula": "" 68 | }, 69 | { 70 | "name": "$collection:shops.contact.address", 71 | "null_percentage": 0, 72 | "type": "Street Address", 73 | "formula": "" 74 | }, 75 | { 76 | "name": "$collection:shops.contact.city", 77 | "null_percentage": 0, 78 | "type": "City", 79 | "formula": "" 80 | }, 81 | { 82 | "name": "$collection:shops.contact.postal", 83 | "null_percentage": 0, 84 | "type": "Postal Code", 85 | "formula": "" 86 | }, 87 | { 88 | "name": "$collection:shops.contact.state", 89 | "null_percentage": 0, 90 | "type": "State", 91 | "onlyUSPlaces": true, 92 | "formula": "" 93 | }, 94 | { 95 | "name": "$collection:shops.$collection:products", 96 | "null_percentage": 1, 97 | "type": "JSON Array", 98 | "minItems": 0, 99 | "maxItems": 5, 100 | "formula": "" 101 | }, 102 | { 103 | "name": "$collection:shops.$collection:products.$id", 104 | "null_percentage": 0, 105 | "type": "Character Sequence", 106 | "format": "************************", 107 | "formula": "" 108 | }, 109 | { 110 | "name": "$collection:shops.$collection:products.name", 111 | "null_percentage": 0, 112 | "type": "Product (Grocery)", 113 | "formula": "" 114 | }, 115 | { 116 | "name": "$collection:shops.$collection:products.price", 117 | "null_percentage": 0, 118 | "type": "Number", 119 | "min": 0.01, 120 | "max": 500, 121 | "decimals": 2, 122 | "formula": "" 123 | }, 124 | { 125 | "name": "$collection:shops.$collection:products.stock", 126 | "null_percentage": 0, 127 | "type": "Number", 128 | "min": 0, 129 | "max": 10, 130 | "decimals": 0, 131 | "formula": "" 132 | } 133 | ] 134 | } -------------------------------------------------------------------------------- /tools/tests/emulator/index.ts: -------------------------------------------------------------------------------- 1 | import { fork, ChildProcess } from 'child_process'; 2 | import { resolve as resolvePath } from 'path'; 3 | import * as jest from 'jest-cli'; 4 | import chalk from 'chalk'; 5 | import * as firebaseTest from '@firebase/testing'; 6 | import firebase from 'firebase/app'; 7 | import 'firebase/firestore'; 8 | 9 | import { loadJSONFile } from '../../utils'; 10 | import { showTask } from '../task-list'; 11 | import { muteDeprecationWarning } from '../mute-warning'; 12 | import { loadTestDataset, TestCollection } from '../load-test-data'; 13 | 14 | const FIRESQL_TEST_PROJECT_ID = 'firesql-tests-with-emulator'; 15 | 16 | let task: ReturnType; 17 | let childProc: ChildProcess | undefined; 18 | 19 | muteDeprecationWarning(); 20 | 21 | async function onChildProcStdout(data: ReadableStream) { 22 | let emulatorRunning = false; 23 | 24 | try { 25 | const stdout = data.toString().trim(); 26 | 27 | if (/^API endpoint: /.test(stdout)) { 28 | const devServerHost = stdout.match(/^API endpoint: http:\/\/(.+)/)![1]; 29 | 30 | initFirestoreTest(devServerHost); 31 | 32 | task.done(); 33 | emulatorRunning = true; 34 | await runTests(devServerHost); 35 | 36 | task = showTask('Shutting down the emulator'); 37 | childProc!.kill('SIGINT'); 38 | } else if (emulatorRunning) { 39 | task.done(); 40 | // console.log('[EMULATOR]', stdout); 41 | } 42 | 43 | console.log('[EMULATOR]', stdout); 44 | } catch (err) { 45 | console.error(err); 46 | 47 | if (childProc) { 48 | childProc.kill('SIGINT'); 49 | } 50 | } 51 | } 52 | 53 | async function runTests(devServerHost: string) { 54 | try { 55 | const testApp = firebaseTest.initializeTestApp({ 56 | projectId: FIRESQL_TEST_PROJECT_ID 57 | }); 58 | const firestore = testApp.firestore() as any as firebase.firestore.Firestore; 59 | 60 | task = showTask('Loading test data'); 61 | await loadTestDataset(firestore.doc('/'), loadJSONFile( 62 | resolvePath(__dirname, '../data.json') 63 | ) as TestCollection[]); 64 | task.done(); 65 | 66 | process.env['FIRESQL_TEST_PROJECT_ID'] = FIRESQL_TEST_PROJECT_ID; 67 | process.env['FIRESQL_TEST_EMULATOR_HOST'] = devServerHost; 68 | 69 | console.log(chalk`{bold {grey \u231B}} Running tests ...\n`); 70 | await jest.run([ 71 | '--verbose', 72 | '--config', 73 | resolvePath(__dirname, '../../../jest.config.js'), 74 | '--rootDir', 75 | resolvePath(__dirname, '../../../') 76 | ]); 77 | } catch (err) { 78 | console.error(err); 79 | 80 | if (childProc) { 81 | childProc.kill('SIGINT'); 82 | } 83 | } 84 | } 85 | 86 | function initFirestoreTest(devServerHost: string) { 87 | const app = firebase.initializeApp({ 88 | projectId: FIRESQL_TEST_PROJECT_ID 89 | }); 90 | 91 | const firestore = app.firestore(); 92 | firestore.settings({ 93 | host: devServerHost, 94 | ssl: false 95 | }); 96 | } 97 | 98 | try { 99 | task = showTask('Starting Firestore emulator'); 100 | childProc = fork(resolvePath(__dirname, './serve.js'), [], { 101 | stdio: ['inherit', 'pipe', 'pipe', 'ipc'] 102 | }); 103 | 104 | childProc.stdout.on('data', onChildProcStdout); 105 | childProc.stderr.on('data', async data => { 106 | task.done(); 107 | const stderr = data.toString(); 108 | console.log('[EMULATOR][Error]', stderr); 109 | }); 110 | 111 | childProc.on('exit', message => { 112 | task.done(); 113 | process.exit(); 114 | }); 115 | } catch (err) { 116 | console.error(err); 117 | 118 | if (typeof childProc !== 'undefined') { 119 | childProc.kill('SIGINT'); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/rx/index.ts: -------------------------------------------------------------------------------- 1 | import { Observable, combineLatest } from 'rxjs'; 2 | import { map } from 'rxjs/operators'; 3 | import { collectionData } from 'rxfire/firestore'; 4 | import { parse, SQL_Select } from '../sql-parser'; 5 | import { FireSQL } from '../firesql'; 6 | import { SelectOperation } from '../select'; 7 | import { assert, DocumentData, contains, DOCUMENT_KEY_NAME } from '../utils'; 8 | import { QueryOptions } from '../shared'; 9 | 10 | declare module '../firesql' { 11 | interface FireSQL { 12 | rxQuery(sql: string, options?: QueryOptions): Observable; 13 | rxQuery(sql: string, options?: QueryOptions): Observable; 14 | } 15 | } 16 | 17 | FireSQL.prototype.rxQuery = function( 18 | sql: string, 19 | options?: QueryOptions 20 | ): Observable { 21 | assert( 22 | // tslint:disable-next-line: strict-type-predicates 23 | typeof sql === 'string' && sql.length > 0, 24 | 'rxQuery() expects a non-empty string.' 25 | ); 26 | const ast = parse(sql); 27 | assert(ast.type === 'select', 'Only SELECT statements are supported.'); 28 | return rxSelect((this as any)._ref, ast, { 29 | ...(this as any)._options, 30 | ...options 31 | }); 32 | }; 33 | 34 | function rxSelect( 35 | ref: firebase.firestore.DocumentReference, 36 | ast: SQL_Select, 37 | options: QueryOptions 38 | ): Observable { 39 | const selectOp = new SelectOperation(ref, ast, options); 40 | let queries = selectOp.generateQueries_(); 41 | 42 | if (ast._next) { 43 | assert( 44 | ast._next.type === 'select', 45 | ' UNION statements are only supported between SELECTs.' 46 | ); 47 | // This is the UNION of 2 SELECTs, so lets process the second 48 | // one and merge their queries 49 | queries = queries.concat(selectOp.generateQueries_(ast._next)); 50 | 51 | // FIXME: The SQL parser incorrectly attributes ORDER BY to the second 52 | // SELECT only, instead of to the whole UNION. Find a workaround. 53 | } 54 | 55 | let idField: string; 56 | let keepIdField: boolean; 57 | 58 | if (selectOp._includeId === true) { 59 | idField = DOCUMENT_KEY_NAME; 60 | keepIdField = true; 61 | } else if (typeof selectOp._includeId === 'string') { 62 | idField = selectOp._includeId; 63 | keepIdField = true; 64 | } else { 65 | idField = DOCUMENT_KEY_NAME; 66 | keepIdField = false; 67 | } 68 | 69 | const rxData = combineLatest( 70 | queries.map(query => 71 | collectionData(query, idField) 72 | ) 73 | ); 74 | 75 | return rxData.pipe( 76 | map((results: firebase.firestore.DocumentData[][]) => { 77 | // We have an array of results (one for each query we generated) where 78 | // each element is an array of documents. We need to flatten them. 79 | const documents: firebase.firestore.DocumentData[] = []; 80 | const seenDocuments: { [id: string]: true } = {}; 81 | 82 | for (const docs of results) { 83 | for (const doc of docs) { 84 | // Note: for now we're only allowing to query a single collection, but 85 | // if at any point we change that (for example with JOINs) we'll need to 86 | // use the full document path here instead of just its ID 87 | if (!contains(seenDocuments, doc[idField])) { 88 | seenDocuments[doc[idField]] = true; 89 | if (!keepIdField) { 90 | delete doc[idField]; 91 | } 92 | documents.push(doc); 93 | } 94 | } 95 | } 96 | 97 | return documents; 98 | }), 99 | map((documents: firebase.firestore.DocumentData[]) => { 100 | return selectOp.processDocuments_(queries, documents); 101 | }) 102 | ); 103 | } 104 | -------------------------------------------------------------------------------- /tools/tests/test-setup.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import { argv } from 'yargs'; 3 | import { homedir } from 'os'; 4 | import { writeFile as writeFileAsync } from 'fs'; 5 | import { promisify } from 'util'; 6 | import { resolve as resolvePath } from 'path'; 7 | import { showTask } from './task-list'; 8 | import { muteDeprecationWarning } from './mute-warning'; 9 | import { loadTestDataset, TestCollection } from './load-test-data'; 10 | import * as firebase from 'firebase'; 11 | import { loadJSONFile } from '../utils'; 12 | 13 | const firebaseTools = require('firebase-tools'); 14 | const inquirer = require('inquirer'); 15 | 16 | // Command-line arguments 17 | const cliOptions = { 18 | token: (argv.token || argv.T) as string, 19 | project: (argv.project || argv.P) as string 20 | }; 21 | 22 | let task: ReturnType; 23 | const writeFile = promisify(writeFileAsync); 24 | const fromRunTests = process.env['FIRESQL_TEST_SETUP_FROM_RUN'] === 'yes'; 25 | 26 | // We export this in case we want to "require()" from another script 27 | // and wait until it's done. 28 | export default getSetupType() 29 | .then(async setupType => { 30 | if (setupType === 'local') { 31 | return setupEmulator(); 32 | } else if (setupType === 'remote') { 33 | return setupProject(); 34 | } else { 35 | throw new Error('Unknown setup type selection.'); 36 | } 37 | }) 38 | .then((result: boolean) => { 39 | if (task) { 40 | task.done(); 41 | } 42 | if (!fromRunTests) { 43 | if (result !== false) { 44 | console.log('\nDone!'); 45 | console.log('You can now run "yarn test" to run the tests.\n'); 46 | } 47 | process.exit(); 48 | } 49 | }) 50 | .catch(err => { 51 | if (fromRunTests) { 52 | throw err; 53 | } else { 54 | console.error(err); 55 | process.exit(1); 56 | } 57 | }); 58 | 59 | async function getSetupType(): Promise { 60 | console.log(''); 61 | 62 | const { setupType } = await inquirer.prompt([ 63 | { 64 | type: 'list', 65 | name: 'setupType', 66 | message: 'What kind of test environment do you want?', 67 | choices: [ 68 | { 69 | name: chalk`Using a local Firestore emulator {grey (requires Java)}`, 70 | value: 'local' 71 | }, 72 | { 73 | name: chalk`Using the real Firestore backend`, 74 | value: 'remote' 75 | } 76 | ] 77 | } 78 | ]); 79 | 80 | return setupType; 81 | } 82 | 83 | async function writeTestConfig(config: object): Promise { 84 | // Write config to top-level config directory 85 | await writeFile( 86 | resolvePath(__dirname, '../../config/test.config.json'), 87 | JSON.stringify(config, null, 2) 88 | ); 89 | } 90 | 91 | async function setupEmulator(): Promise { 92 | task = showTask('Setting up Firestore emulator'); 93 | await writeTestConfig({ type: 'local' }); 94 | await firebaseTools.setup.emulators.firestore(); 95 | task.done(); 96 | return true; 97 | } 98 | 99 | async function setupProject(): Promise { 100 | console.log(''); 101 | task = showTask('Getting authentication token'); 102 | const token = await getToken(); 103 | task.done(); 104 | 105 | const project = await getProject(token); 106 | 107 | showWarning(project); 108 | 109 | const confirmation = await userConfirmation(); 110 | if (!confirmation) { 111 | console.log('\nYou chose not to continue. Nothing was changed.\n'); 112 | return false; 113 | } 114 | 115 | task = showTask('Downloading project configuration'); 116 | const config = await firebaseTools.setup.web({ project, token }); 117 | await writeTestConfig({ type: 'remote', project: config }); 118 | task.done(); 119 | 120 | // Deploy database rules 121 | task = showTask('Deploying Firestore indexes and security rules'); 122 | await firebaseTools.deploy({ 123 | project, 124 | token, 125 | cwd: resolvePath(__dirname, '../../config') 126 | }); 127 | 128 | // Firestore calls grpc.load() which has been deprecated and we 129 | // get an ugly warning on screen. This mutes it temporarily. 130 | const unmute = muteDeprecationWarning(); 131 | 132 | const firestore = firebase.initializeApp(config).firestore(); 133 | const rootRef = firestore.doc('/'); 134 | 135 | task = showTask('Deleting "shops" collection'); 136 | await firebaseTools.firestore.delete('/shops', { 137 | project, 138 | yes: true, 139 | recursive: true 140 | }); 141 | 142 | task = showTask('Loading test data into "shops" collection'); 143 | await loadTestDataset(rootRef, loadJSONFile( 144 | resolvePath(__dirname, './data.json') 145 | ) as TestCollection[]); 146 | 147 | unmute(); 148 | task.done(); 149 | return true; 150 | } 151 | 152 | export async function getToken(): Promise { 153 | if (cliOptions.token) { 154 | return cliOptions.token; 155 | } 156 | 157 | let cachedToken; 158 | 159 | try { 160 | const config = require(resolvePath( 161 | homedir(), 162 | '.config/configstore/firebase-tools.json' 163 | )); 164 | cachedToken = config.tokens.refresh_token; 165 | } catch (err) { 166 | /* no problem */ 167 | } 168 | 169 | if (cachedToken) { 170 | return cachedToken; 171 | } 172 | 173 | const { 174 | tokens: { refresh_token: freshToken } 175 | } = await firebaseTools.login.ci(); 176 | 177 | return freshToken; 178 | } 179 | 180 | async function getProject(token: string): Promise { 181 | if (cliOptions.project) { 182 | return cliOptions.project; 183 | } 184 | 185 | task = showTask('Retrieving list of available projects'); 186 | const projects = await firebaseTools.list({ token }); 187 | task.done(); 188 | console.log(''); 189 | 190 | const { project } = await inquirer.prompt([ 191 | { 192 | type: 'list', 193 | name: 'project', 194 | message: 'Which project would you like to use to test?', 195 | choices: projects.map((project: { [k: string]: string }) => ({ 196 | name: chalk`${project.name} {grey (${project.id})}`, 197 | value: project 198 | })) 199 | } 200 | ]); 201 | 202 | return project.id; 203 | } 204 | 205 | function showWarning(projectId: string) { 206 | console.log(''); 207 | console.log( 208 | chalk`{bold {bgRed WARNING } {red Read this very carefully!}}\n` 209 | ); 210 | console.log( 211 | chalk`{bold You are about to do the following to this project's Firestore:}` 212 | ); 213 | console.log(' \u2022 Overwrite the security rules'); 214 | console.log(' \u2022 Overwrite the indexes'); 215 | console.log(' \u2022 Delete the "shops" collection'); 216 | console.log(' \u2022 Load test data into the "shops" collection'); 217 | console.log(''); 218 | console.log(chalk`{bold {blue Project:}} ${projectId}\n`); 219 | } 220 | 221 | async function userConfirmation(): Promise { 222 | const { confirmation } = await inquirer.prompt([ 223 | { 224 | default: false, 225 | type: 'confirm', 226 | name: 'confirmation', 227 | message: 'Are you sure you want to continue?' 228 | } 229 | ]); 230 | 231 | return confirmation; 232 | } 233 | -------------------------------------------------------------------------------- /src/select/where.ts: -------------------------------------------------------------------------------- 1 | import { SQL_Value, SQL_ValueString, SQL_ValueBool } from '../sql-parser'; 2 | import { assert, prefixSuccessor, astValueToNative } from '../utils'; 3 | 4 | export function applyWhere( 5 | queries: firebase.firestore.Query[], 6 | astWhere: { [k: string]: any } 7 | ): firebase.firestore.Query[] { 8 | if (astWhere.type === 'binary_expr') { 9 | if (astWhere.operator === 'AND') { 10 | queries = applyWhere(queries, astWhere.left); 11 | queries = applyWhere(queries, astWhere.right); 12 | } else if (astWhere.operator === 'OR') { 13 | queries = [ 14 | ...applyWhere(queries, astWhere.left), 15 | ...applyWhere(queries, astWhere.right) 16 | ]; 17 | } else if (astWhere.operator === 'IN') { 18 | assert( 19 | astWhere.left.type === 'column_ref', 20 | 'Unsupported WHERE type on left side.' 21 | ); 22 | assert( 23 | astWhere.right.type === 'expr_list', 24 | 'Unsupported WHERE type on right side.' 25 | ); 26 | 27 | const newQueries: firebase.firestore.Query[] = []; 28 | astWhere.right.value.forEach((valueObj: SQL_Value) => { 29 | newQueries.push( 30 | ...applyCondition(queries, astWhere.left.column, '=', valueObj) 31 | ); 32 | }); 33 | queries = newQueries; 34 | } else if (astWhere.operator === 'LIKE') { 35 | assert( 36 | astWhere.left.type === 'column_ref', 37 | 'Unsupported WHERE type on left side.' 38 | ); 39 | assert( 40 | astWhere.right.type === 'string', 41 | 'Only strings are supported with LIKE in WHERE clause.' 42 | ); 43 | 44 | const whereLike = parseWhereLike(astWhere.right.value); 45 | 46 | if (whereLike.equals !== void 0) { 47 | queries = applyCondition( 48 | queries, 49 | astWhere.left.column, 50 | '=', 51 | whereLike.equals 52 | ); 53 | } else if (whereLike.beginsWith !== void 0) { 54 | const successorStr = prefixSuccessor(whereLike.beginsWith.value); 55 | queries = applyCondition( 56 | queries, 57 | astWhere.left.column, 58 | '>=', 59 | whereLike.beginsWith 60 | ); 61 | queries = applyCondition( 62 | queries, 63 | astWhere.left.column, 64 | '<', 65 | stringASTWhereValue(successorStr) 66 | ); 67 | } else { 68 | throw new Error( 69 | 'Only terms in the form of "value%" (string begins with value) and "value" (string equals value) are supported with LIKE in WHERE clause.' 70 | ); 71 | } 72 | } else if (astWhere.operator === 'BETWEEN') { 73 | assert( 74 | astWhere.left.type === 'column_ref', 75 | 'Unsupported WHERE type on left side.' 76 | ); 77 | assert( 78 | astWhere.right.type === 'expr_list' && 79 | astWhere.right.value.length === 2, 80 | 'BETWEEN needs 2 values in WHERE clause.' 81 | ); 82 | 83 | queries = applyCondition( 84 | queries, 85 | astWhere.left.column, 86 | '>=', 87 | astWhere.right.value[0] 88 | ); 89 | queries = applyCondition( 90 | queries, 91 | astWhere.left.column, 92 | '<=', 93 | astWhere.right.value[1] 94 | ); 95 | } else if (astWhere.operator === 'CONTAINS') { 96 | assert( 97 | astWhere.left.type === 'column_ref', 98 | 'Unsupported WHERE type on left side.' 99 | ); 100 | assert( 101 | ['string', 'number', 'bool', 'null'].includes(astWhere.right.type), 102 | 'Only strings, numbers, booleans, and null are supported with CONTAINS in WHERE clause.' 103 | ); 104 | 105 | queries = applyCondition( 106 | queries, 107 | astWhere.left.column, 108 | astWhere.operator, 109 | astWhere.right 110 | ); 111 | } else { 112 | assert( 113 | astWhere.left.type === 'column_ref', 114 | 'Unsupported WHERE type on left side.' 115 | ); 116 | 117 | queries = applyCondition( 118 | queries, 119 | astWhere.left.column, 120 | astWhere.operator, 121 | astWhere.right 122 | ); 123 | } 124 | } else if (astWhere.type === 'column_ref') { 125 | // The query is like "... WHERE column_name", so lets return 126 | // the documents where "column_name" is true. Ideally we would 127 | // include any document where "column_name" is truthy, but there's 128 | // no way to do that with Firestore. 129 | queries = queries.map(query => query.where(astWhere.column, '==', true)); 130 | } else { 131 | throw new Error('Unsupported WHERE clause'); 132 | } 133 | 134 | return queries; 135 | } 136 | 137 | function applyCondition( 138 | queries: firebase.firestore.Query[], 139 | field: string, 140 | astOperator: string, 141 | astValue: SQL_Value 142 | ): firebase.firestore.Query[] { 143 | /* 144 | TODO: Several things: 145 | 146 | - If we're applying a range condition to a query (<, <=, >, >=) 147 | we need to make sure that any other range condition on that same 148 | query is only applied to the same field. Firestore doesn't 149 | allow range conditions on several fields in the same query. 150 | 151 | - If we apply a range condition, the first .orderBy() needs to 152 | be on that same field. We should wait and only apply it if 153 | the user has requested an ORDER BY. Otherwise, they might be 154 | expecting the results ordered by document id. 155 | 156 | - Can't combine "LIKE 'value%'" and inequality filters (>, <=, ...) 157 | with AND: 158 | SELECT * FROM shops WHERE rating > 2 AND name LIKE 'T%' 159 | In theory it's only a problem when they're on the same field, 160 | but since applying those on different fields doesn't make any 161 | sense it's easier if we just disallow it in any case. 162 | It's OK if it's with an OR (not the same query): 163 | SELECT * FROM shops WHERE rating > 2 OR name LIKE 'T%' 164 | */ 165 | 166 | if (astOperator === '!=' || astOperator === '<>') { 167 | if (astValue.type === 'bool') { 168 | // If the value is a boolean, then just perform a == operation 169 | // with the negation of the value. 170 | const negValue: SQL_ValueBool = { type: 'bool', value: !astValue.value }; 171 | return applyCondition(queries, field, '=', negValue); 172 | } else { 173 | // The != operator is not supported in Firestore so we 174 | // split this query in two, one with the < operator and 175 | // another one with the > operator. 176 | return [ 177 | ...applyCondition(queries, field, '<', astValue), 178 | ...applyCondition(queries, field, '>', astValue) 179 | ]; 180 | } 181 | } else { 182 | const value = astValueToNative(astValue); 183 | const operator = whereFilterOp(astOperator); 184 | return queries.map(query => query.where(field, operator, value)); 185 | } 186 | } 187 | 188 | function whereFilterOp(op: string): firebase.firestore.WhereFilterOp { 189 | let newOp: firebase.firestore.WhereFilterOp; 190 | 191 | switch (op) { 192 | case '=': 193 | case 'IS': 194 | newOp = '=='; 195 | break; 196 | case '<': 197 | case '<=': 198 | case '>': 199 | case '>=': 200 | newOp = op; 201 | break; 202 | case 'CONTAINS': 203 | newOp = 'array-contains'; 204 | break; 205 | case 'NOT': 206 | case 'NOT CONTAINS': 207 | throw new Error('"NOT" WHERE operator unsupported'); 208 | break; 209 | default: 210 | throw new Error('Unknown WHERE operator'); 211 | } 212 | 213 | return newOp; 214 | } 215 | 216 | interface WhereLikeResult { 217 | beginsWith?: SQL_ValueString; 218 | endsWith?: SQL_ValueString; 219 | contains?: SQL_ValueString; 220 | equals?: SQL_ValueString; 221 | } 222 | 223 | function stringASTWhereValue(str: string): SQL_ValueString { 224 | return { 225 | type: 'string', 226 | value: str 227 | }; 228 | } 229 | 230 | function parseWhereLike(str: string): WhereLikeResult { 231 | const result: WhereLikeResult = {}; 232 | const strLength = str.length; 233 | 234 | if (str[0] === '%') { 235 | if (str[strLength - 1] === '%') { 236 | result.contains = stringASTWhereValue(str.substr(1, strLength - 2)); 237 | } else { 238 | result.endsWith = stringASTWhereValue(str.substring(1)); 239 | } 240 | } else if (str[strLength - 1] === '%') { 241 | result.beginsWith = stringASTWhereValue(str.substr(0, strLength - 1)); 242 | } else { 243 | result.equals = stringASTWhereValue(str); 244 | } 245 | 246 | return result; 247 | } 248 | -------------------------------------------------------------------------------- /test/select/select.test.ts: -------------------------------------------------------------------------------- 1 | // import admin from 'firebase-admin'; 2 | import { FireSQL } from '../../src/firesql'; 3 | import { initFirestore /*, initAdminFirestore*/ } from '../helpers/utils'; 4 | import { DOCUMENT_KEY_NAME } from '../../src/utils'; 5 | 6 | // let adminFirestore: admin.firestore.Firestore; 7 | let firestore: firebase.firestore.Firestore; 8 | let fireSQL: FireSQL; 9 | 10 | beforeAll(() => { 11 | // adminFirestore = initAdminFirestore(); 12 | firestore = initFirestore(); 13 | fireSQL = new FireSQL(firestore); 14 | }); 15 | 16 | describe('SELECT', () => { 17 | it('returns no documents from non-existant collection', async () => { 18 | expect.assertions(2); 19 | 20 | const docs = await fireSQL.query('SELECT * FROM nonExistantCollection'); 21 | 22 | expect(docs).toBeInstanceOf(Array); 23 | expect(docs).toHaveLength(0); 24 | }); 25 | 26 | it("throws when there's no collection", async () => { 27 | expect.assertions(2); 28 | 29 | try { 30 | await fireSQL.query('SELECT * FROM'); 31 | } catch (err) { 32 | expect(err).toBeInstanceOf(Error); 33 | expect(err).toHaveProperty('name', 'SyntaxError'); 34 | } 35 | }); 36 | 37 | test('without conditions returns the correct documents', async () => { 38 | expect.assertions(3); 39 | 40 | const docs = await new FireSQL( 41 | firestore.doc('shops/2DIHCbOMkKz0YcrKUsRf6kgF') 42 | ).query('SELECT * FROM products'); 43 | 44 | expect(docs).toBeInstanceOf(Array); 45 | expect(docs).toHaveLength(4); 46 | expect(docs).toEqual([ 47 | { 48 | // doc 3UXchxNEyXZ0t1URO6DrIlFZ 49 | name: 'Juice - Lagoon Mango', 50 | price: 488.61, 51 | stock: 2 52 | }, 53 | { 54 | // doc IO6DPA52DMRylKlOlUFkoWza 55 | name: 'Veal - Bones', 56 | price: 246.07, 57 | stock: 2 58 | }, 59 | { 60 | // doc NNJ7ziylrHGcejJpY9p6drqM 61 | name: 'Juice - Apple, 500 Ml', 62 | price: 49.75, 63 | stock: 2 64 | }, 65 | { 66 | // doc jpF9MHHfw8XyfZm2ukvfEXZK 67 | name: 'Graham Cracker Mix', 68 | price: 300.42, 69 | stock: 9 70 | } 71 | ]); 72 | }); 73 | 74 | // it('returns the correct documents using firebase-admin', async () => { 75 | // expect.assertions(3); 76 | 77 | // const docs = await new FireSQL( 78 | // adminFirestore.doc('shops/2DIHCbOMkKz0YcrKUsRf6kgF') 79 | // ).query('SELECT * FROM products'); 80 | 81 | // expect(docs).toBeInstanceOf(Array); 82 | // expect(docs).toHaveLength(4); 83 | // expect(docs).toEqual([ 84 | // { 85 | // // doc 3UXchxNEyXZ0t1URO6DrIlFZ 86 | // name: 'Juice - Lagoon Mango', 87 | // price: 488.61, 88 | // stock: 2 89 | // }, 90 | // { 91 | // // doc IO6DPA52DMRylKlOlUFkoWza 92 | // name: 'Veal - Bones', 93 | // price: 246.07, 94 | // stock: 2 95 | // }, 96 | // { 97 | // // doc NNJ7ziylrHGcejJpY9p6drqM 98 | // name: 'Juice - Apple, 500 Ml', 99 | // price: 49.75, 100 | // stock: 2 101 | // }, 102 | // { 103 | // // doc jpF9MHHfw8XyfZm2ukvfEXZK 104 | // name: 'Graham Cracker Mix', 105 | // price: 300.42, 106 | // stock: 9 107 | // } 108 | // ]); 109 | // }); 110 | 111 | test('with "*" returns all fields', async () => { 112 | expect.assertions(2); 113 | 114 | const docs = await fireSQL.query(` 115 | SELECT * 116 | FROM shops 117 | WHERE name = 'Beer LLC' 118 | `); 119 | 120 | expect(docs).toHaveLength(1); 121 | 122 | // Should be doc 6yZrSjRzn8DzhjQ6MPv0HfTz 123 | expect(docs[0]).toEqual({ 124 | name: 'Beer LLC', 125 | category: 'Baby', 126 | slogan: null, 127 | rating: 0.8, 128 | tags: [], 129 | manager: { name: 'Evanne Edelmann', phone: '814-869-1492' }, 130 | contact: { 131 | address: '94839 Myrtle Park', 132 | city: 'Erie', 133 | postal: '16510', 134 | state: 'Pennsylvania' 135 | } 136 | }); 137 | }); 138 | 139 | test('with field list returns only those fields', async () => { 140 | expect.assertions(2); 141 | 142 | const docs = await fireSQL.query(` 143 | SELECT category, rating, \`manager.name\` 144 | FROM shops 145 | WHERE name = 'Beer LLC' 146 | `); 147 | 148 | expect(docs).toHaveLength(1); 149 | 150 | // Should be doc 6yZrSjRzn8DzhjQ6MPv0HfTz 151 | expect(docs[0]).toEqual({ 152 | category: 'Baby', 153 | rating: 0.8, 154 | 'manager.name': 'Evanne Edelmann' 155 | }); 156 | }); 157 | 158 | test('with field alias', async () => { 159 | expect.assertions(2); 160 | 161 | const docs = await fireSQL.query(` 162 | SELECT name AS aliasedName 163 | FROM shops 164 | WHERE name = 'Beer LLC' 165 | `); 166 | 167 | expect(docs).toHaveLength(1); 168 | 169 | // Should be doc 6yZrSjRzn8DzhjQ6MPv0HfTz 170 | expect(docs[0]).toEqual({ 171 | aliasedName: 'Beer LLC' 172 | }); 173 | }); 174 | 175 | it('returns document id with global includeId=true option', async () => { 176 | expect.assertions(2); 177 | 178 | const docs = await new FireSQL(fireSQL.ref, { includeId: true }).query(` 179 | SELECT * 180 | FROM shops 181 | WHERE name = 'Beer LLC' 182 | `); 183 | 184 | expect(docs).toHaveLength(1); 185 | expect(docs[0]).toHaveProperty( 186 | DOCUMENT_KEY_NAME, 187 | '6yZrSjRzn8DzhjQ6MPv0HfTz' 188 | ); 189 | }); 190 | 191 | it('returns document id with query includeId=true option', async () => { 192 | expect.assertions(2); 193 | 194 | const docs = await fireSQL.query( 195 | ` 196 | SELECT * 197 | FROM shops 198 | WHERE name = 'Beer LLC' 199 | `, 200 | { includeId: true } 201 | ); 202 | 203 | expect(docs).toHaveLength(1); 204 | expect(docs[0]).toHaveProperty( 205 | DOCUMENT_KEY_NAME, 206 | '6yZrSjRzn8DzhjQ6MPv0HfTz' 207 | ); 208 | }); 209 | 210 | it("doesn't return document id with query includeId=false and global includeId=true", async () => { 211 | expect.assertions(2); 212 | 213 | const docs = await new FireSQL(fireSQL.ref, { includeId: true }).query( 214 | ` 215 | SELECT * 216 | FROM shops 217 | WHERE name = 'Beer LLC' 218 | `, 219 | { includeId: false } 220 | ); 221 | 222 | expect(docs).toHaveLength(1); 223 | expect(docs[0]).not.toHaveProperty(DOCUMENT_KEY_NAME); 224 | }); 225 | 226 | it('returns document id with global includeId="alias" option', async () => { 227 | expect.assertions(3); 228 | 229 | const docs = await new FireSQL(fireSQL.ref, { includeId: 'docIdAlias' }) 230 | .query(` 231 | SELECT * 232 | FROM shops 233 | WHERE name = 'Beer LLC' 234 | `); 235 | 236 | expect(docs).toHaveLength(1); 237 | expect(docs[0]).not.toHaveProperty(DOCUMENT_KEY_NAME); 238 | expect(docs[0]).toHaveProperty('docIdAlias', '6yZrSjRzn8DzhjQ6MPv0HfTz'); 239 | }); 240 | 241 | it('returns document id with query includeId="alias" option', async () => { 242 | expect.assertions(3); 243 | 244 | const docs = await fireSQL.query( 245 | ` 246 | SELECT * 247 | FROM shops 248 | WHERE name = 'Beer LLC' 249 | `, 250 | { includeId: 'docIdAlias' } 251 | ); 252 | 253 | expect(docs).toHaveLength(1); 254 | expect(docs[0]).not.toHaveProperty(DOCUMENT_KEY_NAME); 255 | expect(docs[0]).toHaveProperty('docIdAlias', '6yZrSjRzn8DzhjQ6MPv0HfTz'); 256 | }); 257 | 258 | it('returns document id with includeId=true even if not in SELECTed fields', async () => { 259 | expect.assertions(2); 260 | 261 | const docs = await fireSQL.query( 262 | ` 263 | SELECT rating 264 | FROM shops 265 | WHERE name = 'Beer LLC' 266 | `, 267 | { includeId: true } 268 | ); 269 | 270 | expect(docs).toHaveLength(1); 271 | expect(docs[0]).toHaveProperty( 272 | DOCUMENT_KEY_NAME, 273 | '6yZrSjRzn8DzhjQ6MPv0HfTz' 274 | ); 275 | }); 276 | 277 | test('"__name__" returns the document key', async () => { 278 | expect.assertions(2); 279 | 280 | const docs = await fireSQL.query(` 281 | SELECT ${DOCUMENT_KEY_NAME} 282 | FROM shops 283 | WHERE name = 'Simonis, Howe and Kovacek' 284 | `); 285 | 286 | expect(docs).toHaveLength(1); 287 | expect(docs[0]).toHaveProperty( 288 | DOCUMENT_KEY_NAME, 289 | 'AbvczIyCuxEof6TpfOSwdsGO' 290 | ); 291 | }); 292 | 293 | test('"__name__" can be aliased', async () => { 294 | expect.assertions(3); 295 | 296 | const docs = await fireSQL.query(` 297 | SELECT ${DOCUMENT_KEY_NAME} AS docIdAlias 298 | FROM shops 299 | WHERE name = 'Simonis, Howe and Kovacek' 300 | `); 301 | 302 | expect(docs).toHaveLength(1); 303 | expect(docs[0]).not.toHaveProperty(DOCUMENT_KEY_NAME); 304 | expect(docs[0]).toHaveProperty('docIdAlias', 'AbvczIyCuxEof6TpfOSwdsGO'); 305 | }); 306 | 307 | it('filters duplicate documents from combined queries', async () => { 308 | expect.assertions(3); 309 | 310 | const docs1 = await fireSQL.query(` 311 | SELECT * 312 | FROM shops 313 | WHERE category = 'Toys' 314 | `); 315 | expect(docs1).toHaveLength(3); 316 | 317 | const docs2 = await fireSQL.query(` 318 | SELECT * 319 | FROM shops 320 | WHERE rating > 3 321 | `); 322 | expect(docs2).toHaveLength(20); 323 | 324 | const docs3 = await fireSQL.query(` 325 | SELECT * 326 | FROM shops 327 | WHERE category = 'Toys' OR rating > 3 328 | `); 329 | expect(docs3).toHaveLength(21); // rather than 23 (3 + 20) 330 | }); 331 | 332 | test('collection group query returns the correct documents', async () => { 333 | expect.assertions(3); 334 | 335 | const docs = await fireSQL.query(` 336 | SELECT * 337 | FROM GROUP products 338 | WHERE price < 10 339 | `); 340 | 341 | expect(docs).toBeInstanceOf(Array); 342 | expect(docs).toHaveLength(3); 343 | expect(docs).toEqual([ 344 | { 345 | name: 'Juice - Grape, White', 346 | price: 5.72, 347 | stock: 7 348 | }, 349 | { 350 | name: 'Wine - Baron De Rothschild', 351 | price: 5.86, 352 | stock: 4 353 | }, 354 | { 355 | name: 'Tart Shells - Barquettes, Savory', 356 | price: 8.63, 357 | stock: 4 358 | } 359 | ]); 360 | }); 361 | 362 | // TODO: 363 | // ` 364 | // SELECT city, category, AVG(price) AS avgPrice, SUM(price > 5) 365 | // FROM restaurants 366 | // WHERE category IN ("Mexican", "Indian", "Brunch") 367 | // GROUP BY city, category 368 | // ` 369 | 370 | // TODO: 371 | // ` 372 | // SELECT SUM(price) AS sumPrice, AVG(price) 373 | // FROM restaurants 374 | // ` 375 | }); 376 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FireSQL - Query Firestore using SQL syntax 2 | 3 | ## What is FireSQL? 4 | 5 | FireSQL is a library built on top of the official Firebase SDK that allows you to query Cloud Firestore using SQL syntax. It's smart enough to issue the minimum amount of queries necessary to the Firestore servers in order to get the data that you request. 6 | 7 | On top of that, it offers some of the handy utilities that you're used to when using SQL, so that it can provide a better querying experience beyond what's offered by the native querying methods. 8 | 9 | ## Installation 10 | 11 | Just add `firesql` and `firebase` to your project: 12 | 13 | ```sh 14 | npm install firesql firebase 15 | # or 16 | yarn add firesql firebase 17 | ``` 18 | 19 | If you want to receive realtime updates when querying, then you will also need to install `rxjs` and `rxfire`: 20 | 21 | ```sh 22 | npm install firesql firebase rxjs rxfire 23 | # or 24 | yarn add firesql firebase rxjs rxfire 25 | ``` 26 | 27 | ## Usage 28 | 29 | ```js 30 | // You can either query the collections at the root of the database... 31 | const dbRef = firebase.firestore(); 32 | 33 | // ... or the subcollections of some document 34 | const docRef = firebase.firestore().doc('someDoc'); 35 | 36 | // And then just pass that reference to FireSQL 37 | const fireSQL = new FireSQL(dbRef); 38 | 39 | // Use `.query()` to get a one-time result 40 | fireSQL.query('SELECT * FROM myCollection').then(documents => { 41 | documents.forEach(doc => { 42 | /* Do something with the document */ 43 | }); 44 | }); 45 | 46 | // Use `.rxQuery()` to get an observable for realtime results. 47 | // Don't forget to import "firesql/rx" first (see example below). 48 | fireSQL.rxQuery('SELECT * FROM myCollection').subscribe(documents => { 49 | /* Got an update with the documents! */ 50 | }); 51 | 52 | ``` 53 | 54 | ## Examples 55 | 56 | ### One-time result (Promise) 57 | 58 | ```js 59 | import { FireSQL } from 'firesql'; 60 | import firebase from 'firebase/app'; 61 | import 'firebase/firestore'; 62 | 63 | firebase.initializeApp({ /* ... */ }); 64 | 65 | const fireSQL = new FireSQL(firebase.firestore()); 66 | 67 | const citiesPromise = fireSQL.query(` 68 | SELECT name AS city, country, population AS people 69 | FROM cities 70 | WHERE country = 'USA' AND population > 700000 71 | ORDER BY country, population DESC 72 | LIMIT 10 73 | `); 74 | 75 | citiesPromise.then(cities => { 76 | for (const city of cities) { 77 | console.log( 78 | `${city.city} in ${city.country} has ${city.people} people` 79 | ); 80 | } 81 | }); 82 | ``` 83 | 84 | ### Realtime updates (Observable) 85 | 86 | ```js 87 | import { FireSQL } from 'firesql'; 88 | import firebase from 'firebase/app'; 89 | import 'firesql/rx'; // <-- Important! Don't forget 90 | import 'firebase/firestore'; 91 | 92 | firebase.initializeApp({ /* ... */ }); 93 | 94 | const fireSQL = new FireSQL(firebase.firestore()); 95 | 96 | const cities$ = fireSQL.rxQuery(` 97 | SELECT city, category, AVG(price) AS avgPrice 98 | FROM restaurants 99 | WHERE category IN ("Mexican", "Indian", "Brunch") 100 | GROUP BY city, category 101 | `); 102 | 103 | cities$.subscribe(results => { 104 | /* REALTIME AGGREGATED DATA! */ 105 | }); 106 | ``` 107 | 108 | ## Limitations 109 | 110 | - Only `SELECT` queries for now. Support for `INSERT`, `UPDATE`, and `DELETE` might come in the future. 111 | - No support for `JOIN`s. 112 | - `LIMIT` doesn't accept an `OFFSET`, only a single number. 113 | - No support for aggregate function `COUNT`. 114 | - If using `GROUP BY`, it cannot be combined with `ORDER BY` nor `LIMIT`. 115 | - No support for negating conditions with `NOT`. 116 | - Limited `LIKE`. Allows for searches in the form of `WHERE field LIKE 'value%'`, to look for fields that begin with the given value; and `WHERE field LIKE 'value'`, which is functionally equivalent to `WHERE field = 'value'`. 117 | 118 | ## Nested objects 119 | You can access nested objects by using backticks around the field path. For example, if you have a collection "*products*" with documents like this: 120 | ```js 121 | { 122 | productName: "Firebase Hot Sauce", 123 | details: { 124 | available: true, 125 | stock: 42 126 | } 127 | } 128 | ``` 129 | You could do the following queries: 130 | ```sql 131 | SELECT * 132 | FROM products 133 | WHERE `details.stock` > 10 134 | ``` 135 | ```sql 136 | SELECT productName, `details.stock` AS productStock 137 | FROM products 138 | WHERE `details.available` = true 139 | ``` 140 | 141 | ## Getting the document IDs 142 | You can use the special field `__name__` to refer to the document ID (its key inside a collection). For convenience, you might want to alias it: 143 | ```sql 144 | SELECT __name__ AS docId, country, population 145 | FROM cities 146 | ``` 147 | 148 | If you always want to include the document ID, you can specify that as a global option to the FireSQL class: 149 | ```js 150 | const fireSQL = new FireSQL(ref, { includeId: true}); // To include it as "__name__" 151 | const fireSQL = new FireSQL(ref, { includeId: 'fieldName'}); // To include it as "fieldName" 152 | ``` 153 | 154 | You can also specify that option when querying. This will always take preference over the global option: 155 | ```js 156 | fireSQL.query(sql, { includeId: 'id'}); // To include it as "id" 157 | fireSQL.query(sql, { includeId: false}); // To not include it 158 | ``` 159 | 160 | When querying it's also possible to use the document as a search field by using `__name__` directly. For example, you could search for all the documents whose IDs start with `Hello`: 161 | ```sql 162 | SELECT * 163 | FROM cities 164 | WHERE __name__ LIKE 'Hello%' 165 | ``` 166 | 167 | > **Note**: You will need to specify the `includeId` option if you want to obtain the document IDs when doing a `SELECT *` query. 168 | 169 | ## Collection group queries 170 | You can easily do collection group queries with FireSQL! 171 | 172 | This query will get all documents from any collection or subcollection named "landmarks": 173 | ```sql 174 | SELECT * 175 | FROM GROUP landmarks 176 | ``` 177 | 178 | You can [read more about collection group queries](https://firebase.google.com/docs/firestore/query-data/queries#collection-group-query) in the official Firestore documentation. 179 | 180 | ## Array membership queries 181 | It's as simple as using the `CONTAINS` condition: 182 | ```sql 183 | SELECT * 184 | FROM posts 185 | WHERE tags CONTAINS 'interesting' 186 | ``` 187 | 188 | You can [read more about array membership queries](https://firebase.google.com/docs/firestore/query-data/queries#array_membership) in the official Firestore documentation. 189 | 190 | ## How does FireSQL work? 191 | 192 | FireSQL transforms your SQL query into one or more queries to Firestore. Once all the necessary data has been retrieved, it does some internal processing in order to give you exactly what you asked for. 193 | 194 | For example, take the following SQL: 195 | ```sql 196 | SELECT * 197 | FROM cities 198 | WHERE country = 'USA' AND population > 50000 199 | ``` 200 | This would get transformed into this single Firestore query: 201 | ```js 202 | db.collection('cities') 203 | .where('country', '==', 'USA') 204 | .where('population', '>', 50000); 205 | ``` 206 | That's pretty straightforward. But what about this one? 207 | ```sql 208 | SELECT * 209 | FROM cities 210 | WHERE country = 'USA' OR population > 50000 211 | ``` 212 | There's no direct way to perform an `OR` query on Firestore so FireSQL splits that into 2 separate queries: 213 | ```js 214 | db.collection('cities').where('country', '==', 'USA'); 215 | db.collection('cities').where('population', '>', 50000); 216 | ``` 217 | The results are then merged and any possible duplicates are eliminated. 218 | 219 | The same principle applies to any other query. Sometimes your SQL will result in a single Firestore query and some other times it might result in several. 220 | 221 | For example, take a seemingly simple SQL statement like the following: 222 | ```sql 223 | SELECT * 224 | FROM cities 225 | WHERE country != 'Japan' AND region IN ('north', 'east', 'west') AND (capital = true OR population > 100000) 226 | ``` 227 | 228 | This will need to launch a total of 12 concurrent queries to Firestore! 229 | ```js 230 | const cities = db.collection('cities'); 231 | cities.where('country', '<', 'Japan').where('region', '==', 'north').where('capital', '==', true); 232 | cities.where('country', '<', 'Japan').where('region', '==', 'north').where('population', '>', 100000); 233 | cities.where('country', '<', 'Japan').where('region', '==', 'east').where('capital', '==', true); 234 | cities.where('country', '<', 'Japan').where('region', '==', 'east').where('population', '>', 100000); 235 | cities.where('country', '<', 'Japan').where('region', '==', 'west').where('capital', '==', true); 236 | cities.where('country', '<', 'Japan').where('region', '==', 'west').where('population', '>', 100000); 237 | cities.where('country', '>', 'Japan').where('region', '==', 'north').where('capital', '==', true); 238 | cities.where('country', '>', 'Japan').where('region', '==', 'north').where('population', '>', 100000); 239 | cities.where('country', '>', 'Japan').where('region', '==', 'east').where('capital', '==', true); 240 | cities.where('country', '>', 'Japan').where('region', '==', 'east').where('population', '>', 100000); 241 | cities.where('country', '>', 'Japan').where('region', '==', 'west').where('capital', '==', true); 242 | cities.where('country', '>', 'Japan').where('region', '==', 'west').where('population', '>', 100000); 243 | ``` 244 | As you can see, SQL offers a very concise and powerful way to express your query. But as they say, ***with great power comes great responsibility***. Always be mindful of the underlying data model when using FireSQL. 245 | 246 | ## Examples of supported queries: 247 | 248 | ```sql 249 | SELECT * 250 | FROM restaurants 251 | ``` 252 | 253 | ```sql 254 | SELECT name, price 255 | FROM restaurants 256 | WHERE city = 'Chicago' 257 | ``` 258 | 259 | ```sql 260 | SELECT * 261 | FROM restaurants 262 | WHERE category = 'Indian' AND price < 50 263 | ``` 264 | 265 | ```sql 266 | SELECT * 267 | FROM restaurants 268 | WHERE name LIKE 'Best%' 269 | ``` 270 | 271 | ```sql 272 | SELECT * 273 | FROM restaurants 274 | WHERE name LIKE 'Best%' OR city = 'Los Angeles' 275 | ``` 276 | 277 | ```sql 278 | SELECT * 279 | FROM restaurants 280 | WHERE city IN ( 'Raleigh', 'Nashvile', 'Denver' ) 281 | ``` 282 | 283 | ```sql 284 | SELECT * 285 | FROM restaurants 286 | WHERE city != 'Oklahoma' 287 | ``` 288 | 289 | ```sql 290 | SELECT * 291 | FROM restaurants 292 | WHERE favorite = true 293 | ``` 294 | 295 | ```sql 296 | SELECT * 297 | FROM restaurants 298 | WHERE favorite -- Equivalent to the previous one 299 | ``` 300 | 301 | ```sql 302 | SELECT * 303 | FROM restaurants 304 | WHERE favorite IS NULL 305 | ``` 306 | 307 | ```sql 308 | SELECT AVG(price) AS averagePriceInChicago 309 | FROM restaurants 310 | WHERE city = 'Chicago' 311 | ``` 312 | 313 | ```sql 314 | SELECT city, MIN(price), AVG(price), MAX(price) 315 | FROM restaurants 316 | WHERE category = 'Indian' 317 | GROUP BY city 318 | ``` 319 | 320 | ```sql 321 | SELECT * 322 | FROM restaurants 323 | WHERE city = 'Memphis' AND ( price < 40 OR avgRating > 8 ) 324 | ORDER BY price DESC, avgRating 325 | ``` 326 | 327 | ```sql 328 | SELECT * 329 | FROM restaurants 330 | WHERE price BETWEEN 25 AND 150 331 | ORDER BY city, price 332 | LIMIT 10 333 | ``` 334 | 335 | ```sql 336 | SELECT * 337 | FROM restaurants 338 | WHERE city = 'Chicago' 339 | UNION 340 | SELECT * 341 | FROM restaurants 342 | WHERE price > 200 343 | ``` 344 | -------------------------------------------------------------------------------- /test/select/where.test.ts: -------------------------------------------------------------------------------- 1 | import { FireSQL } from '../../src/firesql'; 2 | import { initFirestore } from '../helpers/utils'; 3 | import { DOCUMENT_KEY_NAME } from '../../src/utils'; 4 | 5 | let firestore: firebase.firestore.Firestore; 6 | let fireSQL: FireSQL; 7 | 8 | beforeAll(() => { 9 | firestore = initFirestore(); 10 | fireSQL = new FireSQL(firestore); 11 | }); 12 | 13 | // 'SELECT * FROM cities', 14 | // "SELECT * FROM cities WHERE state = 'CA'", 15 | // "SELECT * FROM cities WHERE country = 'USA' AND population > 700000", 16 | // "SELECT * FROM cities WHERE country = 'USA' OR country = 'China'", 17 | // "SELECT * FROM cities WHERE country = 'Japan' OR population < 1000000", 18 | // "SELECT * FROM cities WHERE country = 'Japan' OR population > 1000000", 19 | // "SELECT * FROM cities WHERE country = 'USA' AND capital", 20 | // "SELECT * FROM cities WHERE country = 'USA' AND (capital OR population > 1000000)" 21 | // "SELECT * FROM cities WHERE country IN ('USA', 'Japan')", 22 | // "SELECT * FROM cities WHERE country IN ('USA', 'Japan') AND capital IS TRUE", 23 | // "SELECT * FROM cities WHERE country != 'USA'", 24 | // "SELECT * FROM cities WHERE name LIKE 'Sa%'", 25 | // "SELECT * FROM cities WHERE state IS NULL", 26 | // "SELECT * FROM cities WHERE population BETWEEN 700000 AND 2000000", 27 | 28 | describe('WHERE', () => { 29 | it("throws when there's no conditions", async () => { 30 | expect.assertions(2); 31 | 32 | try { 33 | await fireSQL.query('SELECT * FROM shops WHERE'); 34 | } catch (err) { 35 | expect(err).toBeInstanceOf(Error); 36 | expect(err).toHaveProperty('name', 'SyntaxError'); 37 | } 38 | }); 39 | 40 | test('non-existant collection returns no documents', async () => { 41 | expect.assertions(2); 42 | 43 | const docs = await fireSQL.query('SELECT * FROM nonExistantCollection'); 44 | 45 | expect(docs).toBeInstanceOf(Array); 46 | expect(docs).toHaveLength(0); 47 | }); 48 | 49 | test('"=" condition', async () => { 50 | expect.assertions(1); 51 | 52 | const docs = await fireSQL.query(` 53 | SELECT category, name 54 | FROM shops 55 | WHERE category = 'Toys' 56 | `); 57 | 58 | expect(docs).toEqual([ 59 | { 60 | category: 'Toys', 61 | name: 'Stiedemann, Keeling and Carter' 62 | }, 63 | { 64 | category: 'Toys', 65 | name: 'Carroll Group' 66 | }, 67 | { 68 | category: 'Toys', 69 | name: 'Leannon-Conroy' 70 | } 71 | ]); 72 | }); 73 | 74 | test('"<" condition', async () => { 75 | expect.assertions(1); 76 | 77 | const docs = await fireSQL.query(` 78 | SELECT category, name 79 | FROM shops 80 | WHERE rating < 0.3 81 | `); 82 | 83 | expect(docs).toEqual([ 84 | { 85 | category: 'Toys', 86 | name: 'Carroll Group' 87 | } 88 | ]); 89 | }); 90 | 91 | test('">" condition', async () => { 92 | expect.assertions(1); 93 | 94 | const docs = await fireSQL.query(` 95 | SELECT category, name 96 | FROM shops 97 | WHERE rating > 4.8 98 | `); 99 | 100 | expect(docs).toEqual([ 101 | { 102 | category: 'Automotive', 103 | name: 'Grady, Kirlin and Welch' 104 | } 105 | ]); 106 | }); 107 | 108 | test('"<=" condition', async () => { 109 | expect.assertions(1); 110 | 111 | const docs = await fireSQL.query(` 112 | SELECT category, name 113 | FROM shops 114 | WHERE rating <= 0.4 115 | `); 116 | 117 | expect(docs).toEqual([ 118 | { 119 | category: 'Toys', 120 | name: 'Carroll Group' 121 | }, 122 | { 123 | category: 'Movies', 124 | name: 'Stark-Keebler' 125 | }, 126 | { 127 | category: 'Home', 128 | name: 'Nikolaus-Borer' 129 | }, 130 | { 131 | category: 'Games', 132 | name: 'Frami, Reynolds and Fay' 133 | } 134 | ]); 135 | }); 136 | 137 | test('">=" condition', async () => { 138 | expect.assertions(1); 139 | 140 | const docs = await fireSQL.query(` 141 | SELECT category, name 142 | FROM shops 143 | WHERE rating >= 4.8 144 | `); 145 | 146 | expect(docs).toEqual([ 147 | { 148 | category: 'Home', 149 | name: 'Grant, Wisoky and Baumbach' 150 | }, 151 | { 152 | category: 'Beauty', 153 | name: 'Torp Inc' 154 | }, 155 | { 156 | category: 'Automotive', 157 | name: 'Grady, Kirlin and Welch' 158 | } 159 | ]); 160 | }); 161 | 162 | test('"!=" condition', async () => { 163 | expect.assertions(1); 164 | 165 | const docs = await new FireSQL( 166 | firestore.doc('/shops/mEjD3yDXz2Her0OtIGGMeZGx') 167 | ).query(` 168 | SELECT * 169 | FROM products 170 | WHERE stock != 9 171 | `); 172 | 173 | expect(docs).toEqual([ 174 | { 175 | name: 'Pepper - Chilli Seeds Mild', 176 | price: 290.4, 177 | stock: 6 178 | }, 179 | { 180 | name: 'Cake - French Pear Tart', 181 | price: 298.31, 182 | stock: 10 183 | } 184 | ]); 185 | }); 186 | 187 | test('"IN" condition', async () => { 188 | expect.assertions(1); 189 | 190 | const docs = await fireSQL.query(` 191 | SELECT \`contact.postal\`, \`contact.state\`, name 192 | FROM shops 193 | WHERE \`contact.postal\` IN ('32204', '95813') 194 | `); 195 | 196 | expect(docs).toEqual([ 197 | { 198 | 'contact.postal': '32204', 199 | 'contact.state': 'Florida', 200 | name: 'Cummings Inc' 201 | }, 202 | { 203 | 'contact.postal': '95813', 204 | 'contact.state': 'California', 205 | name: 'Leannon-Conroy' 206 | } 207 | ]); 208 | }); 209 | 210 | test('"BETWEEN" condition', async () => { 211 | expect.assertions(1); 212 | 213 | const docs = await fireSQL.query(` 214 | SELECT name, rating 215 | FROM shops 216 | WHERE rating BETWEEN 3.1 AND 3.3 217 | `); 218 | 219 | expect(docs).toEqual([ 220 | { 221 | name: 'Cummings Inc', 222 | rating: 3.1 223 | }, 224 | { 225 | name: 'Lehner-Bartell', 226 | rating: 3.2 227 | }, 228 | { 229 | name: 'Oberbrunner, Runte and Rippin', 230 | rating: 3.3 231 | }, 232 | { 233 | name: 'Aufderhar, Lindgren and Okuneva', 234 | rating: 3.3 235 | } 236 | ]); 237 | }); 238 | 239 | test('"LIKE \'value%\'" condition (begins with)', async () => { 240 | expect.assertions(1); 241 | 242 | const docs = await fireSQL.query(` 243 | SELECT name, category 244 | FROM shops 245 | WHERE name LIKE 'Wa%' 246 | `); 247 | 248 | expect(docs).toEqual([ 249 | { 250 | name: 'Waelchi, Schultz and Skiles', 251 | category: 'Jewelery' 252 | }, 253 | { 254 | name: 'Waelchi-Koss', 255 | category: 'Industrial' 256 | }, 257 | { 258 | name: 'Walker-Keeling', 259 | category: 'Outdoors' 260 | } 261 | ]); 262 | }); 263 | 264 | test('"LIKE \'value\'" condition behaves like "="', async () => { 265 | expect.assertions(1); 266 | 267 | const docs1 = await fireSQL.query(` 268 | SELECT category, name 269 | FROM shops 270 | WHERE category = 'Toys' 271 | `); 272 | 273 | const docs2 = await fireSQL.query(` 274 | SELECT category, name 275 | FROM shops 276 | WHERE category LIKE 'Toys' 277 | `); 278 | 279 | expect(docs1).toEqual(docs2); 280 | }); 281 | 282 | test('"IS" condition behaves like "="', async () => { 283 | expect.assertions(1); 284 | 285 | const docs1 = await fireSQL.query(` 286 | SELECT category, name 287 | FROM shops 288 | WHERE category = 'Toys' 289 | `); 290 | 291 | const docs2 = await fireSQL.query(` 292 | SELECT category, name 293 | FROM shops 294 | WHERE category IS 'Toys' 295 | `); 296 | 297 | expect(docs1).toEqual(docs2); 298 | }); 299 | 300 | test('"AND" operator', async () => { 301 | expect.assertions(1); 302 | 303 | const docs = await fireSQL.query(` 304 | SELECT name, slogan 305 | FROM shops 306 | WHERE rating = 4 AND category = 'Toys' 307 | `); 308 | 309 | expect(docs).toEqual([ 310 | { 311 | name: 'Stiedemann, Keeling and Carter', 312 | slogan: 'incubate B2C ROI' 313 | } 314 | ]); 315 | }); 316 | 317 | test('"OR" operator', async () => { 318 | expect.assertions(1); 319 | 320 | const docs = await fireSQL.query(` 321 | SELECT name, slogan 322 | FROM shops 323 | WHERE rating = 4 OR category = 'Toys' 324 | `); 325 | 326 | expect(docs).toEqual([ 327 | { 328 | name: 'Stiedemann, Keeling and Carter', 329 | slogan: 'incubate B2C ROI' 330 | }, 331 | { 332 | name: 'Adams-Nikolaus', 333 | slogan: null 334 | }, 335 | { 336 | name: 'Lesch-Windler', 337 | slogan: null 338 | }, 339 | { 340 | name: 'Carroll Group', 341 | slogan: null 342 | }, 343 | { 344 | name: 'Leannon-Conroy', 345 | slogan: 'integrate magnetic interfaces' 346 | } 347 | ]); 348 | }); 349 | 350 | test('multiple nested operators', async () => { 351 | expect.assertions(1); 352 | 353 | const docs = await fireSQL.query(` 354 | SELECT name, rating, category, \`contact.state\` 355 | FROM shops 356 | WHERE rating < 2 357 | AND ( 358 | \`contact.state\` = 'California' 359 | OR 360 | category IN ('Computers', 'Automotive') 361 | ) 362 | `); 363 | 364 | expect(docs).toEqual([ 365 | { 366 | name: 'Orn-Auer', 367 | rating: 0.6, 368 | category: 'Outdoors', 369 | 'contact.state': 'California' 370 | }, 371 | { 372 | name: 'Trantow, Deckow and Oberbrunner', 373 | rating: 1.5, 374 | category: 'Shoes', 375 | 'contact.state': 'California' 376 | }, 377 | { 378 | name: 'Schumm-Zieme', 379 | rating: 0.9, 380 | category: 'Computers', 381 | 'contact.state': 'Ohio' 382 | } 383 | ]); 384 | }); 385 | 386 | it('filters by document key when using "__name__"', async () => { 387 | expect.assertions(1); 388 | 389 | const docs = await fireSQL.query(` 390 | SELECT name 391 | FROM shops 392 | WHERE ${DOCUMENT_KEY_NAME} = 'A2AwXRvhW3HmEivfS5LPH3s8' 393 | `); 394 | 395 | expect(docs).toEqual([ 396 | { 397 | name: 'Price, Monahan and Bogisich' 398 | } 399 | ]); 400 | }); 401 | 402 | test('"CONTAINS" condition', async () => { 403 | expect.assertions(3); 404 | 405 | const docs = await fireSQL.query(` 406 | SELECT category, name, tags 407 | FROM shops 408 | WHERE tags CONTAINS "content-based" 409 | `); 410 | 411 | expect(docs).toBeInstanceOf(Array); 412 | expect(docs).toHaveLength(2); 413 | expect(docs).toEqual([ 414 | { 415 | category: 'Industrial', 416 | name: 'Waelchi-Koss', 417 | tags: ['content-based', 'User-centric', 'Profound'] 418 | }, 419 | { 420 | category: 'Computers', 421 | name: 'Brekke-Maggio', 422 | tags: ['content-based', 'Decentralized', 'exuding'] 423 | } 424 | ]); 425 | }); 426 | 427 | // TODO: Can't combine "LIKE 'value%'" with inequality filters (>, <=, ...) 428 | /* 429 | SELECT * 430 | FROM shops 431 | WHERE rating > 2 AND name LIKE 'T%' 432 | */ 433 | }); 434 | -------------------------------------------------------------------------------- /src/sql-parser/firesql.pegjs: -------------------------------------------------------------------------------- 1 | /* 2 | * Adapted from: 3 | * https://github.com/fishbar/node-sqlparser/blob/master/peg/sqlparser.pegjs 4 | */ 5 | 6 | { 7 | function createUnaryExpr(op, e) { 8 | return { 9 | type : 'unary_expr', 10 | operator : op, 11 | expr : e 12 | } 13 | } 14 | 15 | function createBinaryExpr(op, left, right) { 16 | return { 17 | type : 'binary_expr', 18 | operator : op, 19 | left : left, 20 | right : right 21 | } 22 | } 23 | 24 | function createList(head, tail) { 25 | var result = [head]; 26 | for (var i = 0; i < tail.length; i++) { 27 | result.push(tail[i][3]); 28 | } 29 | return result; 30 | } 31 | 32 | function createExprList(head, tail, room) { 33 | var epList = createList(head, tail); 34 | var exprList = []; 35 | var ep; 36 | for (var i = 0; i < epList.length; i++) { 37 | ep = epList[i]; 38 | exprList.push(ep); 39 | } 40 | return exprList; 41 | } 42 | 43 | function createBinaryExprChain(head, tail) { 44 | var result = head; 45 | for (var i = 0; i < tail.length; i++) { 46 | result = createBinaryExpr(tail[i][1], result, tail[i][3]); 47 | } 48 | return result; 49 | } 50 | 51 | var reservedMap = { 52 | 'SHOW' : true, 53 | 'DROP' : true, 54 | 'SELECT' : true, 55 | 'UPDATE' : true, 56 | 'CREATE' : true, 57 | 'DELETE' : true, 58 | 'INSERT' : true, 59 | 'REPLACE' : true, 60 | 'EXPLAIN' : true, 61 | 'ALL' : true, 62 | 'DISTINCT': true, 63 | 'AS' : true, 64 | 'TABLE' : true, 65 | 'INTO' : true, 66 | 'FROM' : true, 67 | 'SET' : true, 68 | 'LEFT' : true, 69 | 'ON' : true, 70 | 'INNER' : true, 71 | 'JOIN' : true, 72 | 'UNION' : true, 73 | 'VALUES' : true, 74 | 'EXISTS' : true, 75 | 'WHERE' : true, 76 | 'GROUP' : true, 77 | 'BY' : true, 78 | 'HAVING' : true, 79 | 'ORDER' : true, 80 | 'ASC' : true, 81 | 'DESC' : true, 82 | 'LIMIT' : true, 83 | 'BETWEEN' : true, 84 | 'IN' : true, 85 | 'IS' : true, 86 | 'LIKE' : true, 87 | 'CONTAINS': true, 88 | 'NOT' : true, 89 | 'AND' : true, 90 | 'OR' : true, 91 | 92 | //literal 93 | 'TRUE' : true, 94 | 'FALSE' : true, 95 | 'NULL' : true 96 | } 97 | } 98 | 99 | start 100 | = __ ast:union_stmt { 101 | return ast; 102 | } 103 | 104 | union_stmt 105 | = head:select_stmt tail:(__ KW_UNION __ select_stmt)* { 106 | var cur = head; 107 | for (var i = 0; i < tail.length; i++) { 108 | cur._next = tail[i][3]; 109 | cur = cur._next 110 | } 111 | return head; 112 | } 113 | 114 | select_stmt 115 | = select_stmt_nake 116 | / s:('(' __ select_stmt __ ')') { 117 | return s[2]; 118 | } 119 | 120 | select_stmt_nake 121 | = KW_SELECT __ 122 | d:KW_DISTINCT? __ 123 | c:column_clause __ 124 | f:from_clause? __ 125 | w:where_clause? __ 126 | g:group_by_clause? __ 127 | o:order_by_clause? __ 128 | l:limit_clause? __ { 129 | return { 130 | type : 'select', 131 | distinct : d, 132 | columns : c, 133 | from : f, 134 | where : w, 135 | groupby : g, 136 | orderby : o, 137 | limit : l 138 | } 139 | } 140 | 141 | column_clause "column_clause" 142 | = (KW_ALL / (STAR !ident_start)) { 143 | return '*'; 144 | } 145 | / head:column_list_item tail:(__ COMMA __ column_list_item)* { 146 | return createList(head, tail); 147 | } 148 | 149 | column_list_item 150 | = e:additive_expr __ alias:alias_clause? { 151 | return { 152 | expr : e, 153 | as : alias 154 | }; 155 | } 156 | 157 | alias_clause 158 | = KW_AS? __ i:ident { return i; } 159 | 160 | from_clause 161 | = KW_FROM __ l:table_base { return l; } 162 | 163 | table_base 164 | = group:(KW_GROUP __)? t:(table_name / '`' table_name '`') __ KW_AS? __ alias:ident? { 165 | return { 166 | db: t.db, 167 | parts: (Array.isArray(t) ? t[1] : t).parts, 168 | as: alias, 169 | group: group ? true : false 170 | } 171 | } 172 | 173 | table_name 174 | = dt:('/'? ident_name)+ { 175 | return { 176 | parts: dt.map(function(parts) { return parts[1]; }) 177 | } 178 | } 179 | 180 | where_clause 181 | = KW_WHERE __ e:expr { return e; } 182 | 183 | group_by_clause 184 | = KW_GROUP __ KW_BY __ l:column_ref_list { return l; } 185 | 186 | column_ref_list 187 | = head:column_ref tail:(__ COMMA __ column_ref)* { 188 | return createList(head, tail); 189 | } 190 | 191 | order_by_clause 192 | = KW_ORDER __ KW_BY __ l:order_by_list { return l; } 193 | 194 | order_by_list 195 | = head:order_by_element tail:(__ COMMA __ order_by_element)* { 196 | return createList(head, tail); 197 | } 198 | 199 | order_by_element 200 | = e:expr __ d:(KW_DESC / KW_ASC)? { 201 | var obj = { 202 | expr : e, 203 | type : 'ASC' 204 | } 205 | if (d == 'DESC') { 206 | obj.type = 'DESC'; 207 | } 208 | return obj; 209 | } 210 | 211 | limit_clause 212 | = KW_LIMIT __ lim:(literal_int) { 213 | return lim; 214 | } 215 | 216 | //for template auto fill 217 | expr_list 218 | = head:expr tail:(__ COMMA __ expr)*{ 219 | var el = { 220 | type : 'expr_list', 221 | value: undefined 222 | } 223 | 224 | var l = createExprList(head, tail, el); 225 | 226 | el.value = l; 227 | return el; 228 | } 229 | 230 | /** 231 | * Borrowed from PL/SQL ,the priority of below list IS ORDER BY DESC 232 | * --------------------------------------------------------------------------------------------------- 233 | * | +, - | identity, negation | 234 | * | *, / | multiplication, division | 235 | * | +, - | addition, subtraction, concatenation | 236 | * | =, <, >, <=, >=, <>, !=, IS, LIKE, BETWEEN, IN, CONTAINS | comparion | 237 | * | !, NOT | logical negation | 238 | * | AND | conjunction | 239 | * | OR | inclusion | 240 | * --------------------------------------------------------------------------------------------------- 241 | */ 242 | 243 | expr = or_expr 244 | 245 | or_expr 246 | = head:and_expr tail:(__ KW_OR __ and_expr)* { 247 | return createBinaryExprChain(head, tail); 248 | } 249 | 250 | and_expr 251 | = head:not_expr tail:(__ KW_AND __ not_expr)* { 252 | return createBinaryExprChain(head, tail); 253 | } 254 | 255 | not_expr 256 | = (KW_NOT / "!" !"=") __ expr:not_expr { 257 | return createUnaryExpr('NOT', expr); 258 | } 259 | / comparison_expr 260 | 261 | comparison_expr 262 | = left:additive_expr __ rh:comparison_op_right? { 263 | if (!rh) { 264 | return left; 265 | } else { 266 | var res = null; 267 | if (rh.type == 'arithmetic') { 268 | res = createBinaryExprChain(left, rh.tail); 269 | } else { 270 | res = createBinaryExpr(rh.op, left, rh.right); 271 | } 272 | return res; 273 | } 274 | } 275 | 276 | comparison_op_right 277 | = arithmetic_op_right 278 | / in_op_right 279 | / between_op_right 280 | / is_op_right 281 | / like_op_right 282 | / contains_op_right 283 | 284 | arithmetic_op_right 285 | = l:(__ arithmetic_comparison_operator __ additive_expr)+ { 286 | return { 287 | type : 'arithmetic', 288 | tail : l 289 | } 290 | } 291 | 292 | arithmetic_comparison_operator 293 | = ">=" / ">" / "<=" / "<>" / "<" / "=" / "!=" 294 | 295 | is_op_right 296 | = op:KW_IS __ right:additive_expr { 297 | return { 298 | op : op, 299 | right : right 300 | } 301 | } 302 | 303 | between_op_right 304 | = op:KW_BETWEEN __ begin:additive_expr __ KW_AND __ end:additive_expr { 305 | return { 306 | op : op, 307 | right : { 308 | type : 'expr_list', 309 | value : [begin, end] 310 | } 311 | } 312 | } 313 | 314 | like_op 315 | = nk:(KW_NOT __ KW_LIKE) { return nk[0] + ' ' + nk[2]; } 316 | / KW_LIKE 317 | 318 | in_op 319 | = nk:(KW_NOT __ KW_IN) { return nk[0] + ' ' + nk[2]; } 320 | / KW_IN 321 | 322 | contains_op 323 | = nk:(KW_NOT __ KW_CONTAINS) { return nk[0] + ' ' + nk[2]; } 324 | / KW_CONTAINS 325 | 326 | like_op_right 327 | = op:like_op __ right:comparison_expr { 328 | return { 329 | op : op, 330 | right : right 331 | } 332 | } 333 | 334 | in_op_right 335 | = op:in_op __ LPAREN __ l:expr_list __ RPAREN { 336 | return { 337 | op : op, 338 | right : l 339 | } 340 | } 341 | 342 | contains_op_right 343 | = op:contains_op __ l:literal { 344 | return { 345 | op : op, 346 | right : l 347 | } 348 | } 349 | 350 | additive_expr 351 | = head:multiplicative_expr 352 | tail:(__ additive_operator __ multiplicative_expr)* { 353 | return createBinaryExprChain(head, tail); 354 | } 355 | 356 | additive_operator 357 | = "+" / "-" 358 | 359 | multiplicative_expr 360 | = head:primary 361 | tail:(__ multiplicative_operator __ primary)* { 362 | return createBinaryExprChain(head, tail) 363 | } 364 | 365 | multiplicative_operator 366 | = "*" / "/" / "%" 367 | 368 | primary 369 | = literal 370 | / aggr_func 371 | / column_ref 372 | / LPAREN __ e:expr __ RPAREN { 373 | e.paren = true; 374 | return e; 375 | } 376 | 377 | column_ref 378 | = tbl:ident __ DOT __ col:column { 379 | return { 380 | type : 'column_ref', 381 | table : tbl, 382 | column : col 383 | }; 384 | } 385 | / col:column { 386 | return { 387 | type : 'column_ref', 388 | table : '', 389 | column: col 390 | }; 391 | } 392 | 393 | column_list 394 | = head:column tail:(__ COMMA __ column)* { 395 | return createList(head, tail); 396 | } 397 | 398 | ident = 399 | name:ident_name !{ return reservedMap[name.toUpperCase()] === true; } { 400 | return name; 401 | } 402 | 403 | column = 404 | name:ident_name !{ return reservedMap[name.toUpperCase()] === true; } { 405 | return name; 406 | } 407 | /'`' chars:[^`]+ '`' { 408 | return chars.join(''); 409 | } 410 | 411 | ident_name 412 | = parts:ident_part+ { return parts.join(''); } 413 | 414 | ident_start = [A-Za-z_] 415 | 416 | ident_part = [A-Za-z0-9_] 417 | 418 | aggr_func 419 | = name:KW_SUM_MAX_MIN_AVG __ LPAREN __ f:ident_name __ RPAREN { 420 | return { 421 | type : 'aggr_func', 422 | name : name, 423 | field: f 424 | } 425 | } 426 | 427 | KW_SUM_MAX_MIN_AVG 428 | = w:(KW_SUM / KW_MAX / KW_MIN / KW_AVG) { 429 | return w; 430 | } 431 | 432 | star_expr 433 | = "*" { 434 | return { 435 | type : 'star', 436 | value : '*' 437 | } 438 | } 439 | 440 | literal 441 | = literal_string / literal_numeric / literal_bool / literal_null 442 | 443 | literal_list 444 | = head:literal tail:(__ COMMA __ literal)* { 445 | return createList(head, tail); 446 | } 447 | 448 | literal_null 449 | = KW_NULL { 450 | return { 451 | type : 'null', 452 | value : null 453 | }; 454 | } 455 | 456 | literal_bool 457 | = KW_TRUE { 458 | return { 459 | type : 'bool', 460 | value : true 461 | }; 462 | } 463 | / KW_FALSE { 464 | return { 465 | type : 'bool', 466 | value : false 467 | }; 468 | } 469 | 470 | literal_string 471 | = ca:( ('"' double_char* '"') 472 | /("'" single_char* "'")) { 473 | return { 474 | type : 'string', 475 | value : ca[1].join('') 476 | } 477 | } 478 | 479 | single_char 480 | = [^'\\\0-\x1F\x7f] 481 | / escape_char 482 | 483 | double_char 484 | = [^"\\\0-\x1F\x7f] 485 | / escape_char 486 | 487 | escape_char 488 | = "\\'" { return "'"; } 489 | / '\\"' { return '"'; } 490 | / "\\\\" { return "\\"; } 491 | / "\\/" { return "/"; } 492 | / "\\b" { return "\b"; } 493 | / "\\f" { return "\f"; } 494 | / "\\n" { return "\n"; } 495 | / "\\r" { return "\r"; } 496 | / "\\t" { return "\t"; } 497 | / "\\u" h1:hexDigit h2:hexDigit h3:hexDigit h4:hexDigit { 498 | return String.fromCharCode(parseInt("0x" + h1 + h2 + h3 + h4)); 499 | } 500 | 501 | line_terminator 502 | = [\n\r] 503 | 504 | literal_numeric 505 | = n:number { 506 | return { 507 | type : 'number', 508 | value : n 509 | } 510 | } 511 | literal_int "LITERAL INT" 512 | = n:int { 513 | return { 514 | type: 'number', 515 | value: n 516 | } 517 | } 518 | 519 | number 520 | = int_:int frac:frac exp:exp __ { var x = parseFloat(int_ + frac + exp); return (x % 1 != 0) ? x.toString() : x.toString() + ".0"} 521 | / int_:int frac:frac __ { var x = parseFloat(int_ + frac); return (x % 1 != 0) ? x.toString() : x.toString() + ".0"} 522 | / int_:int exp:exp __ { return parseFloat(int_ + exp).toString(); } 523 | / int_:int __ { return parseFloat(int_).toString(); } 524 | 525 | int 526 | = digit19:digit19 digits:digits { return digit19 + digits; } 527 | / digit:digit 528 | / op:("-" / "+" ) digit19:digit19 digits:digits { return "-" + digit19 + digits; } 529 | / op:("-" / "+" ) digit:digit { return "-" + digit; } 530 | 531 | frac 532 | = "." digits:digits { return "." + digits; } 533 | 534 | exp 535 | = e:e digits:digits { return e + digits; } 536 | 537 | digits 538 | = digits:digit+ { return digits.join(""); } 539 | 540 | digit "NUMBER" = [0-9] 541 | digit19 "NUMBER" = [1-9] 542 | 543 | hexDigit "HEX" 544 | = [0-9a-fA-F] 545 | 546 | e 547 | = e:[eE] sign:[+-]? { return e + sign; } 548 | 549 | 550 | KW_NULL = "NULL"i !ident_start 551 | KW_TRUE = "TRUE"i !ident_start 552 | KW_FALSE = "FALSE"i !ident_start 553 | 554 | KW_SHOW = "SHOW"i !ident_start 555 | KW_SELECT = "SELECT"i !ident_start 556 | 557 | KW_FROM = "FROM"i !ident_start 558 | 559 | KW_AS = "AS"i !ident_start 560 | KW_TABLE = "TABLE"i !ident_start 561 | 562 | KW_UNION = "UNION"i !ident_start 563 | 564 | KW_IF = "IF"i !ident_start 565 | KW_EXISTS = "EXISTS"i !ident_start 566 | 567 | KW_WHERE = "WHERE"i !ident_start 568 | 569 | KW_GROUP = "GROUP"i !ident_start 570 | KW_BY = "BY"i !ident_start 571 | KW_ORDER = "ORDER"i !ident_start 572 | 573 | KW_LIMIT = "LIMIT"i !ident_start 574 | 575 | KW_ASC = "ASC"i !ident_start { return 'ASC'; } 576 | KW_DESC = "DESC"i !ident_start { return 'DESC'; } 577 | 578 | KW_ALL = "ALL"i !ident_start { return 'ALL'; } 579 | KW_DISTINCT = "DISTINCT"i !ident_start { return 'DISTINCT';} 580 | 581 | KW_BETWEEN = "BETWEEN"i !ident_start { return 'BETWEEN'; } 582 | KW_IN = "IN"i !ident_start { return 'IN'; } 583 | KW_IS = "IS"i !ident_start { return 'IS'; } 584 | KW_LIKE = "LIKE"i !ident_start { return 'LIKE'; } 585 | KW_CONTAINS = "CONTAINS"i !ident_start { return 'CONTAINS';} 586 | 587 | KW_NOT = "NOT"i !ident_start { return 'NOT'; } 588 | KW_AND = "AND"i !ident_start { return 'AND'; } 589 | KW_OR = "OR"i !ident_start { return 'OR'; } 590 | 591 | KW_COUNT = "COUNT"i !ident_start { return 'COUNT'; } 592 | KW_MAX = "MAX"i !ident_start { return 'MAX'; } 593 | KW_MIN = "MIN"i !ident_start { return 'MIN'; } 594 | KW_SUM = "SUM"i !ident_start { return 'SUM'; } 595 | KW_AVG = "AVG"i !ident_start { return 'AVG'; } 596 | 597 | //specail character 598 | DOT = '.' 599 | COMMA = ',' 600 | STAR = '*' 601 | LPAREN = '(' 602 | RPAREN = ')' 603 | 604 | __ = 605 | whitespace* 606 | 607 | char = . 608 | 609 | whitespace 'WHITE_SPACE' = [ \t\n\r] 610 | -------------------------------------------------------------------------------- /src/select/index.ts: -------------------------------------------------------------------------------- 1 | import { QueryOptions } from '../shared'; 2 | import { 3 | SQL_SelectColumn, 4 | SQL_ColumnRef, 5 | SQL_AggrFunction, 6 | SQL_Select 7 | } from '../sql-parser'; 8 | import { 9 | assert, 10 | contains, 11 | deepGet, 12 | DocumentData, 13 | safeGet, 14 | nameOrAlias, 15 | DOCUMENT_KEY_NAME 16 | } from '../utils'; 17 | import { 18 | applyGroupByLocally, 19 | GroupedDocuments, 20 | GroupAggregateValues, 21 | DocumentsGroup 22 | } from './groupby'; 23 | import { applyOrderBy, applyOrderByLocally } from './orderby'; 24 | import { applyLimit, applyLimitLocally } from './limit'; 25 | import { applyWhere } from './where'; 26 | 27 | const VALID_AGGR_FUNCTIONS = ['MIN', 'MAX', 'SUM', 'AVG']; 28 | 29 | export async function select_( 30 | ref: firebase.firestore.DocumentReference, 31 | ast: SQL_Select, 32 | options: QueryOptions 33 | ): Promise { 34 | const selectOp = new SelectOperation(ref, ast, options); 35 | const queries = selectOp.generateQueries_(); 36 | const documents = await selectOp.executeQueries_(queries); 37 | return selectOp.processDocuments_(queries, documents); 38 | } 39 | 40 | export class SelectOperation { 41 | _includeId?: boolean | string; 42 | 43 | constructor( 44 | private _ref: firebase.firestore.DocumentReference, 45 | private _ast: SQL_Select, 46 | options: QueryOptions 47 | ) { 48 | // We need to determine if we have to include 49 | // the document's ID (__name__) in the results. 50 | this._includeId = options.includeId || false; 51 | if (!this._includeId && Array.isArray(_ast.columns)) { 52 | for (let i = 0; i < _ast.columns.length; i++) { 53 | if (_ast.columns[i].expr.type === 'column_ref') { 54 | if ( 55 | (_ast.columns[i].expr as SQL_ColumnRef).column === DOCUMENT_KEY_NAME 56 | ) { 57 | this._includeId = true; 58 | break; 59 | } 60 | } 61 | } 62 | } 63 | 64 | if (this._includeId === void 0) { 65 | this._includeId = false; 66 | } 67 | } 68 | 69 | generateQueries_(ast?: SQL_Select): firebase.firestore.Query[] { 70 | ast = ast || this._ast; 71 | 72 | assert( 73 | ast.from.parts.length % 2 === 1, 74 | '"FROM" needs a path to a collection (odd number of parts).' 75 | ); 76 | 77 | const path = ast.from.parts.join('/'); 78 | let queries: firebase.firestore.Query[] = []; 79 | 80 | if (ast.from.group) { 81 | assert( 82 | this._ref.path === '', 83 | 'Collection group queries are only allowed from the root of the database.' 84 | ); 85 | 86 | const firestore = contains(this._ref, 'firestore') 87 | ? this._ref.firestore 88 | : ((this._ref as any) as firebase.firestore.Firestore); 89 | 90 | assert( 91 | typeof (firestore as any).collectionGroup === 'function', 92 | `Your version of the Firebase SDK doesn't support collection group queries.` 93 | ); 94 | queries.push((firestore as any).collectionGroup(path)); 95 | } else { 96 | queries.push(this._ref.collection(path)); 97 | } 98 | 99 | /* 100 | * We'd need this if we end up implementing JOINs, but for now 101 | * it's unnecessary since we're only querying a single collection 102 | 103 | // Keep track of aliased "tables" (collections) 104 | const aliasedCollections: { [k: string]: string } = {}; 105 | if (ast.from[0].as.length > 0) { 106 | aliasedCollections[ast.from[0].as] = colName; 107 | } else { 108 | aliasedCollections[colName] = colName; 109 | } 110 | */ 111 | 112 | if (ast.where) { 113 | queries = applyWhere(queries, ast.where); 114 | } 115 | 116 | if (ast.orderby) { 117 | queries = applyOrderBy(queries, ast.orderby); 118 | 119 | /* 120 | FIXME: the following query throws an error: 121 | SELECT city, name 122 | FROM restaurants 123 | WHERE city IN ('Nashvile', 'Denver') 124 | ORDER BY city, name 125 | 126 | It happens because "WHERE ... IN ..." splits into 2 separate 127 | queries with a "==" filter, and an order by clause cannot 128 | contain a field with an equality filter: 129 | ...where("city","==","Denver").orderBy("city") 130 | */ 131 | } 132 | 133 | // if (ast.groupby) { 134 | // throw new Error('GROUP BY not supported yet'); 135 | // } 136 | 137 | if (ast.limit) { 138 | // First we apply the limit to each query we may have 139 | // and later we'll apply it again locally to the 140 | // merged set of documents, in case we end up with too many. 141 | queries = applyLimit(queries, ast.limit); 142 | } 143 | 144 | if (ast._next) { 145 | assert( 146 | ast._next.type === 'select', 147 | ' UNION statements are only supported between SELECTs.' 148 | ); 149 | // This is the UNION of 2 SELECTs, so lets process the second 150 | // one and merge their queries 151 | queries = queries.concat(this.generateQueries_(ast._next)); 152 | 153 | // FIXME: The SQL parser incorrectly attributes ORDER BY to the second 154 | // SELECT only, instead of to the whole UNION. Find a workaround. 155 | } 156 | 157 | return queries; 158 | } 159 | 160 | async executeQueries_( 161 | queries: firebase.firestore.Query[] 162 | ): Promise { 163 | let documents: DocumentData[] = []; 164 | const seenDocuments: { [id: string]: true } = {}; 165 | 166 | try { 167 | await Promise.all( 168 | queries.map(async query => { 169 | const snapshot = await query.get(); 170 | const numDocs = snapshot.docs.length; 171 | 172 | for (let i = 0; i < numDocs; i++) { 173 | const docSnap = snapshot.docs[i]; 174 | const docPath = docSnap.ref.path; 175 | 176 | if (!contains(seenDocuments, docPath)) { 177 | const docData = docSnap.data(); 178 | 179 | if (this._includeId) { 180 | docData[ 181 | typeof this._includeId === 'string' 182 | ? this._includeId 183 | : DOCUMENT_KEY_NAME 184 | ] = docSnap.id; 185 | } 186 | 187 | documents.push(docData); 188 | seenDocuments[docPath] = true; 189 | } 190 | } 191 | }) 192 | ); 193 | } catch (err) { 194 | // TODO: handle error? 195 | throw err; 196 | } 197 | 198 | return documents; 199 | } 200 | 201 | processDocuments_( 202 | queries: firebase.firestore.Query[], 203 | documents: DocumentData[] 204 | ): DocumentData[] { 205 | if (documents.length === 0) { 206 | return []; 207 | } else { 208 | if (this._ast.groupby) { 209 | const groupedDocs = applyGroupByLocally(documents, this._ast.groupby); 210 | return this._processGroupedDocs(queries, groupedDocs); 211 | } else { 212 | return this._processUngroupedDocs(queries, documents); 213 | } 214 | } 215 | } 216 | 217 | private _processUngroupedDocs( 218 | queries: firebase.firestore.Query[], 219 | documents: DocumentData[] 220 | ): DocumentData[] { 221 | if (this._ast.orderby && queries.length > 1) { 222 | // We merged more than one query into a single set of documents 223 | // so we need to order the documents again, this time client-side. 224 | documents = applyOrderByLocally(documents, this._ast.orderby); 225 | } 226 | 227 | if (this._ast.limit && queries.length > 1) { 228 | // We merged more than one query into a single set of documents 229 | // so we need to apply the limit again, this time client-side. 230 | documents = applyLimitLocally(documents, this._ast.limit); 231 | } 232 | 233 | if (typeof this._ast.columns === 'string' && this._ast.columns === '*') { 234 | // Return all fields from the documents 235 | } else if (Array.isArray(this._ast.columns)) { 236 | const aggrColumns = getAggrColumns(this._ast.columns); 237 | 238 | if (aggrColumns.length > 0) { 239 | const docsGroup = new DocumentsGroup(); 240 | docsGroup.documents = documents; 241 | aggregateDocuments(docsGroup, aggrColumns); 242 | 243 | /// Since there is no GROUP BY and we already computed all 244 | // necessary aggregated values, at this point we only care 245 | // about the first document in the list. Anything else is 246 | // irrelevant. 247 | const resultEntry = this._buildResultEntry( 248 | docsGroup.documents[0], 249 | docsGroup.aggr 250 | ); 251 | 252 | documents = [resultEntry]; 253 | } else { 254 | documents = documents.map(doc => this._buildResultEntry(doc)); 255 | } 256 | } else { 257 | // We should never reach here 258 | throw new Error('Internal error (ast.columns).'); 259 | } 260 | 261 | return documents; 262 | } 263 | 264 | private _processGroupedDocs( 265 | queries: firebase.firestore.Query[], 266 | groupedDocs: GroupedDocuments 267 | ): DocumentData[] { 268 | assert(this._ast.columns !== '*', 'Cannot "SELECT *" when using GROUP BY.'); 269 | 270 | const aggrColumns = getAggrColumns(this._ast.columns); 271 | const groups = flattenGroupedDocs(groupedDocs); 272 | 273 | if (aggrColumns.length === 0) { 274 | // We're applying a GROUP BY but none of the fields requested 275 | // in the SELECT are an aggregate function. In this case we 276 | // just return an entry for the first document. 277 | const firstGroupKey = Object.keys(groups)[0]; 278 | const firstGroup = groups[firstGroupKey]; 279 | const firstDoc = firstGroup.documents[0]; 280 | return [this._buildResultEntry(firstDoc)]; 281 | } else { 282 | const results: DocumentData[] = []; 283 | 284 | // TODO: ORDER BY 285 | assert( 286 | !this._ast.orderby, 287 | 'ORDER BY is not yet supported when using GROUP BY.' 288 | ); 289 | 290 | // TODO: LIMIT 291 | assert( 292 | !this._ast.limit, 293 | 'LIMIT is not yet supported when using GROUP BY.' 294 | ); 295 | 296 | Object.keys(groups).forEach(groupKey => { 297 | const docsGroup = groups[groupKey]; 298 | aggregateDocuments(docsGroup, aggrColumns); 299 | 300 | const resultEntry = this._buildResultEntry( 301 | docsGroup.documents[0], 302 | docsGroup.aggr 303 | ); 304 | 305 | results.push(resultEntry); 306 | }); 307 | 308 | return results; 309 | } 310 | } 311 | 312 | private _buildResultEntry( 313 | document: DocumentData, 314 | aggregate?: GroupAggregateValues, 315 | asFieldArray?: false 316 | ): DocumentData; 317 | private _buildResultEntry( 318 | document: DocumentData, 319 | aggregate?: GroupAggregateValues, 320 | asFieldArray?: true 321 | ): AliasedField[]; 322 | private _buildResultEntry( 323 | document: DocumentData, 324 | aggregate?: GroupAggregateValues, 325 | asFieldArray = false 326 | ): DocumentData | AliasedField[] { 327 | let idIncluded = false; 328 | const columns = this._ast.columns as SQL_SelectColumn[]; 329 | 330 | const resultFields: AliasedField[] = columns.reduce( 331 | (entries: AliasedField[], column) => { 332 | let fieldName: string; 333 | let fieldAlias: string; 334 | 335 | switch (column.expr.type) { 336 | case 'column_ref': 337 | fieldName = column.expr.column; 338 | fieldAlias = nameOrAlias(fieldName, column.as); 339 | entries.push( 340 | new AliasedField( 341 | fieldName, 342 | fieldAlias, 343 | deepGet(document, fieldName) 344 | ) 345 | ); 346 | if (fieldName === DOCUMENT_KEY_NAME) { 347 | idIncluded = true; 348 | } 349 | break; 350 | 351 | case 'aggr_func': 352 | vaidateAggrFunction(column.expr); 353 | fieldName = column.expr.field; 354 | fieldAlias = nameOrAlias(fieldName, column.as, column.expr); 355 | entries.push( 356 | new AliasedField( 357 | fieldName, 358 | fieldAlias, 359 | (aggregate! as any)[column.expr.name.toLowerCase()][fieldName] 360 | ) 361 | ); 362 | break; 363 | 364 | default: 365 | throw new Error('Unsupported type in SELECT.'); 366 | } 367 | 368 | return entries; 369 | }, 370 | [] 371 | ); 372 | 373 | if (this._includeId && !idIncluded) { 374 | resultFields.push( 375 | new AliasedField( 376 | DOCUMENT_KEY_NAME, 377 | typeof this._includeId === 'string' 378 | ? this._includeId 379 | : DOCUMENT_KEY_NAME, 380 | safeGet(document, DOCUMENT_KEY_NAME) 381 | ) 382 | ); 383 | } 384 | 385 | if (asFieldArray) { 386 | return resultFields; 387 | } else { 388 | return resultFields.reduce((doc: DocumentData, field: AliasedField) => { 389 | doc[field.alias] = field.value; 390 | return doc; 391 | }, {}); 392 | } 393 | } 394 | } 395 | 396 | /*************************************************/ 397 | 398 | function aggregateDocuments( 399 | docsGroup: DocumentsGroup, 400 | functions: SQL_AggrFunction[] 401 | ): DocumentsGroup { 402 | const numDocs = docsGroup.documents.length; 403 | 404 | for (let i = 0; i < numDocs; i++) { 405 | const doc = docsGroup.documents[i]; 406 | 407 | // If the same field is used in more than one aggregate function 408 | // we don't want to sum its value more than once. 409 | const skipSum: { [field: string]: true } = {}; 410 | 411 | functions.forEach(fn => { 412 | let value = safeGet(doc, fn.field); 413 | const isNumber = !Number.isNaN(value); 414 | 415 | switch (fn.name) { 416 | case 'AVG': 417 | // Lets put a value so that later we know we have to compute this avg 418 | docsGroup.aggr.avg[fn.field] = 0; 419 | // tslint:disable-next-line:no-switch-case-fall-through 420 | case 'SUM': 421 | if (safeGet(skipSum, fn.field) !== true) { 422 | skipSum[fn.field] = true; 423 | if (!contains(docsGroup.aggr.total, fn.field)) { 424 | docsGroup.aggr.total[fn.field] = 0; 425 | docsGroup.aggr.sum[fn.field] = 0; 426 | } 427 | value = Number(value); 428 | assert( 429 | !Number.isNaN(value), 430 | `Can't compute aggregate function ${fn.name}(${ 431 | fn.field 432 | }) because some values are not numbers.` 433 | ); 434 | docsGroup.aggr.total[fn.field] += 1; 435 | docsGroup.aggr.sum[fn.field] += value; 436 | // FIXME: if the numbers are big we could easily go out of bounds in this sum 437 | } 438 | break; 439 | case 'MIN': 440 | assert( 441 | isNumber || typeof value === 'string', 442 | `Aggregate function MIN(${ 443 | fn.field 444 | }) can only be performed on numbers or strings` 445 | ); 446 | if (!contains(docsGroup.aggr.min, fn.field)) { 447 | docsGroup.aggr.min[fn.field] = value; 448 | } else { 449 | if (!Number.isNaN(docsGroup.aggr.min[fn.field] as any)) { 450 | // The current minimum is a number 451 | assert( 452 | isNumber, 453 | `Can't compute aggregate function MIN(${ 454 | fn.field 455 | }) because some values are not numbers.` 456 | ); 457 | value = Number(value); 458 | } 459 | if (value < docsGroup.aggr.min[fn.field]) { 460 | docsGroup.aggr.min[fn.field] = value; 461 | } 462 | } 463 | break; 464 | case 'MAX': 465 | assert( 466 | isNumber || typeof value === 'string', 467 | `Aggregate function MAX(${ 468 | fn.field 469 | }) can only be performed on numbers or strings` 470 | ); 471 | if (!contains(docsGroup.aggr.max, fn.field)) { 472 | docsGroup.aggr.max[fn.field] = value; 473 | } else { 474 | if (!Number.isNaN(docsGroup.aggr.max[fn.field] as any)) { 475 | // The current maximum is a number 476 | assert( 477 | isNumber, 478 | `Can't compute aggregate function MAX(${ 479 | fn.field 480 | }) because some values are not numbers.` 481 | ); 482 | value = Number(value); 483 | } 484 | if (value > docsGroup.aggr.max[fn.field]) { 485 | docsGroup.aggr.max[fn.field] = value; 486 | } 487 | } 488 | break; 489 | } 490 | }); 491 | } 492 | 493 | // Compute any necessary averages 494 | Object.keys(docsGroup.aggr.avg).forEach(group => { 495 | docsGroup.aggr.avg[group] = 496 | docsGroup.aggr.sum[group] / docsGroup.aggr.total[group]; 497 | }); 498 | 499 | return docsGroup; 500 | } 501 | 502 | function getAggrColumns(columns: SQL_SelectColumn[] | '*'): SQL_AggrFunction[] { 503 | const aggrColumns: SQL_AggrFunction[] = []; 504 | 505 | if (columns !== '*') { 506 | columns.forEach(astColumn => { 507 | if (astColumn.expr.type === 'aggr_func') { 508 | vaidateAggrFunction(astColumn.expr); 509 | aggrColumns.push(astColumn.expr); 510 | } else { 511 | assert( 512 | astColumn.expr.type === 'column_ref', 513 | 'Only field names and aggregate functions are supported in SELECT statements.' 514 | ); 515 | } 516 | }); 517 | } 518 | 519 | return aggrColumns; 520 | } 521 | 522 | function vaidateAggrFunction(aggrFn: SQL_AggrFunction) { 523 | // TODO: support COUNT, then remove this assert 524 | assert( 525 | aggrFn.name !== 'COUNT', 526 | 'Aggregate function COUNT is not yet supported.' 527 | ); 528 | 529 | assert( 530 | VALID_AGGR_FUNCTIONS.includes(aggrFn.name), 531 | `Unknown aggregate function '${aggrFn.name}'.` 532 | ); 533 | 534 | assert( 535 | // tslint:disable-next-line: strict-type-predicates 536 | typeof aggrFn.field === 'string', 537 | `Unsupported type in aggregate function '${aggrFn.name}'.` 538 | ); 539 | } 540 | 541 | function flattenGroupedDocs( 542 | groupedDocs: GroupedDocuments 543 | ): { [k: string]: DocumentsGroup } { 544 | let result: { [k: string]: any } = {}; 545 | 546 | for (let prop in groupedDocs) { 547 | if (!contains(groupedDocs, prop)) { 548 | continue; 549 | } 550 | 551 | if (!(groupedDocs[prop] instanceof DocumentsGroup)) { 552 | let flatInner = flattenGroupedDocs(groupedDocs[prop] as GroupedDocuments); 553 | 554 | for (let innerProp in flatInner) { 555 | if (!contains(flatInner, innerProp)) { 556 | continue; 557 | } 558 | result[prop + '$$' + innerProp] = flatInner[innerProp]; 559 | } 560 | } else { 561 | result[prop] = groupedDocs[prop]; 562 | } 563 | } 564 | return result; 565 | } 566 | 567 | /** 568 | * Represents a field (prop) in a document. 569 | * It stores the original field name, the assigned alias, and the value. 570 | * 571 | * This is necessary in order to properly apply ORDER BY once 572 | * a result set has been built. 573 | */ 574 | class AliasedField { 575 | constructor(public name: string, public alias: string, public value: any) {} 576 | } 577 | -------------------------------------------------------------------------------- /tools/tests/data.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "collection": "shops", 4 | "docs": [ 5 | { 6 | "key": "iQtESOmVgvWTswFdDG4mZHWF", 7 | "data": { 8 | "name": "Brekke-Maggio", 9 | "category": "Computers", 10 | "slogan": null, 11 | "rating": 2.7, 12 | "tags": ["content-based", "Decentralized", "exuding"], 13 | "manager": { "name": "Madge McRinn", "phone": "757-181-4467" }, 14 | "contact": { 15 | "address": "1 Hoffman Road", 16 | "city": "Newport News", 17 | "postal": "23605", 18 | "state": "Virginia" 19 | } 20 | }, 21 | "collections": [ 22 | { 23 | "collection": "products", 24 | "docs": [ 25 | { 26 | "key": "lzLj3iQ3D01i3CqNxjmAysR1", 27 | "data": { 28 | "name": "Sugar - Monocystal / Rock", 29 | "price": 165.7, 30 | "stock": 5 31 | } 32 | } 33 | ] 34 | } 35 | ] 36 | }, 37 | { 38 | "key": "UOZ8h3o6c80ubr4QB67eMglh", 39 | "data": { 40 | "name": "Waelchi, Schultz and Skiles", 41 | "category": "Jewelery", 42 | "slogan": "incubate front-end schemas", 43 | "rating": 4.3, 44 | "tags": ["systemic", "help-desk", "projection"], 45 | "manager": { "name": "Colas Exall", "phone": "248-275-8817" }, 46 | "contact": { 47 | "address": "1 Linden Avenue", 48 | "city": "Troy", 49 | "postal": "48098", 50 | "state": "Michigan" 51 | } 52 | }, 53 | "collections": [ 54 | { 55 | "collection": "products", 56 | "docs": [ 57 | { 58 | "key": "xDkARJ9Vnjo9ByXcfkLhVxbV", 59 | "data": { 60 | "name": "Tart Shells - Barquettes, Savory", 61 | "price": 8.63, 62 | "stock": 4 63 | } 64 | } 65 | ] 66 | } 67 | ] 68 | }, 69 | { 70 | "key": "eemgSg0MG3fSt2zlHrFneyxT", 71 | "data": { 72 | "name": "Goodwin-Wehner", 73 | "category": "Outdoors", 74 | "slogan": "recontextualize customized ROI", 75 | "rating": 0.9, 76 | "tags": [], 77 | "manager": { "name": "Raddy O'Hagerty", "phone": "309-144-5119" }, 78 | "contact": { 79 | "address": "6 Melvin Hill", 80 | "city": "Peoria", 81 | "postal": "61614", 82 | "state": "Illinois" 83 | } 84 | }, 85 | "collections": [ 86 | { 87 | "collection": "products", 88 | "docs": [ 89 | { 90 | "key": "EDGVVlwgteZ0l3sbDWr4ZRWq", 91 | "data": { 92 | "name": "Foil Cont Round", 93 | "price": 384.82, 94 | "stock": 10 95 | } 96 | }, 97 | { 98 | "key": "IBtbMHmxivy3IUFE6LeYvGFq", 99 | "data": { 100 | "name": "Cardamon Seed / Pod", 101 | "price": 438.17, 102 | "stock": 9 103 | } 104 | } 105 | ] 106 | } 107 | ] 108 | }, 109 | { 110 | "key": "oKLDUZxhh3jrMExQdyi3wO3n", 111 | "data": { 112 | "name": "Goyette-Watsica", 113 | "category": "Tools", 114 | "slogan": "generate impactful platforms", 115 | "rating": 1.4, 116 | "tags": ["circuit", "scalable"], 117 | "manager": { "name": "Harrietta Bettinson", "phone": "775-938-9617" }, 118 | "contact": { 119 | "address": "40 Mccormick Drive", 120 | "city": "Sparks", 121 | "postal": "89436", 122 | "state": "Nevada" 123 | } 124 | }, 125 | "collections": [ 126 | { 127 | "collection": "products", 128 | "docs": [ 129 | { 130 | "key": "27agvFfRVIBEYxOAwKJw1KJD", 131 | "data": { 132 | "name": "Tart - Pecan Butter Squares", 133 | "price": 223.64, 134 | "stock": 8 135 | } 136 | }, 137 | { 138 | "key": "hg7U1RSir5UJRiaARvJBzF4U", 139 | "data": { 140 | "name": "Sprouts - Peppercress", 141 | "price": 64.42, 142 | "stock": 1 143 | } 144 | }, 145 | { 146 | "key": "8Dev851EUjUF3h1MRFoeJoEI", 147 | "data": { 148 | "name": "Pears - Fiorelle", 149 | "price": 126.3, 150 | "stock": 2 151 | } 152 | }, 153 | { 154 | "key": "QxVRlodU8tWr4fqFmjhQN6LV", 155 | "data": { 156 | "name": "Potatoes - Peeled", 157 | "price": 218.48, 158 | "stock": 7 159 | } 160 | } 161 | ] 162 | } 163 | ] 164 | }, 165 | { 166 | "key": "bTREJwuC2BdiRiR4sbIirua7", 167 | "data": { 168 | "name": "Murazik-Grady", 169 | "category": "Tools", 170 | "slogan": null, 171 | "rating": 0.7, 172 | "tags": [], 173 | "manager": { "name": "Cherie Moogan", "phone": "239-388-6881" }, 174 | "contact": { 175 | "address": "1 Helena Junction", 176 | "city": "Naples", 177 | "postal": "33963", 178 | "state": "Florida" 179 | } 180 | }, 181 | "collections": [ 182 | { 183 | "collection": "products", 184 | "docs": [ 185 | { 186 | "key": "TtTzkUfDLfRlIaJDCbTA94E9", 187 | "data": { 188 | "name": "Lemonade - Strawberry, 591 Ml", 189 | "price": 300.3, 190 | "stock": 2 191 | } 192 | }, 193 | { 194 | "key": "mKKthrtRiASiJgjWwsQaBPRT", 195 | "data": { 196 | "name": "Bread - Rosemary Focaccia", 197 | "price": 230.22, 198 | "stock": 0 199 | } 200 | } 201 | ] 202 | } 203 | ] 204 | }, 205 | { 206 | "key": "QjNHCAwKLvEVDzN0XdrRM7OO", 207 | "data": { 208 | "name": "Price Inc", 209 | "category": "Industrial", 210 | "slogan": null, 211 | "rating": 2.7, 212 | "tags": [], 213 | "manager": { "name": "Ad Sibbit", "phone": "203-960-2716" }, 214 | "contact": { 215 | "address": "3 Merry Pass", 216 | "city": "Norwalk", 217 | "postal": "06854", 218 | "state": "Connecticut" 219 | } 220 | }, 221 | "collections": [ 222 | { 223 | "collection": "products", 224 | "docs": [ 225 | { 226 | "key": "yc3LRJpNoji3GklRE3yLvWZ7", 227 | "data": { 228 | "name": "Cabbage - Red", 229 | "price": 263.16, 230 | "stock": 10 231 | } 232 | }, 233 | { 234 | "key": "RBXVWB4mhjYk2lnLO540jySu", 235 | "data": { 236 | "name": "Chickhen - Chicken Phyllo", 237 | "price": 219.12, 238 | "stock": 10 239 | } 240 | }, 241 | { 242 | "key": "wMDiliruhC6Vp0z4qHWrgfhk", 243 | "data": { 244 | "name": "Ham Black Forest", 245 | "price": 484.93, 246 | "stock": 2 247 | } 248 | } 249 | ] 250 | } 251 | ] 252 | }, 253 | { 254 | "key": "cs7w8ieti0LnRw2q27qzOHPU", 255 | "data": { 256 | "name": "Robel Inc", 257 | "category": "Jewelery", 258 | "slogan": "syndicate innovative interfaces", 259 | "rating": 4.4, 260 | "tags": ["hierarchy", "actuating", "Monitored"], 261 | "manager": { "name": "Ethan Dennison", "phone": "815-606-3622" }, 262 | "contact": { 263 | "address": "910 Basil Crossing", 264 | "city": "Rockford", 265 | "postal": "61105", 266 | "state": "Illinois" 267 | } 268 | }, 269 | "collections": [ 270 | { 271 | "collection": "products", 272 | "docs": [ 273 | { 274 | "key": "9Jqw5cxzTWFqV0RJmeXovnY1", 275 | "data": { 276 | "name": "Chicken - Wieners", 277 | "price": 120.73, 278 | "stock": 4 279 | } 280 | }, 281 | { 282 | "key": "HN0o84W82X7tyInUHzhSM947", 283 | "data": { 284 | "name": "Wine - Sauvignon Blanc Oyster", 285 | "price": 285.28, 286 | "stock": 8 287 | } 288 | } 289 | ] 290 | } 291 | ] 292 | }, 293 | { 294 | "key": "7S22Tb0oDGgykQNYLJPZCGQP", 295 | "data": { 296 | "name": "Jakubowski, Doyle and Reilly", 297 | "category": null, 298 | "slogan": "synergize holistic convergence", 299 | "rating": 2.4, 300 | "tags": [], 301 | "manager": { "name": "Shandee Dur", "phone": "202-118-5597" }, 302 | "contact": { 303 | "address": "6265 Petterle Trail", 304 | "city": "Washington", 305 | "postal": "20575", 306 | "state": "District of Columbia" 307 | } 308 | }, 309 | "collections": [ 310 | { 311 | "collection": "products", 312 | "docs": [ 313 | { 314 | "key": "aDoi3gFTHUXLcK4RnNzFvCjU", 315 | "data": { 316 | "name": "Capon - Breast, Double, Wing On", 317 | "price": 302.4, 318 | "stock": 7 319 | } 320 | }, 321 | { 322 | "key": "MNi7y7AzGFQRaYRyaWILnm3p", 323 | "data": { 324 | "name": "Bread - Kimel Stick Poly", 325 | "price": 339.33, 326 | "stock": 9 327 | } 328 | }, 329 | { 330 | "key": "g8v8keky8a2bCMQSTu2Los8k", 331 | "data": { 332 | "name": "Ice Cream Bar - Oreo Sandwich", 333 | "price": 255.4, 334 | "stock": 8 335 | } 336 | }, 337 | { 338 | "key": "DrdwcxeYaxNLJRnbrHXqohkO", 339 | "data": { 340 | "name": "Bread Base - Toscano", 341 | "price": 90.87, 342 | "stock": 5 343 | } 344 | } 345 | ] 346 | } 347 | ] 348 | }, 349 | { 350 | "key": "Ce9kmwDmtH0SmcTZ9mRGuuxl", 351 | "data": { 352 | "name": "Dickinson, Predovic and Cummerata", 353 | "category": "Shoes", 354 | "slogan": "integrate extensible web services", 355 | "rating": 0.6, 356 | "tags": ["hierarchy", "Implemented"], 357 | "manager": { "name": "Kit Shorten", "phone": "405-669-5274" }, 358 | "contact": { 359 | "address": "7 Hazelcrest Place", 360 | "city": "Oklahoma City", 361 | "postal": "73114", 362 | "state": "Oklahoma" 363 | } 364 | }, 365 | "collections": [ 366 | { 367 | "collection": "products", 368 | "docs": [ 369 | { 370 | "key": "3w28M4WDB4byyiNd2adMlqXl", 371 | "data": { 372 | "name": "Nori Sea Weed - Gold Label", 373 | "price": 446.48, 374 | "stock": 3 375 | } 376 | }, 377 | { 378 | "key": "o6eO73rIsYrRs87bnkCOP6IQ", 379 | "data": { 380 | "name": "Wine - Baron De Rothschild", 381 | "price": 5.86, 382 | "stock": 4 383 | } 384 | }, 385 | { 386 | "key": "HW4ZMKtBBXNSqcFIZ5iF4xaH", 387 | "data": { 388 | "name": "Wine - Chateau Timberlay", 389 | "price": 259.24, 390 | "stock": 5 391 | } 392 | }, 393 | { 394 | "key": "ugimEsOvWAjtBnGkoK47VuZA", 395 | "data": { 396 | "name": "Juice - Grapefruit, 341 Ml", 397 | "price": 236.08, 398 | "stock": 2 399 | } 400 | }, 401 | { 402 | "key": "tRzz756u8D1CfbQiCVcLWZg3", 403 | "data": { 404 | "name": "Tomatoes Tear Drop Yellow", 405 | "price": 126.41, 406 | "stock": 0 407 | } 408 | } 409 | ] 410 | } 411 | ] 412 | }, 413 | { 414 | "key": "ERniL8eHjx77mfVkW627tb1K", 415 | "data": { 416 | "name": "Grady, Kirlin and Welch", 417 | "category": "Automotive", 418 | "slogan": "expedite world-class networks", 419 | "rating": 5.0, 420 | "tags": ["Total", "background"], 421 | "manager": { "name": "Cher Lawfull", "phone": "253-305-6226" }, 422 | "contact": { 423 | "address": "6607 Crescent Oaks Parkway", 424 | "city": "Tacoma", 425 | "postal": "98417", 426 | "state": "Washington" 427 | } 428 | }, 429 | "collections": [ 430 | { 431 | "collection": "products", 432 | "docs": [ 433 | { 434 | "key": "ZinF3o6gFVi5qw5iHsMfr5gp", 435 | "data": { 436 | "name": "Sobe - Orange Carrot", 437 | "price": 137.72, 438 | "stock": 9 439 | } 440 | }, 441 | { 442 | "key": "4C7oOu8c2U8vXx6FGPdDJVje", 443 | "data": { 444 | "name": "Bar Bran Honey Nut", 445 | "price": 37.03, 446 | "stock": 2 447 | } 448 | }, 449 | { 450 | "key": "c1nGWuW6mCwXGegJ0hYvcBte", 451 | "data": { 452 | "name": "Wine - Semi Dry Riesling Vineland", 453 | "price": 316.56, 454 | "stock": 5 455 | } 456 | }, 457 | { 458 | "key": "2qgMzDiC3203UZWC78RLvlug", 459 | "data": { 460 | "name": "Gelatine Leaves - Bulk", 461 | "price": 131.45, 462 | "stock": 10 463 | } 464 | }, 465 | { 466 | "key": "IjCdVJ5ipRpzoeJ30QQGifRi", 467 | "data": { 468 | "name": "Wakami Seaweed", 469 | "price": 373.78, 470 | "stock": 5 471 | } 472 | } 473 | ] 474 | } 475 | ] 476 | }, 477 | { 478 | "key": "IuJmDsXXzYn42QMeH0L19NT0", 479 | "data": { 480 | "name": "Adams-Nikolaus", 481 | "category": "Health", 482 | "slogan": null, 483 | "rating": 4.0, 484 | "tags": ["Secured", "Fundamental", "neutral", "analyzer"], 485 | "manager": { "name": "Xavier Pesik", "phone": "505-406-7156" }, 486 | "contact": { 487 | "address": "626 Fairfield Drive", 488 | "city": "Las Cruces", 489 | "postal": "88006", 490 | "state": "New Mexico" 491 | } 492 | }, 493 | "collections": [ 494 | { 495 | "collection": "products", 496 | "docs": [ 497 | { 498 | "key": "2Idqo1tjfjdMZSPfSnKlnmKf", 499 | "data": { "name": "Veal - Bones", "price": 59.19, "stock": 0 } 500 | }, 501 | { 502 | "key": "2UiU8IOpIt84O0CMm23pjLiu", 503 | "data": { "name": "Pickle - Dill", "price": 117.92, "stock": 8 } 504 | }, 505 | { 506 | "key": "e8yKGB0EjQs6Ai2W0K7aIHez", 507 | "data": { 508 | "name": "Food Colouring - Green", 509 | "price": 100.25, 510 | "stock": 8 511 | } 512 | }, 513 | { 514 | "key": "BzjSiFwbi9fgCWT2Jqn78Zbm", 515 | "data": { 516 | "name": "Lamb Tenderloin Nz Fr", 517 | "price": 397.45, 518 | "stock": 3 519 | } 520 | } 521 | ] 522 | } 523 | ] 524 | }, 525 | { 526 | "key": "MpDoZKp0rk248vSFFgxBfDD1", 527 | "data": { 528 | "name": "Rath and Sons", 529 | "category": "Books", 530 | "slogan": null, 531 | "rating": 0.5, 532 | "tags": [], 533 | "manager": { "name": null, "phone": null }, 534 | "contact": { 535 | "address": "07367 Stephen Terrace", 536 | "city": "Colorado Springs", 537 | "postal": "80995", 538 | "state": "Colorado" 539 | } 540 | }, 541 | "collections": [ 542 | { 543 | "collection": "products", 544 | "docs": [ 545 | { 546 | "key": "CIckONgHCbX0BmTuHjXbQMUM", 547 | "data": { 548 | "name": "Juice - Grape, White", 549 | "price": 5.72, 550 | "stock": 7 551 | } 552 | }, 553 | { 554 | "key": "TR4DQmYDgwRfVBGKoHuqSCVn", 555 | "data": { "name": "Cream - 35%", "price": 353.99, "stock": 9 } 556 | }, 557 | { 558 | "key": "TGHGJIHL7gkZoU6gBGtSeFmF", 559 | "data": { 560 | "name": "Beef - Flank Steak", 561 | "price": 345.87, 562 | "stock": 9 563 | } 564 | } 565 | ] 566 | } 567 | ] 568 | }, 569 | { 570 | "key": "DBjzSQfUzU9QGWzGU6AIvLRI", 571 | "data": { 572 | "name": "Grant, Wisoky and Baumbach", 573 | "category": "Home", 574 | "slogan": null, 575 | "rating": 4.8, 576 | "tags": [], 577 | "manager": { "name": "Dani Rosenstiel", "phone": "562-942-1534" }, 578 | "contact": { 579 | "address": "2 Dexter Junction", 580 | "city": "Huntington Beach", 581 | "postal": "92648", 582 | "state": "California" 583 | } 584 | }, 585 | "collections": [ 586 | { 587 | "collection": "products", 588 | "docs": [ 589 | { 590 | "key": "yxdItHSw9Pen7xduNQVRflT6", 591 | "data": { 592 | "name": "Wine - Chateau Bonnet", 593 | "price": 25.85, 594 | "stock": 7 595 | } 596 | }, 597 | { 598 | "key": "rIrC7652QU1CNhzEjizKMihH", 599 | "data": { 600 | "name": "Orange Roughy 6/8 Oz", 601 | "price": 148.58, 602 | "stock": 0 603 | } 604 | }, 605 | { 606 | "key": "jBXR3xkuhiLsE3BbBljZAQfs", 607 | "data": { 608 | "name": "Wine - Trimbach Pinot Blanc", 609 | "price": 325.67, 610 | "stock": 3 611 | } 612 | }, 613 | { 614 | "key": "1f6goy9emuMebBcavFQ8OCdn", 615 | "data": { 616 | "name": "Cake Circle, Paprus", 617 | "price": 71.14, 618 | "stock": 0 619 | } 620 | }, 621 | { 622 | "key": "UC7hDbDPhFVfkoeBAlhdNLrZ", 623 | "data": { 624 | "name": "Energy Drink - Franks Original", 625 | "price": 376.98, 626 | "stock": 3 627 | } 628 | } 629 | ] 630 | } 631 | ] 632 | }, 633 | { 634 | "key": "6w1VkN7wrTc2qOtI8FHrHMNN", 635 | "data": { 636 | "name": "Orn-Auer", 637 | "category": "Outdoors", 638 | "slogan": null, 639 | "rating": 0.6, 640 | "tags": ["directional"], 641 | "manager": { "name": "Ernestine Blampey", "phone": "213-961-7212" }, 642 | "contact": { 643 | "address": "9206 Mitchell Way", 644 | "city": "Van Nuys", 645 | "postal": "91411", 646 | "state": "California" 647 | } 648 | }, 649 | "collections": [ 650 | { 651 | "collection": "products", 652 | "docs": [ 653 | { 654 | "key": "pfaCCAr8Qnj8r6w32V7TXiB6", 655 | "data": { "name": "Leeks - Large", "price": 354.85, "stock": 1 } 656 | }, 657 | { 658 | "key": "XMNXuQqHLnY3vDZ8pjvHAZSF", 659 | "data": { 660 | "name": "Black Currants", 661 | "price": 418.55, 662 | "stock": 6 663 | } 664 | }, 665 | { 666 | "key": "4yqYza3SU9vlt9WHM1hJxrzA", 667 | "data": { 668 | "name": "Pie Filling - Pumpkin", 669 | "price": 471.74, 670 | "stock": 8 671 | } 672 | }, 673 | { 674 | "key": "G9gAg0WTFaqGBtbnql9u98xm", 675 | "data": { 676 | "name": "Muffin Chocolate Individual Wrap", 677 | "price": 404.3, 678 | "stock": 8 679 | } 680 | }, 681 | { 682 | "key": "uubLX97LRrtQm9nR8YnBRaDI", 683 | "data": { 684 | "name": "Rum - Light, Captain Morgan", 685 | "price": 414.31, 686 | "stock": 9 687 | } 688 | } 689 | ] 690 | } 691 | ] 692 | }, 693 | { 694 | "key": "XHxQE9y69lKkPVwnVPgKWXIV", 695 | "data": { 696 | "name": "Rempel, Stoltenberg and Gleason", 697 | "category": "Music", 698 | "slogan": "unleash cross-media eyeballs", 699 | "rating": 2.9, 700 | "tags": ["Self-enabling", "capacity", "leading edge", "framework"], 701 | "manager": { "name": "Jacky Elven", "phone": "303-173-9278" }, 702 | "contact": { 703 | "address": "3 Reindahl Street", 704 | "city": "Denver", 705 | "postal": "80209", 706 | "state": "Colorado" 707 | } 708 | }, 709 | "collections": [ 710 | { 711 | "collection": "products", 712 | "docs": [ 713 | { 714 | "key": "ZxUHbVcVmhT2SOGN7Z2OyjWG", 715 | "data": { 716 | "name": "Salmon Atl.whole 8 - 10 Lb", 717 | "price": 413.86, 718 | "stock": 2 719 | } 720 | }, 721 | { 722 | "key": "O4MX99RSrhMj3Dte8s4aOhQa", 723 | "data": { 724 | "name": "Melon - Cantaloupe", 725 | "price": 194.99, 726 | "stock": 7 727 | } 728 | } 729 | ] 730 | } 731 | ] 732 | }, 733 | { 734 | "key": "ZIxTBimREsEBlvzrlxmssrQv", 735 | "data": { 736 | "name": "Carroll Group", 737 | "category": "Toys", 738 | "slogan": null, 739 | "rating": 0.2, 740 | "tags": [], 741 | "manager": { "name": "Basia Bestman", "phone": "614-909-2132" }, 742 | "contact": { 743 | "address": "1389 Manley Plaza", 744 | "city": "Columbus", 745 | "postal": "43226", 746 | "state": "Ohio" 747 | } 748 | }, 749 | "collections": [ 750 | { 751 | "collection": "products", 752 | "docs": [ 753 | { 754 | "key": "hVtDqpphgHCjrlyinJkgLe2L", 755 | "data": { 756 | "name": "Bamboo Shoots - Sliced", 757 | "price": 337.06, 758 | "stock": 7 759 | } 760 | }, 761 | { 762 | "key": "wHiFno8XoUuPwXNACLm510Zd", 763 | "data": { "name": "Cream - 10%", "price": 221.24, "stock": 0 } 764 | } 765 | ] 766 | } 767 | ] 768 | }, 769 | { 770 | "key": "KhARRWcThnIiUAkBaUlLAW0G", 771 | "data": { 772 | "name": "Hammes, Dooley and Feeney", 773 | "category": "Computers", 774 | "slogan": "optimize distributed experiences", 775 | "rating": 2.3, 776 | "tags": ["Intuitive"], 777 | "manager": { "name": "Alis Nortunen", "phone": "704-257-3966" }, 778 | "contact": { 779 | "address": "8 Westport Trail", 780 | "city": "Winston Salem", 781 | "postal": "27105", 782 | "state": "North Carolina" 783 | } 784 | }, 785 | "collections": [ 786 | { 787 | "collection": "products", 788 | "docs": [ 789 | { 790 | "key": "uwDkDkYTYog6wZpkFYACYvro", 791 | "data": { 792 | "name": "Salt - Rock, Course", 793 | "price": 438.07, 794 | "stock": 8 795 | } 796 | } 797 | ] 798 | } 799 | ] 800 | }, 801 | { 802 | "key": "O0xLcFLDKWu1PKMR5jrqbltX", 803 | "data": { 804 | "name": "Christiansen, Abernathy and Zboncak", 805 | "category": "Beauty", 806 | "slogan": null, 807 | "rating": 4.6, 808 | "tags": [], 809 | "manager": { "name": "Amelina Hodgins", "phone": "571-573-8240" }, 810 | "contact": { 811 | "address": "90702 Anhalt Crossing", 812 | "city": "Arlington", 813 | "postal": "22205", 814 | "state": "Virginia" 815 | } 816 | }, 817 | "collections": [ 818 | { 819 | "collection": "products", 820 | "docs": [ 821 | { 822 | "key": "lFPVfL0bxdoCY7fOJL5WqXH8", 823 | "data": { 824 | "name": "Wine - Acient Coast Caberne", 825 | "price": 391.03, 826 | "stock": 1 827 | } 828 | }, 829 | { 830 | "key": "Hiaik6hMy58gxnDpvAw28aDf", 831 | "data": { 832 | "name": "Puff Pastry - Slab", 833 | "price": 424.22, 834 | "stock": 10 835 | } 836 | }, 837 | { 838 | "key": "3qvZiGcA814DRevIxicDd0J1", 839 | "data": { 840 | "name": "Shrimp - 31/40", 841 | "price": 420.64, 842 | "stock": 0 843 | } 844 | }, 845 | { 846 | "key": "22kfqfOThIJYJPYvdVmWAPrf", 847 | "data": { 848 | "name": "Oregano - Fresh", 849 | "price": 402.92, 850 | "stock": 1 851 | } 852 | } 853 | ] 854 | } 855 | ] 856 | }, 857 | { 858 | "key": "zS20rHB5yU79GI7ueNRkLUDh", 859 | "data": { 860 | "name": "Frami, Reynolds and Fay", 861 | "category": "Games", 862 | "slogan": null, 863 | "rating": 0.4, 864 | "tags": ["artificial intelligence", "policy"], 865 | "manager": { "name": "Christen Burkett", "phone": "515-292-4748" }, 866 | "contact": { 867 | "address": "7 Aberg Pass", 868 | "city": "Des Moines", 869 | "postal": "50320", 870 | "state": "Iowa" 871 | } 872 | }, 873 | "collections": [ 874 | { 875 | "collection": "products", 876 | "docs": [ 877 | { 878 | "key": "gHGt9ICF2OLqUxWt51yQBJWJ", 879 | "data": { 880 | "name": "Bread - Hot Dog Buns", 881 | "price": 304.89, 882 | "stock": 0 883 | } 884 | }, 885 | { 886 | "key": "Edz5Hdm2hq1PrdpAVJQqj6wB", 887 | "data": { "name": "Samosa - Veg", "price": 350.23, "stock": 1 } 888 | } 889 | ] 890 | } 891 | ] 892 | }, 893 | { 894 | "key": "A2AwXRvhW3HmEivfS5LPH3s8", 895 | "data": { 896 | "name": "Price, Monahan and Bogisich", 897 | "category": "Shoes", 898 | "slogan": "synergize web-enabled e-markets", 899 | "rating": 0.6, 900 | "tags": ["systematic", "toolset", "directional"], 901 | "manager": { "name": "Aila Gawthorp", "phone": "857-856-1845" }, 902 | "contact": { 903 | "address": "32427 Morrow Alley", 904 | "city": "Watertown", 905 | "postal": "02472", 906 | "state": "Massachusetts" 907 | } 908 | }, 909 | "collections": [ 910 | { 911 | "collection": "products", 912 | "docs": [ 913 | { 914 | "key": "Q0RAj3uvEHRRqCoGe7PAPbms", 915 | "data": { 916 | "name": "Flour - All Purpose", 917 | "price": 123.54, 918 | "stock": 0 919 | } 920 | }, 921 | { 922 | "key": "RSLBnzvhW2cs6gb4Tal4oyIn", 923 | "data": { "name": "Glycerine", "price": 340.49, "stock": 3 } 924 | }, 925 | { 926 | "key": "oUBbEMCTjfxq69B8n11Z8Xqb", 927 | "data": { 928 | "name": "Tomatoes Tear Drop Yellow", 929 | "price": 345.64, 930 | "stock": 2 931 | } 932 | }, 933 | { 934 | "key": "8YYTJVuoZeL4JPyoRLMqLOE2", 935 | "data": { 936 | "name": "Gloves - Goldtouch Disposable", 937 | "price": 288.22, 938 | "stock": 3 939 | } 940 | } 941 | ] 942 | } 943 | ] 944 | }, 945 | { 946 | "key": "Lz2BG0b6A59RsgiLFgKqQZAA", 947 | "data": { 948 | "name": "Baumbach-Pfannerstill", 949 | "category": "Outdoors", 950 | "slogan": "reinvent global e-business", 951 | "rating": 4.1, 952 | "tags": [], 953 | "manager": { "name": "Killy Reditt", "phone": "831-123-7515" }, 954 | "contact": { 955 | "address": "7030 Dawn Street", 956 | "city": "Salinas", 957 | "postal": "93907", 958 | "state": "California" 959 | } 960 | }, 961 | "collections": [ 962 | { 963 | "collection": "products", 964 | "docs": [ 965 | { 966 | "key": "cMLFTuyThO6QYoD40N0z2G8z", 967 | "data": { 968 | "name": "Wine - Masi Valpolocell", 969 | "price": 78.67, 970 | "stock": 6 971 | } 972 | }, 973 | { 974 | "key": "5lRzY30CvqXp7llygAVmrUci", 975 | "data": { 976 | "name": "Beans - Black Bean, Dry", 977 | "price": 68.41, 978 | "stock": 9 979 | } 980 | }, 981 | { 982 | "key": "50G0sLBzhE9m0D3dHk07egm4", 983 | "data": { 984 | "name": "Sauce - Caesar Dressing", 985 | "price": 497.08, 986 | "stock": 6 987 | } 988 | } 989 | ] 990 | } 991 | ] 992 | }, 993 | { 994 | "key": "Mf4dycssxZPWWq3zbQAdoNc7", 995 | "data": { 996 | "name": "Nikolaus-Borer", 997 | "category": "Home", 998 | "slogan": null, 999 | "rating": 0.4, 1000 | "tags": ["Assimilated"], 1001 | "manager": { "name": "John Veracruysse", "phone": "605-198-4716" }, 1002 | "contact": { 1003 | "address": "348 Crowley Lane", 1004 | "city": "Sioux Falls", 1005 | "postal": "57105", 1006 | "state": "South Dakota" 1007 | } 1008 | }, 1009 | "collections": [ 1010 | { 1011 | "collection": "products", 1012 | "docs": [ 1013 | { 1014 | "key": "Ioh2YOIsq2jEntyIeMbcl9p8", 1015 | "data": { 1016 | "name": "Heavy Duty Dust Pan", 1017 | "price": 106.4, 1018 | "stock": 2 1019 | } 1020 | }, 1021 | { 1022 | "key": "C7xs9TokUFEm2cZZSVdP7RhI", 1023 | "data": { 1024 | "name": "Cheese - Cottage Cheese", 1025 | "price": 107.03, 1026 | "stock": 1 1027 | } 1028 | }, 1029 | { 1030 | "key": "7C9m7SuqwlFKh42CKcHGeIYp", 1031 | "data": { "name": "Sauce - Plum", "price": 356.52, "stock": 3 } 1032 | }, 1033 | { 1034 | "key": "Cmw1cVhkGfUxCxSTtLuCaiiE", 1035 | "data": { 1036 | "name": "Mushroom - Morels, Dry", 1037 | "price": 376.39, 1038 | "stock": 0 1039 | } 1040 | } 1041 | ] 1042 | } 1043 | ] 1044 | }, 1045 | { 1046 | "key": "Bv5uEz7ASEt6DC2dfxDD2xlD", 1047 | "data": { 1048 | "name": "Stiedemann, Keeling and Carter", 1049 | "category": "Toys", 1050 | "slogan": "incubate B2C ROI", 1051 | "rating": 4.0, 1052 | "tags": [], 1053 | "manager": { "name": "Haleigh Dengate", "phone": "512-763-4298" }, 1054 | "contact": { 1055 | "address": "1 Everett Pass", 1056 | "city": "Austin", 1057 | "postal": "78754", 1058 | "state": "Texas" 1059 | } 1060 | }, 1061 | "collections": [ 1062 | { 1063 | "collection": "products", 1064 | "docs": [ 1065 | { 1066 | "key": "frNmlq7M27MYpq60JMPx9Bsz", 1067 | "data": { "name": "Pectin", "price": 31.27, "stock": 6 } 1068 | }, 1069 | { 1070 | "key": "H5O35IlkOBt3Oi2dBWzZ0w7r", 1071 | "data": { 1072 | "name": "Squeeze Bottle", 1073 | "price": 465.04, 1074 | "stock": 1 1075 | } 1076 | } 1077 | ] 1078 | } 1079 | ] 1080 | }, 1081 | { 1082 | "key": "rqUJXW9GD9mFzzeZy4RMU7x3", 1083 | "data": { 1084 | "name": "Cummings Inc", 1085 | "category": "Automotive", 1086 | "slogan": null, 1087 | "rating": 3.1, 1088 | "tags": [], 1089 | "manager": { "name": "Tucker Dore", "phone": "904-935-8232" }, 1090 | "contact": { 1091 | "address": "974 Farragut Plaza", 1092 | "city": "Jacksonville", 1093 | "postal": "32204", 1094 | "state": "Florida" 1095 | } 1096 | }, 1097 | "collections": [ 1098 | { 1099 | "collection": "products", 1100 | "docs": [ 1101 | { 1102 | "key": "pZnicW6xNc5HTekzNZiCk3Dy", 1103 | "data": { 1104 | "name": "Garbage Bag - Clear", 1105 | "price": 110.91, 1106 | "stock": 2 1107 | } 1108 | }, 1109 | { 1110 | "key": "bmsqsC1uo2lOVQNd36OlrB2u", 1111 | "data": { "name": "Currants", "price": 168.69, "stock": 4 } 1112 | }, 1113 | { 1114 | "key": "Zb6Zz2NWXOEe2EJD34phg93q", 1115 | "data": { 1116 | "name": "Chicken - Leg / Back Attach", 1117 | "price": 134.44, 1118 | "stock": 0 1119 | } 1120 | }, 1121 | { 1122 | "key": "ipCb2mIzgjDqotgex4z4znzV", 1123 | "data": { 1124 | "name": "Beer - Heinekin", 1125 | "price": 128.19, 1126 | "stock": 10 1127 | } 1128 | } 1129 | ] 1130 | } 1131 | ] 1132 | }, 1133 | { 1134 | "key": "NGRPDquKd3abC8j5JMoU2eEU", 1135 | "data": { 1136 | "name": "Marks-Shanahan", 1137 | "category": "Health", 1138 | "slogan": null, 1139 | "rating": 0.6, 1140 | "tags": ["archive"], 1141 | "manager": { "name": "George Gravett", "phone": "304-903-5463" }, 1142 | "contact": { 1143 | "address": "4 Drewry Point", 1144 | "city": "Huntington", 1145 | "postal": "25711", 1146 | "state": "West Virginia" 1147 | } 1148 | }, 1149 | "collections": [ 1150 | { 1151 | "collection": "products", 1152 | "docs": [ 1153 | { 1154 | "key": "WE6nLUziRWz4We5aN9sBA9dM", 1155 | "data": { "name": "Pie Shell - 5", "price": 138.98, "stock": 3 } 1156 | }, 1157 | { 1158 | "key": "ekEa5rJmi8zJAgL03IQua7It", 1159 | "data": { 1160 | "name": "Red Snapper - Fillet, Skin On", 1161 | "price": 155.99, 1162 | "stock": 2 1163 | } 1164 | }, 1165 | { 1166 | "key": "UOL2Kg3lh7gOW4dC3aAFtwRe", 1167 | "data": { 1168 | "name": "Bread - Sour Sticks With Onion", 1169 | "price": 10.87, 1170 | "stock": 1 1171 | } 1172 | }, 1173 | { 1174 | "key": "K9U5I7c960e67rJqZZ7IhAfr", 1175 | "data": { 1176 | "name": "Coffee - Hazelnut Cream", 1177 | "price": 241.9, 1178 | "stock": 1 1179 | } 1180 | } 1181 | ] 1182 | } 1183 | ] 1184 | }, 1185 | { 1186 | "key": "AbvczIyCuxEof6TpfOSwdsGO", 1187 | "data": { 1188 | "name": "Simonis, Howe and Kovacek", 1189 | "category": "Industrial", 1190 | "slogan": "optimize mission-critical networks", 1191 | "rating": 3.9, 1192 | "tags": ["Distributed", "intranet"], 1193 | "manager": { "name": "Terza Sives", "phone": "804-437-5705" }, 1194 | "contact": { 1195 | "address": "99 Kensington Hill", 1196 | "city": "Richmond", 1197 | "postal": "23220", 1198 | "state": "Virginia" 1199 | } 1200 | }, 1201 | "collections": [ 1202 | { 1203 | "collection": "products", 1204 | "docs": [ 1205 | { 1206 | "key": "rtYphi0t1myqFnIJEyKp8LUs", 1207 | "data": { "name": "Pancetta", "price": 143.92, "stock": 8 } 1208 | }, 1209 | { 1210 | "key": "FydfukzGLwHYHYMkaL7XZF0k", 1211 | "data": { "name": "Clams - Canned", "price": 68.19, "stock": 2 } 1212 | } 1213 | ] 1214 | } 1215 | ] 1216 | }, 1217 | { 1218 | "key": "41CXOeS6zG6DNqc29VfxR0HK", 1219 | "data": { 1220 | "name": "Walker-Keeling", 1221 | "category": "Outdoors", 1222 | "slogan": null, 1223 | "rating": 2.4, 1224 | "tags": ["definition", "client-driven"], 1225 | "manager": { "name": "Nessi Cardinal", "phone": "210-206-5799" }, 1226 | "contact": { 1227 | "address": "839 Rieder Plaza", 1228 | "city": "San Antonio", 1229 | "postal": "78260", 1230 | "state": "Texas" 1231 | } 1232 | }, 1233 | "collections": [ 1234 | { 1235 | "collection": "products", 1236 | "docs": [ 1237 | { 1238 | "key": "XSRWrM1uKGbvN3IYbpwrhwI3", 1239 | "data": { 1240 | "name": "Bagel - Ched Chs Presliced", 1241 | "price": 340.22, 1242 | "stock": 2 1243 | } 1244 | } 1245 | ] 1246 | } 1247 | ] 1248 | }, 1249 | { 1250 | "key": "8Sij3tLOOZEmXH6TqDBkba69", 1251 | "data": { 1252 | "name": "Emmerich-Kassulke", 1253 | "category": "Music", 1254 | "slogan": "revolutionize extensible eyeballs", 1255 | "rating": 4.1, 1256 | "tags": ["challenge", "interactive"], 1257 | "manager": { "name": "Charmane Filyushkin", "phone": "616-352-3645" }, 1258 | "contact": { 1259 | "address": "5 Grover Lane", 1260 | "city": "Grand Rapids", 1261 | "postal": "49544", 1262 | "state": "Michigan" 1263 | } 1264 | }, 1265 | "collections": [ 1266 | { 1267 | "collection": "products", 1268 | "docs": [ 1269 | { 1270 | "key": "bNiLGm1oRsjpPoL5lerM1nas", 1271 | "data": { 1272 | "name": "Bread - Italian Corn Meal Poly", 1273 | "price": 464.72, 1274 | "stock": 4 1275 | } 1276 | } 1277 | ] 1278 | } 1279 | ] 1280 | }, 1281 | { 1282 | "key": "Sx7KLybOvlUdGHs18s3Xuphh", 1283 | "data": { 1284 | "name": "Toy-Fritsch", 1285 | "category": "Electronics", 1286 | "slogan": null, 1287 | "rating": 1.9, 1288 | "tags": ["modular", "reciprocal"], 1289 | "manager": { "name": "Vicki Tripony", "phone": "409-323-3688" }, 1290 | "contact": { 1291 | "address": "529 Clove Court", 1292 | "city": "Spring", 1293 | "postal": "77388", 1294 | "state": "Texas" 1295 | } 1296 | }, 1297 | "collections": [ 1298 | { 1299 | "collection": "products", 1300 | "docs": [ 1301 | { 1302 | "key": "R1D6WLsTpIkVrro0qBI1f3LJ", 1303 | "data": { 1304 | "name": "Spice - Paprika", 1305 | "price": 363.3, 1306 | "stock": 9 1307 | } 1308 | }, 1309 | { 1310 | "key": "c7BErYsMamb0VDVVN0WCNPc3", 1311 | "data": { 1312 | "name": "Wine - Cabernet Sauvignon", 1313 | "price": 63.72, 1314 | "stock": 4 1315 | } 1316 | }, 1317 | { 1318 | "key": "7357VuwmEXuh3EqH5f1OiQ71", 1319 | "data": { 1320 | "name": "Sauce - Bernaise, Mix", 1321 | "price": 269.7, 1322 | "stock": 5 1323 | } 1324 | }, 1325 | { 1326 | "key": "kxcoPmMC4IyezqWUb1nBICDQ", 1327 | "data": { 1328 | "name": "Sping Loaded Cup Dispenser", 1329 | "price": 219.79, 1330 | "stock": 7 1331 | } 1332 | }, 1333 | { 1334 | "key": "HSSYa4vvQjKIJqXAC0OtXEsY", 1335 | "data": { 1336 | "name": "Chicken - Bones", 1337 | "price": 81.47, 1338 | "stock": 4 1339 | } 1340 | } 1341 | ] 1342 | } 1343 | ] 1344 | }, 1345 | { 1346 | "key": "Z7vSglONvEK24pKbNGGTiYHC", 1347 | "data": { 1348 | "name": "Aufderhar, Lindgren and Okuneva", 1349 | "category": null, 1350 | "slogan": "e-enable real-time ROI", 1351 | "rating": 3.3, 1352 | "tags": ["leading edge", "Managed", "Synergized"], 1353 | "manager": { "name": "Jacob Kopecka", "phone": "202-275-3988" }, 1354 | "contact": { 1355 | "address": "0 Grim Park", 1356 | "city": "Washington", 1357 | "postal": "20508", 1358 | "state": "District of Columbia" 1359 | } 1360 | }, 1361 | "collections": [ 1362 | { 1363 | "collection": "products", 1364 | "docs": [ 1365 | { 1366 | "key": "lFqNcd6TnhvU0hT7bACagQQw", 1367 | "data": { "name": "Water, Tap", "price": 443.54, "stock": 6 } 1368 | }, 1369 | { 1370 | "key": "XRJBQT9OoJuWWbID4bkDh8yp", 1371 | "data": { 1372 | "name": "Chips Potato Reg 43g", 1373 | "price": 265.17, 1374 | "stock": 0 1375 | } 1376 | }, 1377 | { 1378 | "key": "Genpoi5vhX0dzSaWVdSrlCnH", 1379 | "data": { 1380 | "name": "Sage Ground Wiberg", 1381 | "price": 126.79, 1382 | "stock": 1 1383 | } 1384 | }, 1385 | { 1386 | "key": "i0bO0TsFepOhwfghYRTrdxf8", 1387 | "data": { 1388 | "name": "Cheese - Pied De Vents", 1389 | "price": 334.21, 1390 | "stock": 10 1391 | } 1392 | } 1393 | ] 1394 | } 1395 | ] 1396 | }, 1397 | { 1398 | "key": "50Pz8WpLObKvdhikd90rnr7f", 1399 | "data": { 1400 | "name": "Cole, Schaden and Daniel", 1401 | "category": "Garden", 1402 | "slogan": "embrace impactful content", 1403 | "rating": 2.0, 1404 | "tags": ["concept"], 1405 | "manager": { "name": "Margo Grindell", "phone": "651-267-0401" }, 1406 | "contact": { 1407 | "address": "637 Merrick Junction", 1408 | "city": "Saint Paul", 1409 | "postal": "55146", 1410 | "state": "Minnesota" 1411 | } 1412 | }, 1413 | "collections": [ 1414 | { 1415 | "collection": "products", 1416 | "docs": [ 1417 | { 1418 | "key": "iWTuJs3URT226S39kjvfWw9T", 1419 | "data": { 1420 | "name": "Sword Pick Asst", 1421 | "price": 258.39, 1422 | "stock": 0 1423 | } 1424 | }, 1425 | { 1426 | "key": "iObi1SALPZV89Cc52BGi9tP2", 1427 | "data": { 1428 | "name": "Coconut - Creamed, Pure", 1429 | "price": 202.88, 1430 | "stock": 3 1431 | } 1432 | } 1433 | ] 1434 | } 1435 | ] 1436 | }, 1437 | { 1438 | "key": "p0H5osOFWCPlT1QthpXUnnzI", 1439 | "data": { 1440 | "name": "Russel-Doyle", 1441 | "category": "Industrial", 1442 | "slogan": "strategize global deliverables", 1443 | "rating": 1.0, 1444 | "tags": ["implementation", "24/7"], 1445 | "manager": { "name": "Kev Passe", "phone": "239-389-1390" }, 1446 | "contact": { 1447 | "address": "2141 Buena Vista Street", 1448 | "city": "Naples", 1449 | "postal": "34114", 1450 | "state": "Florida" 1451 | } 1452 | }, 1453 | "collections": [ 1454 | { 1455 | "collection": "products", 1456 | "docs": [ 1457 | { 1458 | "key": "1gG3giWPb7tzdCQrHzMgtthK", 1459 | "data": { 1460 | "name": "Sparkling Wine - Rose, Freixenet", 1461 | "price": 170.63, 1462 | "stock": 4 1463 | } 1464 | }, 1465 | { 1466 | "key": "cRCv1m0EjPupbsXX6hFiTVLn", 1467 | "data": { 1468 | "name": "Wine - Marlbourough Sauv Blanc", 1469 | "price": 475.36, 1470 | "stock": 2 1471 | } 1472 | }, 1473 | { 1474 | "key": "7lDK7eoq5uLGZ42Mzoo6PzZR", 1475 | "data": { 1476 | "name": "Wine - Merlot Vina Carmen", 1477 | "price": 281.25, 1478 | "stock": 1 1479 | } 1480 | }, 1481 | { 1482 | "key": "0aHDwHXJbj6YbA4NsS77uGl1", 1483 | "data": { 1484 | "name": "Sloe Gin - Mcguinness", 1485 | "price": 197.41, 1486 | "stock": 4 1487 | } 1488 | } 1489 | ] 1490 | } 1491 | ] 1492 | }, 1493 | { 1494 | "key": "IcF6fcKpStR9uw5khAjk0Uh9", 1495 | "data": { 1496 | "name": "Larkin-Volkman", 1497 | "category": "Books", 1498 | "slogan": "revolutionize strategic e-business", 1499 | "rating": 2.0, 1500 | "tags": ["holistic"], 1501 | "manager": { "name": "Farrand Broader", "phone": "410-564-5780" }, 1502 | "contact": { 1503 | "address": "04110 Grover Center", 1504 | "city": "Silver Spring", 1505 | "postal": "20904", 1506 | "state": "Maryland" 1507 | } 1508 | }, 1509 | "collections": [ 1510 | { 1511 | "collection": "products", 1512 | "docs": [ 1513 | { 1514 | "key": "XBPlCiDrg7669UtwucXvkruM", 1515 | "data": { 1516 | "name": "Soup - Campbells, Beef Barley", 1517 | "price": 291.62, 1518 | "stock": 0 1519 | } 1520 | }, 1521 | { 1522 | "key": "2tQA1x2YLeNfBufIP1g6yeDN", 1523 | "data": { 1524 | "name": "Mustard - Dry, Powder", 1525 | "price": 386.61, 1526 | "stock": 7 1527 | } 1528 | } 1529 | ] 1530 | } 1531 | ] 1532 | }, 1533 | { 1534 | "key": "kHbfKvNXO0TgCz0ykntV5gsJ", 1535 | "data": { 1536 | "name": "Wolf-Tromp", 1537 | "category": "Movies", 1538 | "slogan": null, 1539 | "rating": 3.5, 1540 | "tags": ["knowledge user", "matrix"], 1541 | "manager": { "name": "Richmound More", "phone": "404-634-3861" }, 1542 | "contact": { 1543 | "address": "463 Tennessee Court", 1544 | "city": "Duluth", 1545 | "postal": "30096", 1546 | "state": "Georgia" 1547 | } 1548 | }, 1549 | "collections": [ 1550 | { 1551 | "collection": "products", 1552 | "docs": [ 1553 | { 1554 | "key": "4AZ5ag66LHz9temLeBqjK2ph", 1555 | "data": { 1556 | "name": "Wine - Cabernet Sauvignon", 1557 | "price": 291.78, 1558 | "stock": 10 1559 | } 1560 | } 1561 | ] 1562 | } 1563 | ] 1564 | }, 1565 | { 1566 | "key": "As0gfRNxY6lYgHL7DT5R4vAX", 1567 | "data": { 1568 | "name": "Oberbrunner, Runte and Rippin", 1569 | "category": "Computers", 1570 | "slogan": null, 1571 | "rating": 3.3, 1572 | "tags": [], 1573 | "manager": { "name": "Jack Farfoot", "phone": "316-440-7664" }, 1574 | "contact": { 1575 | "address": "9 Ronald Regan Plaza", 1576 | "city": "Wichita", 1577 | "postal": "67215", 1578 | "state": "Kansas" 1579 | } 1580 | }, 1581 | "collections": [ 1582 | { 1583 | "collection": "products", 1584 | "docs": [ 1585 | { 1586 | "key": "2Tfdo3oHuY7ucsUtkmOzMbal", 1587 | "data": { "name": "Avocado", "price": 131.19, "stock": 2 } 1588 | }, 1589 | { 1590 | "key": "FQZHgr1NdR5KBQ3PXZwJ2wgu", 1591 | "data": { 1592 | "name": "Sambuca - Ramazzotti", 1593 | "price": 102.32, 1594 | "stock": 5 1595 | } 1596 | }, 1597 | { 1598 | "key": "2iZ8CgADiWEGXE75R9eznTzr", 1599 | "data": { "name": "Dried Peach", "price": 276.54, "stock": 9 } 1600 | }, 1601 | { 1602 | "key": "xshtf4NMIhZOx9uJ331h4IgO", 1603 | "data": { 1604 | "name": "Towel - Roll White", 1605 | "price": 196.53, 1606 | "stock": 9 1607 | } 1608 | } 1609 | ] 1610 | } 1611 | ] 1612 | }, 1613 | { 1614 | "key": "PkEWZnqzTGQEA6gsQGUhZJ4M", 1615 | "data": { 1616 | "name": "Feeney, Wuckert and Keeling", 1617 | "category": "Electronics", 1618 | "slogan": null, 1619 | "rating": 1.4, 1620 | "tags": ["synergy"], 1621 | "manager": { "name": "Keelby Turford", "phone": "202-577-4343" }, 1622 | "contact": { 1623 | "address": "9 Sachs Point", 1624 | "city": "Washington", 1625 | "postal": "20010", 1626 | "state": "District of Columbia" 1627 | } 1628 | }, 1629 | "collections": [ 1630 | { 1631 | "collection": "products", 1632 | "docs": [ 1633 | { 1634 | "key": "sflyu7C4cvqyyfwJYBzq4ORh", 1635 | "data": { 1636 | "name": "Trout - Rainbow, Fresh", 1637 | "price": 429.74, 1638 | "stock": 7 1639 | } 1640 | }, 1641 | { 1642 | "key": "kvsWmlNPusL9b5wUtAq7eGw1", 1643 | "data": { 1644 | "name": "Yogurt - Banana, 175 Gr", 1645 | "price": 160.59, 1646 | "stock": 10 1647 | } 1648 | }, 1649 | { 1650 | "key": "0QPQ4Md4jHTJxzjCGIiGUvOZ", 1651 | "data": { "name": "Cheese - Blue", "price": 482.86, "stock": 5 } 1652 | }, 1653 | { 1654 | "key": "1xvMkwiEI3tCU1W3xCFVdSWQ", 1655 | "data": { 1656 | "name": "Artichoke - Fresh", 1657 | "price": 54.01, 1658 | "stock": 3 1659 | } 1660 | }, 1661 | { 1662 | "key": "EV2r95OmiMm1aC2KOVtVksiW", 1663 | "data": { 1664 | "name": "Pork - Tenderloin, Frozen", 1665 | "price": 470.15, 1666 | "stock": 0 1667 | } 1668 | } 1669 | ] 1670 | } 1671 | ] 1672 | }, 1673 | { 1674 | "key": "922HZHX9dVSFhAzjowlEyoP9", 1675 | "data": { 1676 | "name": "Waelchi-Koss", 1677 | "category": "Industrial", 1678 | "slogan": "embrace value-added web-readiness", 1679 | "rating": 0.8, 1680 | "tags": ["content-based", "User-centric", "Profound"], 1681 | "manager": { "name": "Salvador Furber", "phone": "706-123-6346" }, 1682 | "contact": { 1683 | "address": "29 3rd Street", 1684 | "city": "Cumming", 1685 | "postal": "30130", 1686 | "state": "Georgia" 1687 | } 1688 | }, 1689 | "collections": [ 1690 | { 1691 | "collection": "products", 1692 | "docs": [ 1693 | { 1694 | "key": "PvC0NWqhBbKNPTRq8YIGd7FI", 1695 | "data": { "name": "Squeeze Bottle", "price": 92.88, "stock": 0 } 1696 | }, 1697 | { 1698 | "key": "RTBYdE1P3sg4OZ0jXBQu6Ien", 1699 | "data": { 1700 | "name": "Basil - Seedlings Cookstown", 1701 | "price": 239.56, 1702 | "stock": 6 1703 | } 1704 | }, 1705 | { 1706 | "key": "2d0CnlWmPqPRxJn0OOXBkwrZ", 1707 | "data": { 1708 | "name": "Butter - Unsalted", 1709 | "price": 71.75, 1710 | "stock": 7 1711 | } 1712 | }, 1713 | { 1714 | "key": "uddoGieixz2NuHa8deQtLM3K", 1715 | "data": { 1716 | "name": "Skewers - Bamboo", 1717 | "price": 33.22, 1718 | "stock": 8 1719 | } 1720 | }, 1721 | { 1722 | "key": "g34GYDrso3FqibhxPsyj0AgF", 1723 | "data": { "name": "Soy Protein", "price": 230.18, "stock": 7 } 1724 | } 1725 | ] 1726 | } 1727 | ] 1728 | }, 1729 | { 1730 | "key": "DOFT5Tnwz8r9oRlL3dAFD7ze", 1731 | "data": { 1732 | "name": "Stark-Keebler", 1733 | "category": "Movies", 1734 | "slogan": null, 1735 | "rating": 0.4, 1736 | "tags": [], 1737 | "manager": { "name": "Dodie Kimber", "phone": "251-238-9896" }, 1738 | "contact": { 1739 | "address": "95983 Atwood Lane", 1740 | "city": "Mobile", 1741 | "postal": "36628", 1742 | "state": "Alabama" 1743 | } 1744 | }, 1745 | "collections": [ 1746 | { 1747 | "collection": "products", 1748 | "docs": [ 1749 | { 1750 | "key": "NS66jmDbrHtepD9QYMWbHleO", 1751 | "data": { 1752 | "name": "Carbonated Water - Peach", 1753 | "price": 265.21, 1754 | "stock": 8 1755 | } 1756 | }, 1757 | { 1758 | "key": "TGzBvElMmA6RYn3Ytp3oKmTc", 1759 | "data": { 1760 | "name": "Bread - Granary Small Pull", 1761 | "price": 30.27, 1762 | "stock": 10 1763 | } 1764 | }, 1765 | { 1766 | "key": "culZ9NMdldMzsZEvS68GoljZ", 1767 | "data": { 1768 | "name": "Macaroons - Two Bite Choc", 1769 | "price": 80.12, 1770 | "stock": 0 1771 | } 1772 | }, 1773 | { 1774 | "key": "j6d92lWjRQKHmSLWnEaeJON6", 1775 | "data": { 1776 | "name": "Bread - Calabrese Baguette", 1777 | "price": 52.69, 1778 | "stock": 3 1779 | } 1780 | } 1781 | ] 1782 | } 1783 | ] 1784 | }, 1785 | { 1786 | "key": "mEjD3yDXz2Her0OtIGGMeZGx", 1787 | "data": { 1788 | "name": "Schultz-Reilly", 1789 | "category": "Sports", 1790 | "slogan": "benchmark dot-com networks", 1791 | "rating": 3.6, 1792 | "tags": ["Grass-roots", "Assimilated"], 1793 | "manager": { "name": "Leonie Mandre", "phone": "702-569-3590" }, 1794 | "contact": { 1795 | "address": "72 Homewood Avenue", 1796 | "city": "Henderson", 1797 | "postal": "89074", 1798 | "state": "Nevada" 1799 | } 1800 | }, 1801 | "collections": [ 1802 | { 1803 | "collection": "products", 1804 | "docs": [ 1805 | { 1806 | "key": "mQL01jLgVUQAEatCT2ai32P6", 1807 | "data": { 1808 | "name": "Pepper - Chilli Seeds Mild", 1809 | "price": 290.4, 1810 | "stock": 6 1811 | } 1812 | }, 1813 | { 1814 | "key": "Q5d3U8DKgwYz8GWGrvmkSELv", 1815 | "data": { 1816 | "name": "Cake - French Pear Tart", 1817 | "price": 298.31, 1818 | "stock": 10 1819 | } 1820 | }, 1821 | { 1822 | "key": "juw4pORKkzDRkPAtRRSDZCWe", 1823 | "data": { 1824 | "name": "Russian Prince", 1825 | "price": 494.49, 1826 | "stock": 9 1827 | } 1828 | }, 1829 | { 1830 | "key": "yu1EQbI5T9bfc5dYRtTb2VAk", 1831 | "data": { 1832 | "name": "Artichoke - Fresh", 1833 | "price": 248.55, 1834 | "stock": 9 1835 | } 1836 | } 1837 | ] 1838 | } 1839 | ] 1840 | }, 1841 | { 1842 | "key": "aN5DdTueLFfTTu7ZAVdgNwJ9", 1843 | "data": { 1844 | "name": "Sipes Group", 1845 | "category": "Baby", 1846 | "slogan": "monetize ubiquitous relationships", 1847 | "rating": 0.9, 1848 | "tags": ["Streamlined", "local area network", "utilisation"], 1849 | "manager": { 1850 | "name": "Worthington Schellig", 1851 | "phone": "480-173-8139" 1852 | }, 1853 | "contact": { 1854 | "address": "024 Melody Circle", 1855 | "city": "Mesa", 1856 | "postal": "85215", 1857 | "state": "Arizona" 1858 | } 1859 | }, 1860 | "collections": [ 1861 | { 1862 | "collection": "products", 1863 | "docs": [ 1864 | { 1865 | "key": "CEoFeug7wRv3q1GdgA8aWKVr", 1866 | "data": { 1867 | "name": "Wine - White, Ej Gallo", 1868 | "price": 487.29, 1869 | "stock": 3 1870 | } 1871 | }, 1872 | { 1873 | "key": "9s1Kto0f6KTqP6tYO0Yp8SeD", 1874 | "data": { 1875 | "name": "Water Chestnut - Canned", 1876 | "price": 220.48, 1877 | "stock": 3 1878 | } 1879 | }, 1880 | { 1881 | "key": "5syNNAudfaDZRajGZm9SMjq5", 1882 | "data": { 1883 | "name": "Tomatoes - Roma", 1884 | "price": 497.42, 1885 | "stock": 4 1886 | } 1887 | } 1888 | ] 1889 | } 1890 | ] 1891 | }, 1892 | { 1893 | "key": "2DIHCbOMkKz0YcrKUsRf6kgF", 1894 | "data": { 1895 | "name": "Treutel LLC", 1896 | "category": "Music", 1897 | "slogan": null, 1898 | "rating": 1.5, 1899 | "tags": ["time-frame"], 1900 | "manager": { "name": "Syd Lamkin", "phone": "814-965-0752" }, 1901 | "contact": { 1902 | "address": "5 Golf View Pass", 1903 | "city": "Erie", 1904 | "postal": "16522", 1905 | "state": "Pennsylvania" 1906 | } 1907 | }, 1908 | "collections": [ 1909 | { 1910 | "collection": "products", 1911 | "docs": [ 1912 | { 1913 | "key": "NNJ7ziylrHGcejJpY9p6drqM", 1914 | "data": { 1915 | "name": "Juice - Apple, 500 Ml", 1916 | "price": 49.75, 1917 | "stock": 2 1918 | } 1919 | }, 1920 | { 1921 | "key": "IO6DPA52DMRylKlOlUFkoWza", 1922 | "data": { "name": "Veal - Bones", "price": 246.07, "stock": 2 } 1923 | }, 1924 | { 1925 | "key": "jpF9MHHfw8XyfZm2ukvfEXZK", 1926 | "data": { 1927 | "name": "Graham Cracker Mix", 1928 | "price": 300.42, 1929 | "stock": 9 1930 | } 1931 | }, 1932 | { 1933 | "key": "3UXchxNEyXZ0t1URO6DrIlFZ", 1934 | "data": { 1935 | "name": "Juice - Lagoon Mango", 1936 | "price": 488.61, 1937 | "stock": 2 1938 | } 1939 | } 1940 | ] 1941 | } 1942 | ] 1943 | }, 1944 | { 1945 | "key": "sqHweK8WiYQcR2x49Yc87Zmq", 1946 | "data": { 1947 | "name": "Runolfsdottir and Sons", 1948 | "category": "Books", 1949 | "slogan": null, 1950 | "rating": 1.3, 1951 | "tags": ["regional", "Robust", "24 hour"], 1952 | "manager": { "name": "Adriana Sangwine", "phone": "414-271-7660" }, 1953 | "contact": { 1954 | "address": "81306 Hoard Avenue", 1955 | "city": "Milwaukee", 1956 | "postal": "53205", 1957 | "state": "Wisconsin" 1958 | } 1959 | }, 1960 | "collections": [ 1961 | { 1962 | "collection": "products", 1963 | "docs": [ 1964 | { 1965 | "key": "0OlYjfQNv7grYmlRfs1RSCWC", 1966 | "data": { "name": "Veal - Leg", "price": 100.59, "stock": 6 } 1967 | }, 1968 | { 1969 | "key": "HslodlgsGUucvM7L98oWAq65", 1970 | "data": { 1971 | "name": "Goulash Seasoning", 1972 | "price": 384.37, 1973 | "stock": 7 1974 | } 1975 | }, 1976 | { 1977 | "key": "Tsg8QCsP7J4ZSkPHG3RUIj67", 1978 | "data": { 1979 | "name": "Godiva White Chocolate", 1980 | "price": 478.57, 1981 | "stock": 7 1982 | } 1983 | }, 1984 | { 1985 | "key": "RWNNjPH9lWNxvwNRPmshgENO", 1986 | "data": { 1987 | "name": "Sauce - Oyster", 1988 | "price": 295.92, 1989 | "stock": 4 1990 | } 1991 | } 1992 | ] 1993 | } 1994 | ] 1995 | }, 1996 | { 1997 | "key": "xabfEC6rfwNGRh62wcJEiIhc", 1998 | "data": { 1999 | "name": "Trantow, Deckow and Oberbrunner", 2000 | "category": "Shoes", 2001 | "slogan": "expedite out-of-the-box interfaces", 2002 | "rating": 1.5, 2003 | "tags": ["mission-critical"], 2004 | "manager": { "name": "Trever Skoggings", "phone": "408-588-3127" }, 2005 | "contact": { 2006 | "address": "2 Reindahl Trail", 2007 | "city": "San Jose", 2008 | "postal": "95128", 2009 | "state": "California" 2010 | } 2011 | }, 2012 | "collections": [ 2013 | { 2014 | "collection": "products", 2015 | "docs": [ 2016 | { 2017 | "key": "OxjGjUUIhsnVBGtNycNAMNFC", 2018 | "data": { 2019 | "name": "Cake - Mini Cheesecake", 2020 | "price": 274.29, 2021 | "stock": 9 2022 | } 2023 | }, 2024 | { 2025 | "key": "L0RGvjsfb2h9yNhG8Fq9s75x", 2026 | "data": { 2027 | "name": "Wine - Placido Pinot Grigo", 2028 | "price": 196.47, 2029 | "stock": 2 2030 | } 2031 | } 2032 | ] 2033 | } 2034 | ] 2035 | }, 2036 | { 2037 | "key": "sfLkMV2MkO1Oe0iLfr0loCtC", 2038 | "data": { 2039 | "name": "Leannon-Conroy", 2040 | "category": "Toys", 2041 | "slogan": "integrate magnetic interfaces", 2042 | "rating": 4.1, 2043 | "tags": ["web-enabled", "adapter", "multi-state"], 2044 | "manager": { "name": "Carolee Argo", "phone": "916-248-2106" }, 2045 | "contact": { 2046 | "address": "4 Mandrake Point", 2047 | "city": "Sacramento", 2048 | "postal": "95813", 2049 | "state": "California" 2050 | } 2051 | }, 2052 | "collections": [ 2053 | { 2054 | "collection": "products", 2055 | "docs": [ 2056 | { 2057 | "key": "xjPNb25pmnPZEiS13yf2Lh2c", 2058 | "data": { 2059 | "name": "Wine La Vielle Ferme Cote Du", 2060 | "price": 471.76, 2061 | "stock": 8 2062 | } 2063 | }, 2064 | { 2065 | "key": "LdBzcsTMqDOD3mCLAMUZt6KC", 2066 | "data": { 2067 | "name": "Scallops - 20/30", 2068 | "price": 325.93, 2069 | "stock": 9 2070 | } 2071 | }, 2072 | { 2073 | "key": "jY9Ino185SCEsYmEyC2h3nzH", 2074 | "data": { 2075 | "name": "Filling - Mince Meat", 2076 | "price": 430.23, 2077 | "stock": 10 2078 | } 2079 | } 2080 | ] 2081 | } 2082 | ] 2083 | }, 2084 | { 2085 | "key": "kMnayTx9yD3T1ju0r376eFLJ", 2086 | "data": { 2087 | "name": "Lehner-Bartell", 2088 | "category": "Music", 2089 | "slogan": null, 2090 | "rating": 3.2, 2091 | "tags": ["Synchronised", "info-mediaries"], 2092 | "manager": { "name": "Fanechka Riddel", "phone": "212-198-4464" }, 2093 | "contact": { 2094 | "address": "118 Garrison Pass", 2095 | "city": "New York City", 2096 | "postal": "10125", 2097 | "state": "New York" 2098 | } 2099 | }, 2100 | "collections": [ 2101 | { 2102 | "collection": "products", 2103 | "docs": [ 2104 | { 2105 | "key": "QIsNHzdlWfgi3QKi0EogXSIL", 2106 | "data": { "name": "Nectarines", "price": 162.01, "stock": 8 } 2107 | } 2108 | ] 2109 | } 2110 | ] 2111 | }, 2112 | { 2113 | "key": "wZ6hAxsIo20hfh7jBJlCdh8C", 2114 | "data": { 2115 | "name": "Harris-Lang", 2116 | "category": "Electronics", 2117 | "slogan": null, 2118 | "rating": 3.7, 2119 | "tags": ["Graphic Interface", "Managed", "De-engineered", "array"], 2120 | "manager": { "name": "Wallace Cridlin", "phone": "405-939-8606" }, 2121 | "contact": { 2122 | "address": "5 Westend Point", 2123 | "city": "Oklahoma City", 2124 | "postal": "73173", 2125 | "state": "Oklahoma" 2126 | } 2127 | }, 2128 | "collections": [ 2129 | { 2130 | "collection": "products", 2131 | "docs": [ 2132 | { 2133 | "key": "WKQi4UyYom6DGTbg4iJ2eRb9", 2134 | "data": { 2135 | "name": "Tart - Butter Plain Squares", 2136 | "price": 179.25, 2137 | "stock": 0 2138 | } 2139 | }, 2140 | { 2141 | "key": "REnyyAARF1JQ9azrHOkpG22Y", 2142 | "data": { "name": "Hersey Shakes", "price": 237.04, "stock": 5 } 2143 | }, 2144 | { 2145 | "key": "mLmJB5gX8SoMOPj509eCg4TE", 2146 | "data": { 2147 | "name": "Juice - Tomato, 10 Oz", 2148 | "price": 179.95, 2149 | "stock": 4 2150 | } 2151 | } 2152 | ] 2153 | } 2154 | ] 2155 | }, 2156 | { 2157 | "key": "Pl9abyyGlZFARkwVHnsfmwj0", 2158 | "data": { 2159 | "name": "Torp Inc", 2160 | "category": "Beauty", 2161 | "slogan": null, 2162 | "rating": 4.8, 2163 | "tags": ["synergy", "intranet"], 2164 | "manager": { "name": null, "phone": null }, 2165 | "contact": { 2166 | "address": "6 Packers Way", 2167 | "city": "Sarasota", 2168 | "postal": "34238", 2169 | "state": "Florida" 2170 | } 2171 | }, 2172 | "collections": [ 2173 | { 2174 | "collection": "products", 2175 | "docs": [ 2176 | { 2177 | "key": "U2YEq4QA3PCOHpigupY7fDyC", 2178 | "data": { 2179 | "name": "Beer - Tetleys", 2180 | "price": 345.04, 2181 | "stock": 1 2182 | } 2183 | }, 2184 | { 2185 | "key": "ugD8S4qf009BuXXIrZfXWa0H", 2186 | "data": { 2187 | "name": "Rabbit - Saddles", 2188 | "price": 70.39, 2189 | "stock": 4 2190 | } 2191 | }, 2192 | { 2193 | "key": "QAZ7okmOJOkw7IBdHIPvfiJ4", 2194 | "data": { "name": "Cheese - Blue", "price": 239.42, "stock": 3 } 2195 | } 2196 | ] 2197 | } 2198 | ] 2199 | }, 2200 | { 2201 | "key": "RSMyWcSEvFctqvsFwzzEYqWG", 2202 | "data": { 2203 | "name": "Schumm-Zieme", 2204 | "category": "Computers", 2205 | "slogan": null, 2206 | "rating": 0.9, 2207 | "tags": ["impactful", "application"], 2208 | "manager": { "name": "Allianora Larret", "phone": "614-519-2925" }, 2209 | "contact": { 2210 | "address": "7 Del Sol Lane", 2211 | "city": "Columbus", 2212 | "postal": "43220", 2213 | "state": "Ohio" 2214 | } 2215 | }, 2216 | "collections": [ 2217 | { 2218 | "collection": "products", 2219 | "docs": [ 2220 | { 2221 | "key": "bgbvOwMhAyK7REBjMgrNnM4o", 2222 | "data": { 2223 | "name": "Sprite, Diet - 355ml", 2224 | "price": 159.91, 2225 | "stock": 0 2226 | } 2227 | }, 2228 | { 2229 | "key": "Z31NPHoGBifdTKU9mcs1mdw5", 2230 | "data": { 2231 | "name": "Sea Bass - Whole", 2232 | "price": 481.31, 2233 | "stock": 9 2234 | } 2235 | } 2236 | ] 2237 | } 2238 | ] 2239 | }, 2240 | { 2241 | "key": "6yZrSjRzn8DzhjQ6MPv0HfTz", 2242 | "data": { 2243 | "name": "Beer LLC", 2244 | "category": "Baby", 2245 | "slogan": null, 2246 | "rating": 0.8, 2247 | "tags": [], 2248 | "manager": { "name": "Evanne Edelmann", "phone": "814-869-1492" }, 2249 | "contact": { 2250 | "address": "94839 Myrtle Park", 2251 | "city": "Erie", 2252 | "postal": "16510", 2253 | "state": "Pennsylvania" 2254 | } 2255 | }, 2256 | "collections": [ 2257 | { 2258 | "collection": "products", 2259 | "docs": [ 2260 | { 2261 | "key": "QoilEWGOwaJydxX3TnqvvwNk", 2262 | "data": { 2263 | "name": "Tomato - Peeled Italian Canned", 2264 | "price": 227.14, 2265 | "stock": 6 2266 | } 2267 | } 2268 | ] 2269 | } 2270 | ] 2271 | }, 2272 | { 2273 | "key": "pM5fE4KFEuZ8SIssUp2DFGur", 2274 | "data": { 2275 | "name": "Lesch-Windler", 2276 | "category": "Shoes", 2277 | "slogan": null, 2278 | "rating": 4.0, 2279 | "tags": [], 2280 | "manager": { "name": null, "phone": null }, 2281 | "contact": { 2282 | "address": "0 1st Road", 2283 | "city": "Santa Cruz", 2284 | "postal": "95064", 2285 | "state": "California" 2286 | } 2287 | }, 2288 | "collections": [ 2289 | { 2290 | "collection": "products", 2291 | "docs": [ 2292 | { 2293 | "key": "b5RBDtkjMWSk6QIvjbqkU8Qo", 2294 | "data": { 2295 | "name": "Cheese - Shred Cheddar / Mozza", 2296 | "price": 14.86, 2297 | "stock": 8 2298 | } 2299 | }, 2300 | { 2301 | "key": "vuyFvSHWLFM50TBHCDK9zj8s", 2302 | "data": { 2303 | "name": "Longos - Assorted Sandwich", 2304 | "price": 423.58, 2305 | "stock": 0 2306 | } 2307 | } 2308 | ] 2309 | } 2310 | ] 2311 | } 2312 | ] 2313 | } 2314 | ] 2315 | --------------------------------------------------------------------------------