├── perf └── .gitkeep ├── inspector └── .gitkeep ├── src ├── version.ts ├── utils │ ├── noop.ts │ ├── has-own.ts │ ├── truthy.ts │ ├── identity.ts │ ├── option.ts │ ├── get-type.ts │ ├── warn.ts │ ├── hash.ts │ ├── contains.ts │ ├── valid.ts │ ├── index.ts │ ├── clone.ts │ ├── assert.ts │ ├── for-each.ts │ ├── try-catch.ts │ └── diff.ts ├── storage │ ├── symbols │ │ ├── dispose.ts │ │ ├── hidden-columns.ts │ │ ├── index.ts │ │ ├── field-identifier.ts │ │ └── context-table.ts │ ├── index.ts │ ├── helper │ │ ├── create-pk-clause.ts │ │ ├── index.ts │ │ ├── create-predicate.ts │ │ ├── graph.ts │ │ ├── merge-transaction-result.ts │ │ ├── db-factory.ts │ │ ├── definition.ts │ │ └── predicatable-query.ts │ └── modules │ │ ├── mapFn.ts │ │ ├── index.ts │ │ ├── ProxySelector.ts │ │ ├── Mutation.ts │ │ ├── QueryToken.ts │ │ ├── PredicateProvider.ts │ │ └── Selector.ts ├── exception │ ├── index.ts │ ├── token.ts │ ├── Exception.ts │ └── database.ts ├── addons │ ├── index.ts │ └── aggresive-optimizer.ts ├── shared │ ├── index.ts │ ├── Traversable.ts │ └── Logger.ts ├── index.ts ├── tsconfig.json ├── proxy │ └── index.ts ├── interface │ ├── enum.ts │ └── index.ts └── global.ts ├── example ├── rdb │ ├── index.ts │ ├── Database.ts │ └── defineSchema.ts ├── index.ts ├── consumer │ └── index.ts ├── tsconfig.json ├── package.json └── webpack.config.js ├── .yarnrc ├── test ├── utils │ ├── mocks │ │ ├── index.ts │ │ ├── Lovefield.ts │ │ └── Selector.ts │ ├── index.ts │ ├── generators │ │ ├── involved-members-generator.ts │ │ ├── index.ts │ │ ├── post-generator.ts │ │ ├── subtask-generator.ts │ │ ├── relational-scenario-generator.ts │ │ ├── program-generator.ts │ │ └── task-generator.ts │ ├── uuid.ts │ ├── check-executor-result.ts │ └── random.ts ├── e2e │ ├── app.ts │ ├── database.ts │ ├── index.html │ └── fetch.ts ├── tsconfig.json ├── index.ts ├── run.ts ├── schemas │ ├── Post.ts │ ├── Engineer.ts │ ├── Subtask.ts │ ├── Project.ts │ ├── Module.ts │ ├── index.ts │ ├── Program.ts │ ├── Test.ts │ ├── Task.ts │ └── Activity.ts ├── specs │ ├── index.ts │ ├── exception.spec.ts │ ├── storage │ │ ├── helper │ │ │ ├── graph.spec.ts │ │ │ └── definition.spec.ts │ │ ├── modules │ │ │ ├── ProxySelector.spec.ts │ │ │ ├── Mutation.spec.ts │ │ │ └── PredicateProvider.spec.ts │ │ └── Database.before.connect.spec.ts │ ├── shared │ │ └── Traversable.spec.ts │ └── utils │ │ └── utils.spec.ts └── teambition.ts ├── .github └── FUNDING.yml ├── .gitignore ├── .vscode └── settings.json ├── docs ├── Design-Document │ ├── README.md │ ├── 01_schema.md │ ├── 02_data_lifecycle.md │ └── 00_goals.md └── API-description │ ├── WIP.md │ ├── QueryToken.md │ ├── QueryDescription.md │ └── README.md ├── tools ├── version.ts ├── tman.ts ├── watch.ts └── publish.ts ├── renovate.json ├── tslint.json ├── tsconfig.json ├── .circleci └── config.yml ├── LICENSE ├── webpack.config.js ├── package.json └── README.md /perf/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /inspector/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/version.ts: -------------------------------------------------------------------------------- 1 | export default '0.11.0' 2 | -------------------------------------------------------------------------------- /example/rdb/index.ts: -------------------------------------------------------------------------------- 1 | import './defineSchema' 2 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | registry "https://registry.npmjs.org" 2 | -------------------------------------------------------------------------------- /example/index.ts: -------------------------------------------------------------------------------- 1 | import './rdb' 2 | import './consumer' 3 | -------------------------------------------------------------------------------- /src/utils/noop.ts: -------------------------------------------------------------------------------- 1 | export const noop = (): any => void 0 2 | -------------------------------------------------------------------------------- /example/consumer/index.ts: -------------------------------------------------------------------------------- 1 | // import Database from '../rdb/Database' 2 | -------------------------------------------------------------------------------- /src/storage/symbols/dispose.ts: -------------------------------------------------------------------------------- 1 | export const dispose = '@@dispose' 2 | -------------------------------------------------------------------------------- /src/exception/index.ts: -------------------------------------------------------------------------------- 1 | export * from './database' 2 | export * from './token' 3 | -------------------------------------------------------------------------------- /test/utils/mocks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Lovefield' 2 | export * from './Selector' 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [brooooooklyn] 4 | 5 | -------------------------------------------------------------------------------- /src/addons/index.ts: -------------------------------------------------------------------------------- 1 | // forkQueryToken 2 | // replaceQueryToken 3 | export * from './aggresive-optimizer' 4 | -------------------------------------------------------------------------------- /src/storage/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Database' 2 | export { QueryToken, Selector } from './modules' 3 | -------------------------------------------------------------------------------- /src/shared/index.ts: -------------------------------------------------------------------------------- 1 | export { ContextLogger, Level, Logger } from './Logger' 2 | export * from './Traversable' 3 | -------------------------------------------------------------------------------- /src/utils/has-own.ts: -------------------------------------------------------------------------------- 1 | export function hasOwn(target: any, key: string) { 2 | return target.hasOwnProperty(key) 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/truthy.ts: -------------------------------------------------------------------------------- 1 | export function isNonNullable(x: T): x is NonNullable { 2 | return x != null 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | spec-js 3 | dist 4 | *.log 5 | coverage 6 | .nyc_output 7 | .awcache 8 | .happypack 9 | .idea/ 10 | -------------------------------------------------------------------------------- /src/utils/identity.ts: -------------------------------------------------------------------------------- 1 | export function identity(): void 2 | export function identity(r: T): T 3 | export function identity(r?: T) { 4 | return r 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "typescript.tsdk": "./node_modules/typescript/lib" 4 | } -------------------------------------------------------------------------------- /example/rdb/Database.ts: -------------------------------------------------------------------------------- 1 | import { Database, DataStoreType } from 'reactivedb' 2 | 3 | export default new Database(DataStoreType.INDEXED_DB, true, 'ReactiveDB', 1) 4 | -------------------------------------------------------------------------------- /src/storage/symbols/hidden-columns.ts: -------------------------------------------------------------------------------- 1 | export const hidden = '__hidden__' 2 | 3 | export function hiddenColName(key: string) { 4 | return `${hidden}${key}` 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/option.ts: -------------------------------------------------------------------------------- 1 | export function option(condition: T, getElse: U) { 2 | if (!condition) { 3 | return getElse 4 | } 5 | return condition 6 | } 7 | -------------------------------------------------------------------------------- /src/storage/symbols/index.ts: -------------------------------------------------------------------------------- 1 | export * from './dispose' 2 | export * from './context-table' 3 | export * from './hidden-columns' 4 | export * from './field-identifier' 5 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import './global' 2 | import 'tslib' 3 | 4 | export * from './interface' 5 | export * from './shared' 6 | export * from './storage' 7 | export * from './addons' 8 | -------------------------------------------------------------------------------- /src/storage/helper/create-pk-clause.ts: -------------------------------------------------------------------------------- 1 | export function createPkClause(key: string, val: any) { 2 | return { 3 | where: { 4 | [key]: val, 5 | }, 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "strict": true, 5 | "skipLibCheck": true, 6 | "outDir": ".." 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/get-type.ts: -------------------------------------------------------------------------------- 1 | export function getType(object: any) { 2 | return Object.prototype.toString 3 | .call(object) 4 | .match(/\s\w+/)![0] 5 | .trim() 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/warn.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '../shared/Logger' 2 | 3 | const warn = (...messages: string[]) => { 4 | Logger.warn(...messages) 5 | } 6 | 7 | export { warn } 8 | -------------------------------------------------------------------------------- /src/storage/modules/mapFn.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs' 2 | 3 | export const mapFn = (dist$: Observable) => dist$ 4 | 5 | mapFn.toString = () => 'RDB_DEFAULT_MAP_FN' 6 | -------------------------------------------------------------------------------- /src/storage/symbols/field-identifier.ts: -------------------------------------------------------------------------------- 1 | export const link = '@' 2 | 3 | export function fieldIdentifier(tableName: string, val: string) { 4 | return `${tableName}${link}${val}` 5 | } 6 | -------------------------------------------------------------------------------- /test/e2e/app.ts: -------------------------------------------------------------------------------- 1 | import * as tman from 'tman' 2 | import 'tman-skin' 3 | import '../specs' 4 | 5 | tman.mocha() 6 | tman.run() 7 | 8 | // import '../schemas' 9 | // import './fetch' 10 | -------------------------------------------------------------------------------- /src/storage/modules/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Mutation' 2 | export * from './Selector' 3 | export * from './ProxySelector' 4 | export * from './PredicateProvider' 5 | export * from './QueryToken' 6 | -------------------------------------------------------------------------------- /src/storage/symbols/context-table.ts: -------------------------------------------------------------------------------- 1 | export const context = '#' 2 | 3 | export function contextTableName(tableName: string, suffix: number) { 4 | return `${tableName}${context}${suffix}` 5 | } 6 | -------------------------------------------------------------------------------- /test/e2e/database.ts: -------------------------------------------------------------------------------- 1 | import { DataStoreType } from '../../src/interface' 2 | import { Database } from '../../src/storage/Database' 3 | 4 | export const database = new Database(DataStoreType.MEMORY, true) 5 | -------------------------------------------------------------------------------- /src/proxy/index.ts: -------------------------------------------------------------------------------- 1 | export { QueryToken, SelectorMeta, TraceResult } from '../storage/modules/QueryToken' 2 | export { ProxySelector } from '../storage/modules/ProxySelector' 3 | export * from '../interface' 4 | -------------------------------------------------------------------------------- /test/utils/index.ts: -------------------------------------------------------------------------------- 1 | import * as random from './random' 2 | 3 | export * from './mocks' 4 | export * from './generators' 5 | export * from './uuid' 6 | export * from './check-executor-result' 7 | export { random } 8 | -------------------------------------------------------------------------------- /docs/Design-Document/README.md: -------------------------------------------------------------------------------- 1 | # ReactiveDB Design Document 2 | 3 | 这些文档尚未完工 4 | 5 | [0. Goals](./00_goals.md) 6 | 7 | 8 | [1. Schema](./01_schema.md) 9 | 10 | 11 | [2. Data Lifecycle](./02_data_lifecycle.md) 12 | -------------------------------------------------------------------------------- /src/utils/hash.ts: -------------------------------------------------------------------------------- 1 | export const hash = (str: string) => { 2 | let ret = 0 3 | for (let i = 0; i < str.length; i++) { 4 | ret = (ret << 5) - ret + str.charCodeAt(i) 5 | ret = ret & ret 6 | } 7 | return ret 8 | } 9 | -------------------------------------------------------------------------------- /test/e2e/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ReactiveDB E2E Test 6 | 7 | 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /src/utils/contains.ts: -------------------------------------------------------------------------------- 1 | export function contains(target: any, container: Array | Set | Map) { 2 | if (container instanceof Set || container instanceof Map) { 3 | return container.has(target) 4 | } else { 5 | return container.indexOf(target) > -1 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tools/version.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | 3 | const version = require('../package.json').version 4 | 5 | const filePath = 'src/version.ts' 6 | 7 | const replace = fs.readFileSync(filePath, 'utf-8').replace(/'.*'/g, `'${version}'`) 8 | 9 | fs.writeFileSync(filePath, replace) 10 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ES2015", 5 | "strict": false, 6 | "noImplicitThis": true, 7 | "noImplicitAny": false, 8 | "skipLibCheck": true, 9 | "noImplicitReturns": true, 10 | "outDir": "../spec-js" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/storage/helper/index.ts: -------------------------------------------------------------------------------- 1 | import * as definition from './definition' 2 | 3 | export * from './create-pk-clause' 4 | export * from './create-predicate' 5 | export * from './predicatable-query' 6 | export * from './merge-transaction-result' 7 | export * from './db-factory' 8 | export * from './graph' 9 | export { definition } 10 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base", ":preserveSemverRanges"], 3 | "packageRules": [ 4 | { 5 | "automerge": true, 6 | "matchUpdateTypes": ["minor", "patch", "pin", "digest"] 7 | } 8 | ], 9 | "lockFileMaintenance": { 10 | "enabled": true, 11 | "extends": [ 12 | "schedule:monthly" 13 | ] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/exception/token.ts: -------------------------------------------------------------------------------- 1 | import { ReactiveDBException } from './Exception' 2 | 3 | export const TokenConsumed = () => new ReactiveDBException('QueryToken was already consumed.') 4 | 5 | export const TokenConcatFailed = (msg?: string) => { 6 | const errMsg = 'Token cannot be concated' + `${msg ? ' due to: ' + msg : ''}.` 7 | return new ReactiveDBException(errMsg) 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/valid.ts: -------------------------------------------------------------------------------- 1 | import { throwError, Observable, EMPTY } from 'rxjs' 2 | import { skip } from 'rxjs/operators' 3 | 4 | // think it as asynchronous assert 5 | export function valid(condition: any, error: Error): Observable | Observable { 6 | if (!condition) { 7 | return throwError(error) 8 | } 9 | 10 | return EMPTY.pipe(skip(1)) 11 | } 12 | -------------------------------------------------------------------------------- /src/storage/helper/create-predicate.ts: -------------------------------------------------------------------------------- 1 | import * as lf from 'lovefield' 2 | import { PredicateProvider } from '../modules/PredicateProvider' 3 | import { Predicate } from '../../interface' 4 | 5 | export function createPredicate(table: lf.schema.Table, clause: Predicate | null = null) { 6 | return clause ? new PredicateProvider(table, clause).getPredicate() : null 7 | } 8 | -------------------------------------------------------------------------------- /test/utils/generators/involved-members-generator.ts: -------------------------------------------------------------------------------- 1 | import * as random from '../random' 2 | import { uuid } from '../uuid' 3 | 4 | export default function(limit: number, seed: string[] = []) { 5 | const size = random.number(0, limit) 6 | const involves: string[] = [] 7 | for (let i = 0; i < size; i++) { 8 | involves.push(uuid()) 9 | } 10 | return seed.concat(involves) 11 | } 12 | -------------------------------------------------------------------------------- /test/utils/uuid.ts: -------------------------------------------------------------------------------- 1 | const s4 = () => 2 | Math.floor((1 + Math.random()) * 0x10000) 3 | .toString(16) 4 | .substring(1) 5 | 6 | const uuidStack = new Set() 7 | 8 | export const uuid = () => { 9 | let UUID = s4() + s4() 10 | /* istanbul ignore next */ 11 | while (uuidStack.has(UUID)) { 12 | UUID = s4() + s4() 13 | } 14 | uuidStack.add(UUID) 15 | return UUID 16 | } 17 | -------------------------------------------------------------------------------- /test/index.ts: -------------------------------------------------------------------------------- 1 | export { Database } from '../src/storage/Database' 2 | export { TeambitionTypes } from './teambition' 3 | export * from '../src/interface' 4 | export * from '../src/storage/modules' 5 | export * from '../src/storage/helper' 6 | export * from '../src/utils' 7 | export * from '../src/shared' 8 | export * from './schemas' 9 | export * from '../src/exception' 10 | export * from '../src/addons' 11 | -------------------------------------------------------------------------------- /test/run.ts: -------------------------------------------------------------------------------- 1 | // require alias 2 | import '../src/global' 3 | const lfPath = 'lovefield/dist/lovefield.js' 4 | require(lfPath) 5 | require.cache[require.resolve('lovefield')] = require.cache[require.resolve(lfPath)] 6 | 7 | import { aggresiveOptimizer } from '../src' 8 | if (!!process.env.optimize) { 9 | aggresiveOptimizer() 10 | } 11 | 12 | import './specs' 13 | export { run, setExit, reset, mocha } from 'tman' 14 | -------------------------------------------------------------------------------- /test/utils/generators/index.ts: -------------------------------------------------------------------------------- 1 | export { default as involveMembersGen } from './involved-members-generator' 2 | export { default as postGen } from './post-generator' 3 | export { default as subtaskGen } from './subtask-generator' 4 | export { default as taskGen } from './task-generator' 5 | export { default as scenarioGen } from './relational-scenario-generator' 6 | export { default as programGen } from './program-generator' 7 | -------------------------------------------------------------------------------- /src/exception/Exception.ts: -------------------------------------------------------------------------------- 1 | import { attachMoreErrorInfo } from '../utils' 2 | 3 | export class ReactiveDBException extends Error { 4 | constructor(message: string, moreInfo?: {}) { 5 | const messageWithContext = !moreInfo ? message : attachMoreErrorInfo(message, moreInfo) 6 | super(messageWithContext) 7 | this.name = 'ReactiveDBError' 8 | Object.setPrototypeOf(this, ReactiveDBException.prototype) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './hash' 2 | export * from './clone' 3 | export * from './valid' 4 | export * from './option' 5 | export * from './assert' 6 | export * from './has-own' 7 | export * from './contains' 8 | export * from './for-each' 9 | export * from './identity' 10 | export * from './get-type' 11 | export * from './truthy' 12 | export * from './try-catch' 13 | export * from './diff' 14 | export { warn } from './warn' 15 | -------------------------------------------------------------------------------- /tools/tman.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | 3 | const testDir = path.join(process.cwd(), 'spec-js/test') 4 | const testFile = `${testDir}/run` 5 | 6 | export function runTman() { 7 | Object.keys(require.cache).forEach((id) => { 8 | delete require.cache[id] 9 | }) 10 | 11 | const { run, setExit, reset, mocha } = require(testFile) 12 | 13 | setExit(false) 14 | mocha() 15 | return run()(() => { 16 | reset() 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /test/utils/generators/post-generator.ts: -------------------------------------------------------------------------------- 1 | import { uuid } from '../uuid' 2 | 3 | export default function(limit: number, belongTo: string) { 4 | const result: any[] = [] 5 | const created = new Date(1970, 0, 1).toISOString() 6 | 7 | while (limit > 0) { 8 | limit-- 9 | result.push({ 10 | _id: uuid(), 11 | content: 'posts content:' + uuid(), 12 | belongTo, 13 | created, 14 | }) 15 | } 16 | return result 17 | } 18 | -------------------------------------------------------------------------------- /docs/API-description/WIP.md: -------------------------------------------------------------------------------- 1 | - ```[WIP]PredicateMeta``` 2 | 3 | 4 |
5 | 6 | 7 | ### PredicateMeta: 8 | 9 | PredicateMeta 的详细描述参见 Predicate 部分 10 | 11 | ```ts 12 | { 13 | project: { 14 | created: { 15 | $gt: new Date().valueOf() 16 | } 17 | }, 18 | $or: { 19 | _id: 'xxxx', 20 | updated: { 21 | $lt: new Date(2015, 1, 1).valueOf() 22 | }, 23 | _executorId: 'memberId' 24 | } 25 | } 26 | ``` 27 | -------------------------------------------------------------------------------- /test/utils/check-executor-result.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { ExecutorResult } from '../index' 3 | 4 | export const checkExecutorResult = function( 5 | result: ExecutorResult, 6 | insertCount: number = 0, 7 | deleteCount: number = 0, 8 | updateCount: number = 0, 9 | ) { 10 | expect(result.result).to.equal(true) 11 | expect(result).have.property('insert', insertCount) 12 | expect(result).have.property('delete', deleteCount) 13 | expect(result).have.property('update', updateCount) 14 | } 15 | -------------------------------------------------------------------------------- /test/utils/random.ts: -------------------------------------------------------------------------------- 1 | export function rnd(percent: number) { 2 | if (percent > 100 || percent < 0) { 3 | throw new TypeError(`Invaild percent`) 4 | } 5 | const judge = Math.ceil(Math.random() * 100) 6 | return judge < percent 7 | } 8 | 9 | export function number(from: number, to: number) { 10 | return parseInt(from as any) + Math.ceil(Math.random() * (to - from)) 11 | } 12 | 13 | export function string(length: number = 10) { 14 | return Math.random() 15 | .toString(36) 16 | .substr(2, length) 17 | } 18 | -------------------------------------------------------------------------------- /test/schemas/Post.ts: -------------------------------------------------------------------------------- 1 | import { Database, RDBType } from '../index' 2 | 3 | export interface PostSchema { 4 | _id: string 5 | content: string 6 | belongTo: string 7 | created: Date 8 | } 9 | 10 | export default (db: Database) => 11 | db.defineSchema('Post', { 12 | _id: { 13 | type: RDBType.STRING, 14 | primaryKey: true, 15 | }, 16 | content: { 17 | type: RDBType.BOOLEAN, 18 | }, 19 | belongTo: { 20 | type: RDBType.STRING, 21 | }, 22 | created: { 23 | type: RDBType.DATE_TIME, 24 | }, 25 | }) 26 | -------------------------------------------------------------------------------- /src/storage/helper/graph.ts: -------------------------------------------------------------------------------- 1 | import { identity } from '../../utils' 2 | import * as Exception from '../../exception' 3 | 4 | const nestJS = require('nesthydrationjs')() 5 | 6 | export const LiteralArray = 'LiteralArray' 7 | nestJS.registerType(LiteralArray, identity) 8 | 9 | // primaryKey based, key: { id: true } must be given in definition. 10 | export function graph(rows: any[], definition: Object) { 11 | try { 12 | const result = nestJS.nest(rows, [definition]) 13 | return result as T[] 14 | } catch (e) { 15 | throw Exception.GraphFailed(e) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /test/utils/generators/subtask-generator.ts: -------------------------------------------------------------------------------- 1 | import { SubtaskSchema, TeambitionTypes } from '../../index' 2 | import { uuid } from '../uuid' 3 | import * as random from '../random' 4 | 5 | export default function(limit: number, taskId: TeambitionTypes.TaskId) { 6 | const result: SubtaskSchema[] = [] 7 | while (limit > 0) { 8 | limit-- 9 | result.push({ 10 | _id: uuid(), 11 | _taskId: taskId, 12 | content: 'subtask content: ' + uuid(), 13 | isDone: random.rnd(20), 14 | created: new Date(2016, random.number(1, 12), random.number(1, 31)).toISOString(), 15 | }) 16 | } 17 | return result 18 | } 19 | -------------------------------------------------------------------------------- /src/storage/helper/merge-transaction-result.ts: -------------------------------------------------------------------------------- 1 | import * as lf from 'lovefield' 2 | 3 | export function mergeTransactionResult(queries: lf.query.Builder[], transactionResult: any[]) { 4 | const ret = { insert: 0, update: 0, delete: 0 } 5 | 6 | queries.forEach((query, index) => { 7 | if (query instanceof lf.query.InsertBuilder) { 8 | ret.insert += Array.isArray(transactionResult[index]) ? transactionResult[index].length : 1 9 | } else if (query instanceof lf.query.UpdateBuilder) { 10 | ret.update++ 11 | } else if (query instanceof lf.query.DeleteBuilder) { 12 | ret.delete++ 13 | } 14 | }) 15 | 16 | return ret 17 | } 18 | -------------------------------------------------------------------------------- /test/specs/index.ts: -------------------------------------------------------------------------------- 1 | export * from './storage/Database.before.connect.spec' 2 | export * from './storage/Database.public.spec' 3 | export * from './storage/modules/Selector.spec' 4 | export * from './storage/modules/ProxySelector.spec' 5 | export * from './storage/modules/QueryToken.spec' 6 | export * from './storage/modules/PredicateProvider.spec' 7 | export * from './storage/modules/Mutation.spec' 8 | export * from './shared/Traversable.spec' 9 | export * from './utils/utils.spec' 10 | export * from './utils/diff.spec' 11 | export * from './storage/helper/definition.spec' 12 | export * from './storage/helper/graph.spec' 13 | 14 | import './exception.spec' 15 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint-eslint-rules", "tslint-config-prettier"], 3 | "rules": { 4 | "curly": true, 5 | "class-name": true, 6 | "no-duplicate-variable": true, 7 | "no-console": [true, "log", "trace"], 8 | "no-construct": true, 9 | "no-debugger": true, 10 | "no-var-keyword": true, 11 | "no-empty": true, 12 | "no-eval": true, 13 | "no-var-requires": false, 14 | "no-require-imports": false, 15 | "no-shadowed-variable": true, 16 | "prefer-const": true, 17 | "ter-prefer-arrow-callback": true, 18 | "use-isnan": true, 19 | "comment-format": [true, "check-space", "check-lowercase"] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /test/schemas/Engineer.ts: -------------------------------------------------------------------------------- 1 | import { Database, RDBType, Relationship, ProgramSchema } from '../index' 2 | 3 | export interface EngineerSchema { 4 | _id: string 5 | name: string 6 | leadProgram?: ProgramSchema[] 7 | } 8 | 9 | export default (db: Database) => 10 | db.defineSchema('Engineer', { 11 | _id: { 12 | type: RDBType.STRING, 13 | primaryKey: true, 14 | }, 15 | name: { 16 | type: RDBType.STRING, 17 | }, 18 | leadProgram: { 19 | type: Relationship.oneToMany, 20 | virtual: { 21 | name: 'Program', 22 | where(table) { 23 | return { 24 | _id: table.ownerId, 25 | } 26 | }, 27 | }, 28 | }, 29 | }) 30 | -------------------------------------------------------------------------------- /test/schemas/Subtask.ts: -------------------------------------------------------------------------------- 1 | import { TeambitionTypes, Database, RDBType } from '../index' 2 | 3 | export interface SubtaskSchema { 4 | _id: TeambitionTypes.SubtaskId 5 | content: string 6 | _taskId: TeambitionTypes.TaskId 7 | isDone: boolean 8 | created: string 9 | } 10 | 11 | export default (db: Database) => 12 | db.defineSchema('Subtask', { 13 | _id: { 14 | type: RDBType.STRING, 15 | primaryKey: true, 16 | }, 17 | content: { 18 | type: RDBType.STRING, 19 | }, 20 | _taskId: { 21 | type: RDBType.STRING, 22 | index: true, 23 | }, 24 | isDone: { 25 | type: RDBType.BOOLEAN, 26 | }, 27 | created: { 28 | type: RDBType.DATE_TIME, 29 | }, 30 | }) 31 | -------------------------------------------------------------------------------- /src/interface/enum.ts: -------------------------------------------------------------------------------- 1 | export enum RDBType { 2 | ARRAY_BUFFER = 100, 3 | BOOLEAN, 4 | DATE_TIME, 5 | INTEGER, 6 | NUMBER, 7 | OBJECT, 8 | STRING, 9 | LITERAL_ARRAY, 10 | } 11 | 12 | export enum Relationship { 13 | oneToMany = 500, 14 | oneToOne = 501, 15 | manyToMany = 502, 16 | } 17 | 18 | export enum DataStoreType { 19 | INDEXED_DB = 0, 20 | MEMORY = 1, 21 | LOCAL_STORAGE = 2, 22 | WEB_SQL = 3, 23 | OBSERVABLE_STORE = 4, 24 | } 25 | 26 | export enum StatementType { 27 | Insert = 1001, 28 | Update = 1002, 29 | Delete = 1003, 30 | Select = 1004, 31 | } 32 | 33 | export enum JoinMode { 34 | imlicit = 2001, 35 | explicit = 2002, 36 | } 37 | 38 | export enum LeafType { 39 | column = 300, 40 | navigator = 301, 41 | } 42 | -------------------------------------------------------------------------------- /src/global.ts: -------------------------------------------------------------------------------- 1 | // lovefield nodejs polyfill 2 | if (typeof global !== 'undefined') { 3 | if (!global['self']) { 4 | global['self'] = global 5 | } 6 | // shim for SinonJS 7 | if (!global['location']) { 8 | global['location'] = Object.create(null) 9 | } 10 | } 11 | 12 | import * as lf from 'lovefield' 13 | 14 | // lovefield declaration merging 15 | declare module 'lovefield' { 16 | namespace query { 17 | export interface Select extends lf.query.Builder { 18 | clone(): lf.query.Select 19 | } 20 | export function InsertBuilder(): lf.query.Insert 21 | export function SelectBuilder(): lf.query.Select 22 | export function UpdateBuilder(): lf.query.Update 23 | export function DeleteBuilder(): lf.query.Delete 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /test/schemas/Project.ts: -------------------------------------------------------------------------------- 1 | import { TeambitionTypes, Database, RDBType, Relationship } from '../index' 2 | 3 | export interface ProjectSchema { 4 | _id: TeambitionTypes.ProjectId 5 | name: string 6 | isArchived: boolean 7 | posts: any[] 8 | } 9 | export default (db: Database) => 10 | db.defineSchema('Project', { 11 | _id: { 12 | type: RDBType.STRING, 13 | primaryKey: true, 14 | }, 15 | name: { 16 | type: RDBType.STRING, 17 | }, 18 | isArchived: { 19 | type: RDBType.BOOLEAN, 20 | }, 21 | posts: { 22 | type: Relationship.oneToMany, 23 | virtual: { 24 | name: 'Post', 25 | where: (ref) => ({ 26 | _id: ref.belongTo, 27 | }), 28 | }, 29 | }, 30 | }) 31 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2015", 4 | "experimentalDecorators": true, 5 | "sourceMap": true, 6 | "module": "commonjs", 7 | "emitDecoratorMetadata": true, 8 | "suppressImplicitAnyIndexErrors": true, 9 | "noImplicitThis": true, 10 | "noImplicitAny": true, 11 | "noUnusedLocals": true, 12 | "noImplicitReturns": true, 13 | "noUnusedParameters": true, 14 | "baseUrl": "../", 15 | "paths": { 16 | "*": [ 17 | "*" 18 | ], 19 | "reactivedb": [ "./dist/cjs" ] 20 | }, 21 | "allowJs": true, 22 | "typeRoots": [ 23 | "../node_modules/@types" 24 | ] 25 | }, 26 | "exclude": [ 27 | "node_modules" 28 | ], 29 | "compileOnSave": false, 30 | "awesomeTypescriptLoaderOptions": { 31 | "useCache": true 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/storage/modules/ProxySelector.ts: -------------------------------------------------------------------------------- 1 | import { Observable, OperatorFunction } from 'rxjs' 2 | import { map } from 'rxjs/operators' 3 | import { Query } from '../../interface' 4 | import { mapFn } from './mapFn' 5 | 6 | export class ProxySelector { 7 | public request$: Observable 8 | 9 | private mapFn: (stream$: Observable) => Observable = mapFn 10 | 11 | constructor(request$: Observable, public query: Query, public tableName: string) { 12 | this.request$ = request$.pipe(map((r) => (Array.isArray(r) ? r : [r]))) 13 | } 14 | 15 | values() { 16 | return this.mapFn(this.request$) 17 | } 18 | 19 | changes() { 20 | return this.mapFn(this.request$) 21 | } 22 | 23 | map(fn: OperatorFunction) { 24 | this.mapFn = fn 25 | return (this as any) as ProxySelector 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reactivedb-expmple", 3 | "version": "0.7.0", 4 | "description": "Example for ReactiveDB", 5 | "main": "index.ts", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/teambition/ReactiveDB.git" 12 | }, 13 | "keywords": [ 14 | "ReactiveDB", 15 | "RxJS", 16 | "Lovefield" 17 | ], 18 | "author": "lynweklm@gmail.com", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/teambition/ReactiveDB/issues" 22 | }, 23 | "homepage": "https://github.com/teambition/ReactiveDB#readme", 24 | "dependencies": { 25 | "antd": "^4.0.0", 26 | "react": "^17.0.0", 27 | "react-dom": "^17.0.0" 28 | }, 29 | "devDependencies": { 30 | "typescript": "^4.0.0" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2015", 4 | "experimentalDecorators": true, 5 | "sourceMap": true, 6 | "module": "commonjs", 7 | "emitDecoratorMetadata": true, 8 | "suppressImplicitAnyIndexErrors": true, 9 | "noUnusedLocals": true, 10 | "noUnusedParameters": true, 11 | "noImplicitReturns": true, 12 | "strict": true, 13 | "allowJs": true, 14 | "importHelpers": true, 15 | "noEmitHelpers": true, 16 | "lib": [ 17 | "es5", 18 | "es2015", 19 | "es2016", 20 | "es2017" 21 | ] 22 | }, 23 | "exclude": [ 24 | "node_modules", 25 | "tools/build", 26 | "dist", 27 | "coverage", 28 | "example", 29 | "spec-js", 30 | "./webpack.config.js" 31 | ], 32 | "compileOnSave": false, 33 | "awesomeTypescriptLoaderOptions": { 34 | "useTranspileModule": true, 35 | "useCache": true 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /test/specs/exception.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'tman' 2 | import { expect } from 'chai' 3 | import { ReactiveDBException } from '../../src/exception/Exception' 4 | 5 | describe('ReactiveDBException', () => { 6 | it('should create an instance of Error', () => { 7 | expect(new ReactiveDBException('hello')).to.be.instanceOf(Error) 8 | expect(new ReactiveDBException('world', { msg: 'hello' })).to.be.instanceOf(Error) 9 | }) 10 | 11 | it('should create an Error with static name and specified message', () => { 12 | const err = new ReactiveDBException('hello') 13 | expect(err.name).to.equal('ReactiveDBError') 14 | expect(err.message).to.equal('hello') 15 | }) 16 | 17 | it('should allow caller to pass in more related info through `moreInfo` param', () => { 18 | const err = new ReactiveDBException('hello', { msg: 'world' }) 19 | expect(err.message).to.equal('hello\nMoreInfo: {"msg":"world"}') 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /tools/watch.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | import { Observable, Observer, from } from 'rxjs' 3 | import { map, mergeMap, debounceTime } from 'rxjs/operators' 4 | import { runTman } from './tman' 5 | 6 | const fileWacher = require('node-watch') 7 | 8 | function watch(paths: string[]) { 9 | return from(paths).pipe( 10 | map((p) => path.join(process.cwd(), p)), 11 | mergeMap((p) => { 12 | return Observable.create((observer: Observer) => { 13 | fileWacher(p, { recursive: true }, (_: any, fileName: string) => { 14 | observer.next(fileName) 15 | }) 16 | }) 17 | }), 18 | debounceTime(500), 19 | ) 20 | } 21 | 22 | watch(['spec-js']).subscribe( 23 | () => { 24 | runTman() 25 | }, 26 | (err) => { 27 | console.error(err) 28 | }, 29 | ) 30 | 31 | process.on('uncaughtException', (err: any) => { 32 | console.info(`Caught exception: ${err.stack}`) 33 | }) 34 | 35 | console.info('\x1b[1m\x1b[34mwatch start\x1b[39m\x1b[22m') 36 | -------------------------------------------------------------------------------- /docs/Design-Document/01_schema.md: -------------------------------------------------------------------------------- 1 | # ReactiveDB Design Document 2 | 3 | ## 1. Schema Design 4 | 5 | ### 1.1 Schema Metadata 6 | Schema Metadata 是 ReactiveDB 的基础,ReactiveDB 会根据定义的 SchemaMetadata 生成数据表。SchemaMetadata 承载了 7 | 8 | - 数据表的形状信息(字段,类型) 9 | - 对应的数据存入数据表的过程中,数据该如何解析的信息(RDBType) 10 | - 从这个数据表中获取数据的时候,如果获取关联数据的信息(Virtual) 11 | 12 | 这种设计的原因是: 通常 API 返回的数据,是多个数据表 `Join` 之后的结果, 如果要在前端还原这种数据与数据间的 `Join` 关系,就必须要有一个地方承载这种关系的信息,而在一般的数据表设计中,Table 与 Table 的关系(one - one or one - many) 通常是静态可确定的,所以将它作为 Schema 的`元信息`存放在 Schema Metadata 中, ReactiveDB 会使用这个信息在查询数据的时候将数据折叠成和存入时一致的结构。 13 | 14 | ### 1.2 Select Metadata 15 | Schema Metadata 中的数据表形状信息在 ReactiveDB 初始化的时候被消费,然后 Schema Metadata 会被销毁,其中定义的`解析信息`与`关联信息`会转存到 Select Metadata 中。 16 | 除此之外 Select Metadata 还会存储与这个 table 相关联的 Virtual Metadata 17 | 18 | 19 | ### 1.3 Virtual Metadata 20 | Virtual Metadata 存储了 Schema Metadata 中的 Virtual 信息,包括 property name ==> tablename 映射信息,Virtual Table 在取数据时 `leftOuterJoin` 的 Predicate。除此之外, Virtual Metadata 还会承载外部调用 `Database#insert` 时数据中 Virtual 字段的形状信息(Collection or Model)。 21 | -------------------------------------------------------------------------------- /src/storage/helper/db-factory.ts: -------------------------------------------------------------------------------- 1 | import * as lf from 'lovefield' 2 | import { Observable, ConnectableObservable, ReplaySubject, Observer } from 'rxjs' 3 | import { publishReplay } from 'rxjs/operators' 4 | import { LfFactoryInit } from '../../interface' 5 | 6 | export const rawDb$ = new ReplaySubject(1) 7 | 8 | function onUpgrade(rawDb: lf.raw.BackStore) { 9 | rawDb$.next(rawDb) 10 | rawDb$.complete() 11 | return Promise.resolve() 12 | } 13 | 14 | export const lfFactory = ( 15 | schemaBuilder: lf.schema.Builder, 16 | config: LfFactoryInit, 17 | ): ConnectableObservable => { 18 | return Observable.create((observer: Observer) => { 19 | ;(config as any).onUpgrade = onUpgrade 20 | if (config.storeType >= 3) { 21 | config.storeType = config.storeType + 1 22 | } 23 | 24 | schemaBuilder 25 | .connect(config as any) 26 | .then((db) => { 27 | observer.next(db) 28 | observer.complete() 29 | }) 30 | .catch((e) => observer.error(e)) 31 | }).pipe(publishReplay(1)) 32 | } 33 | -------------------------------------------------------------------------------- /example/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const TsConfigPathsPlugin = require('awesome-typescript-loader').TsConfigPathsPlugin 3 | const config = require('../webpack.config') 4 | 5 | config.entry = { 6 | app: './example/index.ts', 7 | reactivedb: ['reactivedb'], 8 | vendor: ['lovefield', 'rxjs', 'tslib', 'react', 'react-dom', 'antd', 'lodash'] 9 | } 10 | 11 | for (const x of config.module.rules) { 12 | if (x.use === 'awesome-typescript-loader') { 13 | x.test = /\.(ts|tsx)$/ 14 | x.use = `awesome-typescript-loader?configFileName=${path.join(process.cwd(), 'example/tsconfig.json')}&useCache=true` 15 | } 16 | } 17 | 18 | config.resolve = { 19 | modules: [ 20 | path.join(process.cwd(), 'example'), 21 | 'node_modules', 22 | path.join(process.cwd(), 'example/node_modules') 23 | ], 24 | extensions: ['.tsx', '.ts', '.js', 'css'], 25 | alias: { 26 | 'reactivedb': path.join(process.cwd(), 'dist/cjs') 27 | // 'lovefield': path.join(process.cwd(), 'node_modules/lovefield/dist/lovefield.es6.js') 28 | } 29 | } 30 | 31 | module.exports = config 32 | -------------------------------------------------------------------------------- /test/utils/generators/relational-scenario-generator.ts: -------------------------------------------------------------------------------- 1 | import { uuid } from '../uuid' 2 | import * as random from '../random' 3 | import { EngineerSchema, ProgramSchema, ModuleSchema } from '../../index' 4 | 5 | export default function() { 6 | const engineerCount = 10 7 | const moduleCount = 5 8 | const programId = uuid() 9 | 10 | const engineers: EngineerSchema[] = Array.from({ length: random.number(0, engineerCount) }, () => { 11 | return { 12 | _id: uuid(), 13 | name: random.string(), 14 | } 15 | }) 16 | 17 | const redundantSeed = random.number(3, 7) 18 | const modules: ModuleSchema[] = Array.from({ length: random.number(0, moduleCount + redundantSeed) }, (_, index) => { 19 | return { 20 | _id: uuid(), 21 | name: random.string(), 22 | ownerId: engineers[random.number(0, engineers.length) - 1]._id, 23 | parentId: index < moduleCount ? programId : uuid(), 24 | } 25 | }) 26 | 27 | const program: ProgramSchema = { 28 | _id: programId, 29 | ownerId: engineers[random.number(0, engineers.length) - 1]._id, 30 | } 31 | 32 | return { program, modules, engineers } 33 | } 34 | -------------------------------------------------------------------------------- /src/storage/helper/definition.ts: -------------------------------------------------------------------------------- 1 | import { forEach } from '../../utils' 2 | import { RDBType, ColumnDef } from '../../interface' 3 | import { LiteralArray } from './graph' 4 | import { Relationship } from '../../interface' 5 | import * as Exception from '../../exception' 6 | 7 | export function revise(relation: Relationship, def: Object) { 8 | switch (relation) { 9 | case Relationship.oneToOne: 10 | forEach(def, (value) => { 11 | if (value.id) { 12 | value.id = false 13 | } 14 | }) 15 | break 16 | case Relationship.oneToMany: 17 | def = [def] 18 | break 19 | case Relationship.manyToMany: 20 | throw Exception.NotImplemented() 21 | default: 22 | throw Exception.UnexpectedRelationship() 23 | } 24 | 25 | return def 26 | } 27 | 28 | /** 29 | * Specify a part of the definition object that is 30 | * to be fed to nestJS.nest function. 31 | */ 32 | export function create(column: string, asId: boolean, type: RDBType): ColumnDef { 33 | if (type === RDBType.LITERAL_ARRAY) { 34 | return { column, id: asId, type: LiteralArray } 35 | } 36 | 37 | return { column, id: asId } 38 | } 39 | -------------------------------------------------------------------------------- /src/utils/clone.ts: -------------------------------------------------------------------------------- 1 | import { forEach } from './for-each' 2 | 3 | export const clone = (origin: T, target: T | null = null): T | null => { 4 | if (origin == null) { 5 | return origin 6 | } 7 | 8 | if (((origin as unknown) as Date).constructor === Date) { 9 | return new Date((origin as any).valueOf()) as any 10 | } 11 | 12 | if (origin instanceof RegExp) { 13 | const pattern = origin.valueOf() as RegExp 14 | let flags = '' 15 | flags += pattern.global ? 'g' : '' 16 | flags += pattern.ignoreCase ? 'i' : '' 17 | flags += pattern.multiline ? 'm' : '' 18 | 19 | return new RegExp(pattern.source, flags) as any 20 | } 21 | 22 | if ( 23 | ((origin as unknown) as Function).constructor === Function || 24 | ((origin as unknown) as Function).constructor === String || 25 | ((origin as unknown) as Function).constructor === Number || 26 | ((origin as unknown) as Function).constructor === Boolean 27 | ) { 28 | return origin 29 | } 30 | 31 | target = target || new (origin as any).constructor() 32 | 33 | forEach(origin, (val, key) => { 34 | target![key] = clone(val, null) 35 | }) 36 | 37 | return target 38 | } 39 | -------------------------------------------------------------------------------- /test/schemas/Module.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs' 2 | import { tap } from 'rxjs/operators' 3 | 4 | import { Database, RDBType, Relationship } from '../index' 5 | 6 | export interface ModuleSchema { 7 | _id: string 8 | name: string 9 | ownerId: string 10 | programmer?: Object 11 | parentId: string 12 | } 13 | 14 | export default (db: Database) => 15 | db.defineSchema('Module', { 16 | _id: { 17 | type: RDBType.STRING, 18 | primaryKey: true, 19 | }, 20 | name: { 21 | type: RDBType.STRING, 22 | unique: true, 23 | }, 24 | ownerId: { 25 | type: RDBType.STRING, 26 | }, 27 | parentId: { 28 | type: RDBType.STRING, 29 | }, 30 | programmer: { 31 | type: Relationship.oneToOne, 32 | virtual: { 33 | name: 'Engineer', 34 | where: (ref) => ({ 35 | ownerId: ref._id, 36 | }), 37 | }, 38 | }, 39 | dispose(rootEntities, scope) { 40 | const [matcher, disposer] = scope('Engineer') 41 | return matcher({ _id: { $in: rootEntities.map((entity) => entity.ownerId) } }).pipe(tap(disposer)) as Observable< 42 | any 43 | > 44 | }, 45 | }) 46 | -------------------------------------------------------------------------------- /test/schemas/index.ts: -------------------------------------------------------------------------------- 1 | import { Database } from '../index' 2 | 3 | import ProjectSelectMetadata from './Project' 4 | import SubtaskSelectMetadata from './Subtask' 5 | import TaskSelectMetadata from './Task' 6 | import PostSelectMetadata from './Post' 7 | import EngineerSelectMetadata from './Engineer' 8 | import ModuleSelectMetadata from './Module' 9 | import ProgramSelectMetadata from './Program' 10 | 11 | export default (db: Database) => { 12 | ProjectSelectMetadata(db) 13 | SubtaskSelectMetadata(db) 14 | TaskSelectMetadata(db) 15 | PostSelectMetadata(db) 16 | EngineerSelectMetadata(db) 17 | ModuleSelectMetadata(db) 18 | ProgramSelectMetadata(db) 19 | } 20 | 21 | export { ProjectSchema } from './Project' 22 | export { PostSchema } from './Post' 23 | export { SubtaskSchema } from './Subtask' 24 | export { TaskSchema } from './Task' 25 | export { ProgramSchema } from './Program' 26 | export { ModuleSchema } from './Module' 27 | export { EngineerSchema } from './Engineer' 28 | export { TestSchema, TestFixture, TestFixture2 } from './Test' 29 | 30 | /** 31 | * import ActivitySelectMeta from './Activity' 32 | * ActivitySelectMeta(db) 33 | * export { ActivitySchema } from './Activity' 34 | */ 35 | -------------------------------------------------------------------------------- /src/utils/assert.ts: -------------------------------------------------------------------------------- 1 | type FailureHandler = (...args: U) => Error 2 | 3 | export function assert(condition: boolean, failureMsg: string): void 4 | export function assert(condition: boolean, failure: FailureHandler, ...failureArgs: U): void 5 | export function assert( 6 | condition: boolean, 7 | failure: FailureHandler | string, 8 | ...failureArgs: U 9 | ): void { 10 | truthyOrThrow(condition, failure, ...failureArgs) 11 | } 12 | 13 | type Maybe = T | null | undefined 14 | 15 | export function assertValue(value: Maybe, falsyValueMsg: string): asserts value is T 16 | export function assertValue( 17 | value: Maybe, 18 | failure: FailureHandler, 19 | ...failureArgs: U 20 | ): asserts value is T 21 | export function assertValue( 22 | value: Maybe, 23 | failure: FailureHandler | string, 24 | ...failureArgs: U 25 | ): asserts value is T { 26 | truthyOrThrow(value, failure, ...failureArgs) 27 | } 28 | 29 | function truthyOrThrow( 30 | x: Maybe, 31 | failure: FailureHandler | string, 32 | ...failureArgs: U 33 | ): asserts x is T { 34 | if ((x as Maybe) === false || x == null) { 35 | const error = typeof failure === 'string' ? new Error(failure) : failure(...failureArgs) 36 | throw error 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/addons/aggresive-optimizer.ts: -------------------------------------------------------------------------------- 1 | const lf = require('lovefield') 2 | 3 | const shallowEqual = function(thisArg: any, columns: any[], left: any, right: any) { 4 | return columns.every(function(this: any, column) { 5 | const colType = column.getType() 6 | const leftField = left.getField(column) 7 | const rightField = right.getField(column) 8 | 9 | if (colType == lf.Type.OBJECT || colType == lf.Type.ARRAY_BUFFER) { 10 | return leftField === rightField 11 | } 12 | 13 | const evalEqual = this.evalRegistry_.getEvaluator(colType, lf.eval.Type.EQ) 14 | return evalEqual(leftField, rightField) 15 | }, thisArg) 16 | } 17 | 18 | const compareFn = function(ctx: any, left: any, right: any) { 19 | if (left.length !== right.length) { 20 | return true 21 | } 22 | 23 | for (let i = 0; i < left.length; i++) { 24 | const ret = shallowEqual(ctx, ctx.columns_, left[i], right[i]) 25 | if (!ret) { 26 | return true 27 | } 28 | } 29 | 30 | return false 31 | } 32 | 33 | export const aggresiveOptimizer = () => { 34 | lf.ObserverRegistry.Entry_.prototype.updateResults = function(newResults: any[]) { 35 | const oldList: any = (this.lastResults_ && this.lastResults_.entries) || [] 36 | const newList: any = newResults.entries || [] 37 | 38 | const hasChanges = compareFn(this.diffCalculator_, oldList, newList) 39 | this.lastResults_ = newResults 40 | 41 | if (hasChanges) { 42 | this.observers_.forEach((observerFn: Function) => { 43 | observerFn() 44 | }) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/storage/helper/predicatable-query.ts: -------------------------------------------------------------------------------- 1 | import * as lf from 'lovefield' 2 | import { StatementType } from '../../interface' 3 | 4 | export function predicatableQuery( 5 | db: lf.Database, 6 | table: lf.schema.Table, 7 | predicate: lf.Predicate | null, 8 | type: StatementType.Select, 9 | ...columns: lf.schema.Column[] 10 | ): lf.query.Select 11 | 12 | export function predicatableQuery( 13 | db: lf.Database, 14 | table: lf.schema.Table, 15 | predicate: lf.Predicate | null, 16 | type: StatementType.Delete, 17 | ...columns: lf.schema.Column[] 18 | ): lf.query.Delete 19 | 20 | export function predicatableQuery( 21 | db: lf.Database, 22 | table: lf.schema.Table, 23 | predicate: lf.Predicate | null, 24 | type: StatementType.Update, 25 | ...columns: lf.schema.Column[] 26 | ): lf.query.Update 27 | 28 | export function predicatableQuery( 29 | db: lf.Database, 30 | table: lf.schema.Table, 31 | predicate: lf.Predicate | null, 32 | type: StatementType.Select | StatementType.Update | StatementType.Delete, 33 | ...columns: lf.schema.Column[] 34 | ) { 35 | let query: lf.query.Select | lf.query.Delete | lf.query.Update 36 | 37 | switch (type) { 38 | case StatementType.Select: 39 | query = db.select(...columns).from(table) 40 | break 41 | case StatementType.Delete: 42 | query = db.delete().from(table) 43 | break 44 | case StatementType.Update: 45 | query = db.update(table) 46 | break 47 | default: 48 | throw TypeError('unreachable code path') 49 | } 50 | 51 | return predicate ? query.where(predicate) : query 52 | } 53 | -------------------------------------------------------------------------------- /test/schemas/Program.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs' 2 | import { concatMap, tap } from 'rxjs/operators' 3 | 4 | import { Database, RDBType, Relationship } from '../index' 5 | 6 | export interface ProgramSchema { 7 | _id: string 8 | ownerId: string 9 | owner?: Object 10 | modules?: Object[] 11 | } 12 | 13 | export default (db: Database) => 14 | db.defineSchema('Program', { 15 | _id: { 16 | type: RDBType.STRING, 17 | primaryKey: true, 18 | }, 19 | ownerId: { 20 | type: RDBType.STRING, 21 | index: true, 22 | }, 23 | owner: { 24 | type: Relationship.oneToOne, 25 | virtual: { 26 | name: 'Engineer', 27 | where: (ref) => ({ 28 | ownerId: ref._id, 29 | }), 30 | }, 31 | }, 32 | modules: { 33 | type: Relationship.oneToMany, 34 | virtual: { 35 | name: 'Module', 36 | where: (moduleTable: lf.schema.Table) => ({ 37 | _id: moduleTable.parentId, 38 | }), 39 | }, 40 | }, 41 | ['@@dispose']: (rootEntities, scope) => { 42 | const [matcher1, disposer1] = scope('Module') 43 | const [matcher2, disposer2] = scope('Engineer') 44 | 45 | return matcher1({ parentId: { $in: rootEntities.map((e) => e._id) } }).pipe( 46 | tap(disposer1), 47 | concatMap((modules) => { 48 | const engineers = rootEntities.map((entity) => entity.ownerId).concat(modules.map((m: any) => m.ownerId)) 49 | return matcher2({ _id: { $in: engineers } }) 50 | }), 51 | tap(disposer2), 52 | ) as Observable 53 | }, 54 | }) 55 | -------------------------------------------------------------------------------- /docs/Design-Document/02_data_lifecycle.md: -------------------------------------------------------------------------------- 1 | # ReactiveDB Design Document 2 | 3 | ## 2. Data Lifecycle 4 | ReactiveDB 的职责之一就是将后端 `Join` 多个 `table` 之后的结果重新拆分并存储为原来的结构。 5 | 整个数据的流动过程其实是: 6 | 7 | `a) Backend Database` => 8 | 9 | `b) Join and Group` => 10 | 11 | `c) Normalize and Store lovefield` => 12 | 13 | `d) Join and Group` => 14 | 15 | `e) Consumed by Views` 16 | 17 | ### 2.1 Store Progress 18 | 在数据流动的过程中,`c` 步骤就是 Data Store 的过程。 19 | 20 | Normalize 的过程在存储数据之前,这个过程的依据是 Schema Metadata 中存储的 Virtual 信息,这些信息会指导 ReactiveDB 完成对数据的拆解。 21 | 数据拆解后会根据 Virtual 信息中的 name 找到目标 `Table` 存入数据。这些过程都是`事务性`的,不会因为数据中间过程的失败导致脏数据产生。 22 | 23 | Store 之后的数据表结构应该和后端存储的数据表结构基本一致。 24 | 25 | 26 | ### 2.2 Data Select 27 | 在数据流动的过程中,`d` 步骤就是 Data Select 的过程,这个过程应该是和 `b` 过程基本一致。 28 | 在 ReactiveDB 中,`get` 方法对应 Select 的过程,它通过以下几个步骤从 `lovefield` 中取出数据: 29 | 30 | 1. 通过传入的 `QueryDescription` 构建 query 对象(如果为空则默认 Select all) 31 | 2. 通过传入的 `QueryDescription` 构建 Predicate 32 | 3. 通过 query predicate 与 fold 方法构建 QueryToken 作为返回值 33 | 4. 在 QueryToken 的 `values` 方法或 `changes` 方法被调用时,执行 `query.where(predicate)` 34 | 5. QueryToken query 执行的结构 `fold` 到 `QueryDescription` 定义的数据结构 35 | 36 | ### 2.3 Data Update 37 | 数据的更新是一次直接调用,与 **Store/Select/Delete** 不同的是,它的 hook 不存在 Database 内,而是由 Database 将 update 事件 Broadcast 到外部。 38 | ReactiveDB 的 `update` 实现过程非常简单: 39 | 40 | `QueryDescription` => `UpdateQuery` => `exec` 41 | 42 | ### 2.4 Data Delete 43 | 在删除一条数据时,后端会在数据库中删除与之相关的数据。这个过程则依赖 ReactiveDB 的使用者定义的 `delete hook`。 44 | deleteHook 的执行过程在 delete 真正要 delete 的数据之前。 45 | 46 | ### 2.5 Data Observe 47 | ReactiveDB 实现 Observe 依赖 lovefield 的 `lf.Database#observe` 方法。 ReactiveDB 只是将这个 observe 包装成 `Observable`。 48 | -------------------------------------------------------------------------------- /src/utils/for-each.ts: -------------------------------------------------------------------------------- 1 | import { noop } from './noop' 2 | 3 | export function forEach(target: Array, eachFunc: (val: T, key: number) => void, inverse?: boolean): void 4 | 5 | export function forEach( 6 | target: { 7 | [index: string]: T 8 | }, 9 | eachFunc: (val: T, key: string) => void, 10 | inverse?: boolean, 11 | ): void 12 | 13 | export function forEach(target: any, eachFunc: (val: any, key: any) => void, inverse?: boolean): void 14 | 15 | export function forEach(target: any, eachFunc: (val: any, key: any) => any, inverse?: boolean): void { 16 | let length: number 17 | let handler = eachFunc 18 | if (target instanceof Set || target instanceof Map) { 19 | // since we cannot use [[Set/Map]].entries() directly 20 | ;(target as any).forEach((value: any, key: any) => { 21 | if (handler(value, key) === false) { 22 | handler = noop 23 | } 24 | }) 25 | } else if (target instanceof Array) { 26 | length = target.length 27 | if (!inverse) { 28 | let i = -1 29 | while (++i < length) { 30 | if (eachFunc(target[i], i) === false) { 31 | break 32 | } 33 | } 34 | } else { 35 | let i = length 36 | while (i--) { 37 | if (eachFunc(target[i], i) === false) { 38 | break 39 | } 40 | } 41 | } 42 | } else if (typeof target === 'object') { 43 | const keys = Object.keys(target) 44 | let key: string 45 | length = keys.length 46 | let i = -1 47 | while (++i < length) { 48 | key = keys[i] 49 | if (eachFunc(target[key], key) === false) { 50 | break 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /test/utils/generators/program-generator.ts: -------------------------------------------------------------------------------- 1 | import { uuid } from '../uuid' 2 | import * as random from '../random' 3 | import { EngineerSchema, ProgramSchema, ModuleSchema } from '../../index' 4 | 5 | export default function(programCount: number = 1, moduleCount: number = 1) { 6 | const engineerCount = moduleCount 7 | const ret: ProgramSchema[] = [] 8 | 9 | let engineers: EngineerSchema[] = [] 10 | let modules: ModuleSchema[] = [] 11 | 12 | engineers = engineers.concat( 13 | Array.from({ length: engineerCount }, () => ({ 14 | _id: uuid(), 15 | name: random.string(), 16 | })), 17 | ) 18 | 19 | while (programCount--) { 20 | const programId = uuid() 21 | const remainModuleCount = (moduleCount - modules.length) / (programCount + 1) 22 | 23 | const pickOneEngineer = () => { 24 | const index = random.number(0, engineers.length - 1) 25 | return engineers.splice(index, 1)[0] 26 | } 27 | 28 | modules = modules.concat( 29 | Array.from({ length: remainModuleCount }, () => { 30 | const _owner = pickOneEngineer() 31 | 32 | return { 33 | _id: uuid(), 34 | name: random.string(), 35 | ownerId: _owner._id, 36 | parentId: programId, 37 | programmer: _owner, 38 | } 39 | }), 40 | ) 41 | 42 | const modulesOfProgram = modules.filter((m) => m.parentId === programId) 43 | const owner = modulesOfProgram[0].programmer as EngineerSchema 44 | 45 | ret.push({ 46 | _id: programId, 47 | ownerId: owner._id, 48 | owner: owner, 49 | modules: modulesOfProgram, 50 | }) 51 | } 52 | 53 | return ret 54 | } 55 | -------------------------------------------------------------------------------- /test/utils/generators/task-generator.ts: -------------------------------------------------------------------------------- 1 | import * as moment from 'moment' 2 | import { TaskSchema } from '../../index' 3 | import { uuid } from '../uuid' 4 | import * as random from '../random' 5 | import subtaskGen from './subtask-generator' 6 | import postGen from './post-generator' 7 | import involveMembersGen from './involved-members-generator' 8 | 9 | export default function(limit: number) { 10 | const result: TaskSchema[] = [] 11 | while (limit > 0) { 12 | limit-- 13 | const _id = uuid() 14 | const _projectId = uuid() 15 | const _stageId = uuid() 16 | const _creatorId = uuid() 17 | const _executorId = random.rnd(20) ? uuid() : _creatorId 18 | const involves = [_executorId] 19 | if (_creatorId !== _executorId) { 20 | involves.push(_creatorId) 21 | } 22 | const subtasks = subtaskGen(random.number(1, 20), _id) 23 | result.push({ 24 | _id, 25 | _projectId, 26 | _stageId, 27 | _creatorId, 28 | _executorId, 29 | _tasklistId: uuid(), 30 | _sourceId: null, 31 | accomplished: null, 32 | subtasks, 33 | subtasksCount: subtasks.length, 34 | content: 'content: ' + uuid(), 35 | note: 'note: ' + uuid(), 36 | project: { 37 | _id: _projectId, 38 | name: 'project name: ' + uuid(), 39 | isArchived: true, 40 | posts: postGen(5, _projectId), 41 | }, 42 | involveMembers: involveMembersGen(15, involves), 43 | created: moment() 44 | .add(6 - random.number(0, 12), 'month') 45 | .add(30 - random.number(0, 30), 'day') 46 | .toISOString(), 47 | }) 48 | } 49 | 50 | return result 51 | } 52 | -------------------------------------------------------------------------------- /test/specs/storage/helper/graph.spec.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, it, describe } from 'tman' 2 | import { expect } from 'chai' 3 | import { graph } from '../../../../src/storage/helper' 4 | import { GraphFailed } from '../../../index' 5 | 6 | export default describe('Helper - Graph Testcase: ', () => { 7 | let data: Object[] 8 | let definition: Object 9 | 10 | beforeEach(() => { 11 | data = [ 12 | { 13 | id: 1, 14 | foo: 'foo', 15 | }, 16 | { 17 | id: 1, 18 | foo: 'bar', 19 | }, 20 | { 21 | id: 2, 22 | foo: 'baz', 23 | }, 24 | ] 25 | 26 | definition = { 27 | id: { 28 | column: 'id', 29 | }, 30 | foo: [{ content: { column: 'foo' } }], 31 | } 32 | }) 33 | 34 | it('should merge data as definition', () => { 35 | const result = graph(data, definition) 36 | const expectResult = [ 37 | { 38 | id: 1, 39 | foo: [ 40 | { 41 | content: 'foo', 42 | }, 43 | { 44 | content: 'bar', 45 | }, 46 | ], 47 | }, 48 | { 49 | id: 2, 50 | foo: [ 51 | { 52 | content: 'baz', 53 | }, 54 | ], 55 | }, 56 | ] 57 | 58 | expect(result).deep.equal(expectResult) 59 | }) 60 | 61 | it('should throw when definition is unsuitable', () => { 62 | const check = () => graph(data, { baz: {} }) 63 | const err = new Error("invalid structPropToColumnMap format - property 'baz' can not be an empty object") 64 | const standardErr = GraphFailed(err) 65 | 66 | expect(check).to.throw(standardErr.message) 67 | }) 68 | }) 69 | -------------------------------------------------------------------------------- /test/e2e/fetch.ts: -------------------------------------------------------------------------------- 1 | import { ajax } from 'rxjs/ajax' 2 | import { map, concatMap, tap } from 'rxjs/operators' 3 | 4 | import { database } from './database' 5 | import factory from '../schemas' 6 | 7 | factory(database) 8 | database.connect() 9 | 10 | ajax({ 11 | url: `http://project.ci/api/v2/tasks/me/?count=100&isDone=false&page=1`, 12 | withCredentials: true, 13 | crossDomain: true, 14 | }) 15 | .pipe( 16 | map((r) => r.response), 17 | concatMap((r) => { 18 | // return Observable.from(r).concatMap(r2 => { 19 | // return database.upset3('Task', r2) 20 | // }) 21 | return database.upsert('Task', r) 22 | }), 23 | tap({ 24 | error: () => { 25 | database 26 | .get('Task') 27 | .values() 28 | .subscribe((ret) => console.warn(ret)) 29 | }, 30 | }), 31 | concatMap(() => { 32 | console.timeEnd('Task insert') 33 | return ajax({ 34 | url: `http://project.ci/api/v2/tasks/me/subtasks?count=500&isDone=false&page=1`, 35 | withCredentials: true, 36 | crossDomain: true, 37 | }) 38 | }), 39 | map((r) => r.response), 40 | concatMap((r) => database.insert('Subtask', r)), 41 | tap(() => { 42 | database.insert('Subtask', { 43 | _id: 1, 44 | content: 'foo', 45 | _taskId: '56cfbabb981fbfc92eb8f517', 46 | isDone: true, 47 | created: new Date().toISOString(), 48 | }) 49 | }), 50 | concatMap(() => { 51 | return database.get('Task').values() 52 | }), 53 | ) 54 | .subscribe( 55 | (_) => { 56 | console.warn('effected records count:', _) 57 | database 58 | .get('Task') 59 | .values() 60 | .subscribe((r) => console.warn(r)) 61 | }, 62 | (err) => { 63 | console.error(err) 64 | }, 65 | ) 66 | -------------------------------------------------------------------------------- /tools/publish.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import { resolve } from 'path' 3 | import * as shelljs from 'shelljs' 4 | 5 | const tagReg = /^[0-9]+(\.[0-9]+)*(-(alpha|beta)\.[0-9]+)?/ 6 | 7 | const gitExecResult = shelljs.exec('git log -1 --pretty=%B') 8 | const gitError = gitExecResult.stderr 9 | 10 | if (gitError) { 11 | console.info(gitError) 12 | process.exit(1) 13 | } 14 | 15 | const gitStdout = gitExecResult.stdout 16 | 17 | if (!tagReg.test(gitStdout)) { 18 | console.info('Not a release commit.') 19 | process.exit(0) 20 | } 21 | 22 | const pkg = require('../package.json') 23 | const README = fs.readFileSync(resolve(process.cwd(), 'README.md'), 'utf8') 24 | 25 | const cjsPkg = { ...pkg, main: './index.js' } 26 | const esPkg = { ...cjsPkg, name: 'reactivedb-es', sideEffects: false } 27 | 28 | const write = (distPath: string, data: any) => { 29 | return new Promise((res, reject) => { 30 | fs.writeFile(resolve(process.cwd(), distPath), data, 'utf8', (err) => { 31 | if (!err) { 32 | return res() 33 | } 34 | reject(err) 35 | }) 36 | }) 37 | } 38 | 39 | const cjsPkgData = JSON.stringify(cjsPkg, null, 2) 40 | const esPkgData = JSON.stringify(esPkg, null, 2) 41 | 42 | Promise.all([ 43 | write('dist/cjs/package.json', cjsPkgData), 44 | write('dist/es/package.json', esPkgData), 45 | write('dist/es/README.md', README), 46 | write('dist/cjs/README.md', README), 47 | ]) 48 | .then(() => { 49 | const { stderr, stdout } = shelljs.exec('npm publish dist/cjs --tag=next') 50 | if (stderr) { 51 | throw stderr 52 | } 53 | console.info(stdout) 54 | }) 55 | .then(() => { 56 | const { stderr, stdout } = shelljs.exec('npm publish dist/es --tag=next') 57 | if (stderr) { 58 | throw stderr 59 | } 60 | console.info(stdout) 61 | }) 62 | .catch((e: Error) => { 63 | console.error(e) 64 | process.exit(1) 65 | }) 66 | -------------------------------------------------------------------------------- /src/utils/try-catch.ts: -------------------------------------------------------------------------------- 1 | type Value = { kind: 'value'; unwrapped: T } 2 | type Exception = { kind: 'exception'; unwrapped: Error } 3 | type Maybe = Value | Exception 4 | 5 | export function isException(maybeT: Maybe): maybeT is Exception { 6 | return maybeT.kind === 'exception' 7 | } 8 | 9 | export const attachMoreErrorInfo = (error: T, info: {}): T => { 10 | const appendMessage = `\nMoreInfo: ${JSON.stringify(info)}` 11 | if (error instanceof Error) { 12 | error.message += appendMessage 13 | return error 14 | } else { 15 | return (error + appendMessage) as T 16 | } 17 | } 18 | 19 | type Options = { 20 | doThrow?: F 21 | [errorInfoKey: string]: any 22 | } 23 | 24 | type Return = F extends true ? Value : Maybe 25 | 26 | /** 27 | * 包裹一个可能抛异常的、生成值类型为`T`的函数,令其抛异常这种可能, 28 | * 显式地表达在使用它的代码里: 29 | * 30 | * 1. 使用的代码里提供`doThrow: true`选项,表示包裹得到的函数可以 31 | * 直接抛异常;而在没发生异常的情况下,会返回`Value`类型。 32 | * 33 | * 2. 如果`doThrow`选项为假值或空值(没有设置),表示包裹得到的函数 34 | * 在发生异常的情况下,不会将其直接抛出,而是返回`Exception`类型;而 35 | * 在没发生异常的情况下,会返回`Value`类型。 36 | * 37 | * 另外,可以在使用的代码(`options`)里提供任意键值对,它们会在出现 38 | * 异常时被 JSON.stringify,并添加到对应的 Error 对象的 message 里。 39 | */ 40 | export function tryCatch(this: any, fn: (...args: U) => T) { 41 | return (options?: Options) => (...args: U): Return => { 42 | try { 43 | return { 44 | kind: 'value', 45 | unwrapped: fn.apply(this, args), 46 | } as Return 47 | } catch (error) { 48 | if (options) { 49 | const { doThrow, ...info } = options 50 | error = attachMoreErrorInfo(error, info) 51 | if (doThrow) { 52 | throw error 53 | } 54 | } 55 | return { 56 | kind: 'exception', 57 | unwrapped: error, 58 | } as Return 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/exception/database.ts: -------------------------------------------------------------------------------- 1 | import { ReactiveDBException } from './Exception' 2 | 3 | export const NonExistentTable = (tableName: string) => 4 | new ReactiveDBException(`Table: \`${tableName}\` cannot be found.`) 5 | 6 | export const UnmodifiableTable = () => 7 | new ReactiveDBException(`Method: defineSchema cannot be invoked since schema is existed or database is connected`) 8 | 9 | export const InvalidQuery = () => new ReactiveDBException('Only navigation properties were included in query.') 10 | 11 | export const AliasConflict = (column: string, tableName: string) => 12 | new ReactiveDBException(`Definition conflict, Column: \`${column}\` on table: ${tableName}.`) 13 | 14 | export const GraphFailed = (err: Error) => 15 | new ReactiveDBException(`Graphify query result failed, due to: ${err.message}.`) 16 | 17 | export const NotImplemented = () => new ReactiveDBException('Not implemented yet.') 18 | 19 | export const UnexpectedRelationship = () => new ReactiveDBException('Unexpected relationship was specified.') 20 | 21 | export const InvalidType = (expect?: [string, string]) => { 22 | let message = 'Unexpected data type' 23 | if (expect) { 24 | message += `, expect ${expect[0]} but got ${expect[1]}` 25 | } 26 | return new ReactiveDBException(message + '.') 27 | } 28 | 29 | export const UnexpectedTransactionUse = () => 30 | new ReactiveDBException('Please use Database#transaction to get a transaction scope first.') 31 | 32 | export const PrimaryKeyNotProvided = (moreInfo?: {}) => 33 | new ReactiveDBException(`Primary key was not provided.`, moreInfo) 34 | 35 | export const PrimaryKeyConflict = (moreInfo?: {}) => 36 | new ReactiveDBException(`Primary key was already provided.`, moreInfo) 37 | 38 | export const DatabaseIsNotEmpty = () => 39 | new ReactiveDBException('Method: load cannnot be invoked since database is not empty.') 40 | 41 | export const NotConnected = () => 42 | new ReactiveDBException('Method: dispose cannnot be invoked before database is connected.') 43 | -------------------------------------------------------------------------------- /test/schemas/Test.ts: -------------------------------------------------------------------------------- 1 | import { TeambitionTypes, Database, RDBType, Relationship } from '../index' 2 | 3 | export interface TestSchema { 4 | _id: string 5 | name: string 6 | taskId: TeambitionTypes.TaskId 7 | } 8 | 9 | export const TestFixture = (db: Database) => { 10 | const schema = { 11 | _id: { 12 | type: RDBType.STRING, 13 | primaryKey: true, 14 | }, 15 | data1: { 16 | type: RDBType.ARRAY_BUFFER, 17 | }, 18 | data2: { 19 | type: RDBType.NUMBER, 20 | }, 21 | data3: { 22 | type: RDBType.STRING, 23 | virtual: { 24 | name: 'Project', 25 | where: (ref: any) => ({ 26 | _projectId: ref._id, 27 | }), 28 | }, 29 | }, 30 | data4: { 31 | type: RDBType.OBJECT, 32 | }, 33 | data5: { 34 | type: RDBType.INTEGER, 35 | }, 36 | } 37 | 38 | db.defineSchema('Fixture1', schema) 39 | } 40 | 41 | export const TestFixture2 = (db: Database) => { 42 | const schema = { 43 | _id: { 44 | type: RDBType.STRING, 45 | primaryKey: true, 46 | as: 'id', 47 | }, 48 | data1: { 49 | type: RDBType.ARRAY_BUFFER, 50 | }, 51 | data2: { 52 | type: RDBType.NUMBER, 53 | }, 54 | data3: { 55 | type: RDBType.OBJECT, 56 | }, 57 | data4: { 58 | type: RDBType.INTEGER, 59 | }, 60 | data5: { 61 | type: RDBType.LITERAL_ARRAY, 62 | }, 63 | data6: { 64 | type: 1000 as RDBType, 65 | }, 66 | } 67 | 68 | return db.defineSchema('Fixture2', schema) 69 | } 70 | 71 | export const TestFixture3 = (db: Database) => { 72 | const schema = { 73 | id: { 74 | type: RDBType.STRING, 75 | primaryKey: true, 76 | }, 77 | data1: { 78 | type: RDBType.NUMBER, 79 | }, 80 | data2: { 81 | type: Relationship.oneToMany, 82 | virtual: { 83 | name: 'Project', 84 | where: (ref: any) => ({ 85 | id: ref['_id'], 86 | }), 87 | }, 88 | }, 89 | } 90 | 91 | return db.defineSchema('Test', schema) 92 | } 93 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | defaults: &defaults 2 | working_directory: ~/ReactiveDB 3 | docker: 4 | - image: circleci/node:16 5 | 6 | version: 2 7 | jobs: 8 | build: 9 | <<: *defaults 10 | steps: 11 | - checkout 12 | - run: echo 'export PATH=${PATH}:${HOME}/${CIRCLE_PROJECT_REPONAME}/node_modules/.bin' >> $BASH_ENV 13 | - run: curl --compressed -o- -L https://yarnpkg.com/install.sh | bash 14 | - run: sudo ln -sf ~/.yarn/bin/yarn /usr/local/bin/yarn 15 | - restore_cache: 16 | key: dependency-cache-{{ checksum "package.json" }} 17 | - run: 18 | name: yarn-with-greenkeeper 19 | command: | 20 | sudo yarn global add greenkeeper-lockfile@1 21 | yarn 22 | - save_cache: 23 | key: dependency-cache-{{ checksum "package.json" }} 24 | paths: 25 | - ~/.cache/yarn 26 | - run: greenkeeper-lockfile-update 27 | - run: npm run build_all 28 | - run: greenkeeper-lockfile-upload 29 | - persist_to_workspace: 30 | root: ~/ReactiveDB 31 | paths: 32 | - ./* 33 | test: 34 | <<: *defaults 35 | steps: 36 | - attach_workspace: 37 | at: ~/ReactiveDB 38 | - run: npm run lint 39 | - run: npm run check_circular_dependencies 40 | - run: npm run cover 41 | - run: npm run test_O1 42 | - run: 43 | name: test-coverage 44 | command: npx codecov -f coverage/*.json 45 | 46 | deploy: 47 | <<: *defaults 48 | steps: 49 | - attach_workspace: 50 | at: ~/ReactiveDB 51 | - run: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc 52 | - run: yarn publish_all 53 | 54 | workflows: 55 | version: 2 56 | build_test_and_deploy: 57 | jobs: 58 | - build 59 | - test: 60 | requires: 61 | - build 62 | - deploy: 63 | requires: 64 | - test 65 | filters: 66 | tags: 67 | only: /.*/ 68 | branches: 69 | only: master 70 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017-2019 ReactiveDB 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | MIT License 24 | 25 | Copyright (c) 2017 Teambition 26 | 27 | Permission is hereby granted, free of charge, to any person obtaining a copy 28 | of this software and associated documentation files (the "Software"), to deal 29 | in the Software without restriction, including without limitation the rights 30 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 31 | copies of the Software, and to permit persons to whom the Software is 32 | furnished to do so, subject to the following conditions: 33 | 34 | The above copyright notice and this permission notice shall be included in all 35 | copies or substantial portions of the Software. 36 | 37 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 38 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 39 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 40 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 41 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 42 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 43 | SOFTWARE. -------------------------------------------------------------------------------- /test/specs/storage/helper/definition.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'tman' 2 | import { expect } from 'chai' 3 | import { definition, Relationship, RDBType, NotImplemented, UnexpectedRelationship } from '../../../index' 4 | 5 | export default describe('Helper - definition Testcase: ', () => { 6 | describe('Func: create', () => { 7 | it('should be able to create a basic column definition', () => { 8 | const def = definition.create('foo', true, RDBType.INTEGER) 9 | expect(def).to.deep.equal({ 10 | column: 'foo', 11 | id: true, 12 | }) 13 | }) 14 | 15 | it('should be able to create a `LiteralArray` typed column definition', () => { 16 | const def = definition.create('bar', false, RDBType.LITERAL_ARRAY) 17 | expect(def).to.deep.equal({ 18 | column: 'bar', 19 | id: false, 20 | type: 'LiteralArray', 21 | }) 22 | }) 23 | }) 24 | 25 | describe('Func: revise', () => { 26 | it('should revise the definition based on oneToOne relationship', () => { 27 | const fixture = { 28 | foo: { 29 | column: 'id', 30 | id: true, 31 | }, 32 | } 33 | const def = definition.revise(Relationship.oneToOne, fixture) 34 | 35 | expect(def).to.deep.equal({ 36 | foo: { 37 | column: 'id', 38 | id: false, 39 | }, 40 | }) 41 | }) 42 | 43 | it('should revise the definition based on oneToMany relationship', () => { 44 | const fixture = { 45 | column: 'id', 46 | id: true, 47 | } 48 | const def = definition.revise(Relationship.oneToMany, fixture) 49 | 50 | expect(def).to.deep.equal([fixture]) 51 | }) 52 | 53 | it('should revise the definition based on manyToMany relationship', () => { 54 | const fixture = { 55 | column: 'id', 56 | id: true, 57 | } 58 | 59 | const check = () => definition.revise(Relationship.manyToMany, fixture) 60 | 61 | expect(check).to.throw(NotImplemented().message) 62 | }) 63 | 64 | it('should throw if a incorrect relationship was deteched', () => { 65 | const fixture = { 66 | foo: { 67 | column: 'foo', 68 | id: true, 69 | }, 70 | } 71 | const check = () => definition.revise(123 as any, fixture) 72 | 73 | expect(check).to.throw(UnexpectedRelationship().message) 74 | }) 75 | }) 76 | }) 77 | -------------------------------------------------------------------------------- /src/shared/Traversable.ts: -------------------------------------------------------------------------------- 1 | import { forEach, getType } from '../utils' 2 | import { TraverseContext } from '../interface' 3 | 4 | export class Traversable { 5 | private ctxgen: (key: any, val: any, ctx: TraverseContext) => T | boolean 6 | 7 | constructor(private entities: any) { 8 | this.ctxgen = () => true 9 | } 10 | 11 | context(fn: (key: any, val?: any, ctx?: TraverseContext) => T | boolean) { 12 | this.ctxgen = fn 13 | return this 14 | } 15 | 16 | keys(target: any): any[] { 17 | switch (typeof target) { 18 | case 'string': 19 | case 'boolean': 20 | case 'number': 21 | case 'undefined': 22 | return [] 23 | default: 24 | if (target instanceof Date) { 25 | return [] 26 | } else if (target && typeof target.keys === 'function') { 27 | return Array.from(target.keys()) 28 | } else { 29 | return (typeof target === 'object' && target !== null) || Array.isArray(target) ? Object.keys(target) : [] 30 | } 31 | } 32 | } 33 | 34 | forEach(eachFunc: (ctx: T & TraverseContext, node: any) => void) { 35 | const self = this 36 | let index = -1 37 | function walk(node: any, path: string[] = [], parents: any[] = []) { 38 | let advanced = true 39 | const children = self.keys(node) 40 | const parent = parents[parents.length - 1] 41 | 42 | index++ 43 | const defaultCtx = { 44 | isRoot: path.length === 0, 45 | node, 46 | path: path, 47 | parent, 48 | children: children, 49 | key: path[path.length - 1], 50 | isLeaf: children.length === 0, 51 | type: () => getType(node), 52 | skip: () => (advanced = false), 53 | index, 54 | } 55 | 56 | const ret: Object | boolean = self.ctxgen(path[path.length - 1], node, defaultCtx) 57 | 58 | if (ret !== false) { 59 | const ctx = typeof ret === 'object' ? { ...defaultCtx, ...ret } : defaultCtx 60 | 61 | eachFunc.call(null, ctx as any, node) 62 | } 63 | 64 | if (!advanced) { 65 | return 66 | } else if (!defaultCtx.isLeaf) { 67 | forEach(node, (val, key) => { 68 | const nextPath = path.concat(key) 69 | const nextParents = Array.isArray(node) ? parents.concat([node]) : parents.concat(node) 70 | 71 | walk(val, nextPath, nextParents) 72 | }) 73 | } 74 | } 75 | 76 | walk(this.entities) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /docs/Design-Document/00_goals.md: -------------------------------------------------------------------------------- 1 | # ReactiveDB Design Document 2 | 3 | ## 0. Goals 4 | ReactiveDB 主要目标是提供可声明式调用的接口来操作底层的关系型数据库,并返回响应式的结果。 5 | 6 | ## 0.1 Motivation 7 | Lovefield 提供了命令式的接口来操作关系型的数据,但这些接口是不够抽象且不够直观的。ReactiveDB 最初被用来封装 Lovefield 中的常用接口,比如建数据表,更新数据,observe 数据等。在这个实践过程中我们发现我们封装出来的功能与传统数据库中的 ORM 角色非常类似,于是 ReactiveDB 被重新设计成一个类 ORM 的 Database Driven。将所底层数据库提供的接口转化成 Reactive 风格,并且能以声明式调用,降低使用数据库的难度。 8 | 9 | ## 0.2 Database 10 | ReactiveDB 使用 Lovefield 是经过多方面考察的结果。 11 | 12 | 1. Lovefield 支持最新的 IndexedDB 13 | 2. Lovefield 有 Google 的复杂产品实践背书(Inbox) 14 | 3. Lovefield 支持事务,可以极大的程度上避免脏数据的产生 15 | 4. 作者响应问题迅速(通常 issue 在 5 分钟内就有回应) 16 | 17 | 而且 Lovefield 提供的 `query` 级别的 **observe** 非常适合被封装成 Reactive 风格的接口。而市面上其它的 `js-sql` Database Driven 都无法提供这种粒度的 **observe**。 18 | 19 | ## 0.3 Components of ReactiveDB 20 | ReactiveDB 由下面几个部分组成: 21 | - Database (src/storage/Database.ts) 22 | - Schema Management 23 | - Lovefield Bridge 24 | - CRUD Method 25 | - QueryToken (src/storage/QueryToken.ts) 26 | - SelectMeta (src/storage/SelectMeta.ts) 27 | - Query Engine 28 | - PredicateProvider (src/storage/PredicateProvider.ts) 29 | - Type Definition (src/storage/DataType.ts) 30 | 31 | ## 0.4 Design Principles 32 | - 不做 hack,不侵入任何使用的库的功能 (Lovefield & RxJS) 33 | - 职责单一,所有方法都有明确的职责和语义 34 | - 初始化前的静态方法全部是同步的,初始化后的方法都是异步的 35 | - Query 的写法与社区主流的写法保持风格的一致(主要参考 Sequelize),关键的术语比如 and, or, lt, gt 不使用其它词代替。 36 | 37 | ## 0.5 API Design 38 | Lovefield 的 API 设计宗旨有一条就是易读性。其中有一个例子很有代表性: 39 | > Microsoft 的 LINQ 实现的接口类似: 40 | ```ts 41 | db.from(job) 42 | .where(function(x) { return x.id < 200; }) 43 | .select(); 44 | ``` 45 | > 而这种封装方式破坏了 SQL 语言最大的优势 "易读性",相对 LINQ,lovefield 提供的 API 是这种风格: 46 | ```ts 47 | db.select() 48 | .from(job) 49 | .where(job.id.lt(200)) 50 | .exec(); 51 | ``` 52 | > 这种风格的封装保持了 SQL 接近自然语言且容易读懂的优势。 53 | 54 | 在 ReactiveDB 的 API 实现过程中,极力避免了以破坏语义为代价去实现一个 API。 55 | 比如 `Database#get` 方法的第二个参数,`selectQl` 是这种形式的: 56 | 57 | ```ts 58 | { 59 | fields: [ 60 | '_id', 'name', 'ownerId', 61 | { 62 | owner: ['_id', 'name', 'avatarUrl'] 63 | } 64 | ], 65 | where: { 66 | '$or': { 67 | dueDate: { 68 | '$gt': new Date().valueOf() 69 | }, 70 | created: { 71 | '$lt': new Date(2015, 1, 1).valueOf() 72 | } 73 | } 74 | } 75 | } 76 | ``` 77 | 78 | 其中 fields 字段的定义方式借鉴了 `[GraphQL](http://facebook.github.io/graphql/)` 的 QL 定义,而 where 的定义借鉴了 [Sequelize](http://docs.sequelizejs.com/en/v3/docs/querying/#where) 的 定义。 79 | -------------------------------------------------------------------------------- /test/utils/mocks/Lovefield.ts: -------------------------------------------------------------------------------- 1 | export class MockQueryBuilder { 2 | toSql() { 3 | return 'SELECT * FROM MOCK WHERE COND = FALSE' 4 | } 5 | 6 | explain() { 7 | return 'MOCK EXPLAIN' 8 | } 9 | 10 | exec() { 11 | return new Promise((resolve) => { 12 | resolve(0) 13 | }) 14 | } 15 | 16 | bind() { 17 | return this 18 | } 19 | } 20 | 21 | export class MockInsert extends MockQueryBuilder { 22 | into() { 23 | return this 24 | } 25 | 26 | values() { 27 | return this 28 | } 29 | } 30 | 31 | export class MockUpdate extends MockQueryBuilder { 32 | private params = Object.create(null) 33 | 34 | where(predicate: lf.Predicate) { 35 | return predicate 36 | } 37 | 38 | set(key: any, val: any) { 39 | this.params[key.toString()] = val 40 | return this 41 | } 42 | 43 | valueOf() { 44 | return this.params 45 | } 46 | } 47 | 48 | export class MockDatabaseTable { 49 | constructor(private name: string = null) { 50 | return new Proxy(this, { 51 | get: function(target, prop) { 52 | if (target[prop]) { 53 | return target[prop] 54 | } 55 | return new MockComparator(prop.toString()) 56 | }, 57 | }) 58 | } 59 | 60 | createRow() { 61 | return {} as lf.Row 62 | } 63 | 64 | getName() { 65 | return this.name.toString() ? this.name.toString() : 'MOCKTABLE' 66 | } 67 | } 68 | 69 | export class MockComparator { 70 | private handler = (_: any) => null as lf.Predicate 71 | public and: lf.Predicate 72 | public or: lf.Predicate 73 | public eq: lf.Predicate 74 | public match: lf.Predicate 75 | public not: lf.Predicate 76 | public lt: lf.Predicate 77 | public lte: lf.Predicate 78 | public gt: lf.Predicate 79 | public gte: lf.Predicate 80 | public between: lf.Predicate 81 | public in: lf.Predicate 82 | public isNull: lf.Predicate 83 | 84 | constructor(private propName: string) { 85 | this.and = this.between = this.eq = this.gt = this.gte = this.in = this.isNull = this.lt = this.lte = this.match = this.not = this.handler 86 | } 87 | 88 | valueOf() { 89 | return this.propName 90 | } 91 | 92 | toString() { 93 | return this.propName 94 | } 95 | } 96 | 97 | export class MockDatabase { 98 | update(_: MockDatabaseTable) { 99 | return new MockUpdate() 100 | } 101 | 102 | insertOrReplace() { 103 | return new MockInsert() 104 | } 105 | 106 | insert() { 107 | return new MockInsert() 108 | } 109 | 110 | getSchema() { 111 | return { 112 | table: () => new MockDatabaseTable(), 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /test/specs/storage/modules/ProxySelector.spec.ts: -------------------------------------------------------------------------------- 1 | import { Subject, Subscription } from 'rxjs' 2 | import { map } from 'rxjs/operators' 3 | import { expect } from 'chai' 4 | import { beforeEach, it, describe, afterEach } from 'tman' 5 | import { ProxySelector } from '../../../index' 6 | 7 | export default describe('ProxySelector test', () => { 8 | let selector: ProxySelector 9 | let subscription: Subscription | undefined 10 | let request$: Subject 11 | 12 | beforeEach(() => { 13 | request$ = new Subject() 14 | selector = new ProxySelector(request$, {} as any, 'Task') 15 | }) 16 | 17 | afterEach(() => { 18 | request$.complete() 19 | request$.unsubscribe() 20 | if (subscription instanceof Subscription) { 21 | subscription.unsubscribe() 22 | } 23 | }) 24 | 25 | it('should transfer Object to Array by `values`', (done) => { 26 | const fixture = { 27 | foo: 'bar', 28 | } 29 | 30 | subscription = selector.values().subscribe(([r]) => { 31 | expect(r).to.deep.equal(fixture) 32 | done() 33 | }) 34 | 35 | request$.next(fixture) 36 | }) 37 | 38 | it('should return Array directly by `values`', (done) => { 39 | const fixture = { 40 | foo: 'bar', 41 | } 42 | 43 | subscription = selector.values().subscribe((r) => { 44 | expect(r).to.deep.equal([fixture]) 45 | done() 46 | }) 47 | 48 | request$.next([fixture]) 49 | }) 50 | 51 | it('changes should complete after emit value', (done) => { 52 | const fixture = { 53 | foo: 'bar', 54 | } 55 | 56 | subscription = selector.values().subscribe({ 57 | next: (r) => { 58 | expect(r).to.deep.equal([fixture]) 59 | done() 60 | }, 61 | complete: () => done(), 62 | }) 63 | 64 | request$.next([fixture]) 65 | request$.complete() 66 | }) 67 | 68 | it('should map values', (done) => { 69 | const fixture = { 70 | foo: 'bar', 71 | } 72 | 73 | subscription = selector 74 | .map((s$) => s$.pipe(map((r) => r.map(() => 1)))) 75 | .values() 76 | .subscribe((r) => { 77 | expect(r).to.deep.equal([1]) 78 | done() 79 | }) 80 | 81 | request$.next([fixture]) 82 | }) 83 | 84 | it('should map changes', (done) => { 85 | const fixture = { 86 | foo: 'bar', 87 | } 88 | 89 | subscription = selector 90 | .map((s$) => s$.pipe(map((r) => r.map(() => 1)))) 91 | .changes() 92 | .subscribe((r) => { 93 | expect(r).to.deep.equal([1]) 94 | done() 95 | }) 96 | 97 | request$.next([fixture]) 98 | }) 99 | }) 100 | -------------------------------------------------------------------------------- /test/utils/mocks/Selector.ts: -------------------------------------------------------------------------------- 1 | import { ReplaySubject, Observable, from } from 'rxjs' 2 | import { combineAll, map, flatMap, reduce, take } from 'rxjs/operators' 3 | 4 | export class MockSelector { 5 | static datas = new Map() 6 | static selectMeta = new Map>() 7 | 8 | static update(_id: string, patch: any) { 9 | if (!MockSelector.datas.has(_id)) { 10 | throw new TypeError(`Patch target is not exist: ${_id}`) 11 | } 12 | const data = MockSelector.datas.get(_id) 13 | const newData = Object.assign({}, data, patch) 14 | MockSelector.datas.set(_id, newData) 15 | const mockSelector = MockSelector.selectMeta.get(_id) 16 | mockSelector.datas = mockSelector.datas.map((d) => { 17 | return d === data ? newData : d 18 | }) 19 | 20 | mockSelector.notify() 21 | } 22 | 23 | private static mapFn = (dist$: Observable) => dist$ 24 | 25 | private subject = new ReplaySubject(1) 26 | private change$ = this.subject 27 | private datas: T[] 28 | private mapFn = MockSelector.mapFn 29 | 30 | constructor(datas: Map) { 31 | const result: T[] = [] 32 | datas.forEach((val, key) => { 33 | if (MockSelector.datas.has(key)) { 34 | throw new TypeError(`Conflic data`) 35 | } 36 | MockSelector.datas.set(key, val) 37 | MockSelector.selectMeta.set(key, this) 38 | result.push(val) 39 | }) 40 | this.datas = result 41 | this.subject.next(this.datas) 42 | } 43 | 44 | changes(): Observable { 45 | return this.mapFn(this.change$) 46 | } 47 | 48 | values() { 49 | return this.mapFn(this.change$.pipe(take(1))) 50 | } 51 | 52 | concat(...metas: MockSelector[]) { 53 | const dist = this.combine(...metas) 54 | dist['__test_label_selector_kind__'] = 'by concat' 55 | return dist 56 | } 57 | 58 | combine(...metas: MockSelector[]) { 59 | metas.unshift(this) 60 | const dist = new MockSelector(new Map()) 61 | dist.values = () => { 62 | return from(metas).pipe( 63 | flatMap((meta) => meta.values()), 64 | reduce((acc: T[], val: T[]) => acc.concat(val)), 65 | ) 66 | } 67 | 68 | dist.changes = () => { 69 | return from(metas).pipe( 70 | map((meta) => meta.changes()), 71 | combineAll(), 72 | map((r: T[][]) => r.reduce((acc, val) => acc.concat(val))), 73 | ) 74 | } 75 | dist['__test_label_selector_kind__'] = 'by combine' 76 | return dist 77 | } 78 | 79 | toString() { 80 | return `MockSelector SQL` 81 | } 82 | 83 | map(fn: any) { 84 | this.mapFn = fn 85 | } 86 | 87 | private notify() { 88 | this.subject.next(this.datas) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /test/schemas/Task.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs' 2 | import { tap } from 'rxjs/operators' 3 | 4 | import { RDBType, Relationship } from '../index' 5 | import { TeambitionTypes, Database, SubtaskSchema } from '../index' 6 | 7 | export interface TaskSchema { 8 | _id: TeambitionTypes.TaskId 9 | _creatorId: string 10 | _executorId: string 11 | content: string 12 | note: string 13 | _sourceId?: string 14 | _projectId: TeambitionTypes.ProjectId 15 | _stageId: TeambitionTypes.StageId 16 | _tasklistId: TeambitionTypes.TasklistId 17 | accomplished: string 18 | project?: { 19 | _id: TeambitionTypes.ProjectId 20 | name: string 21 | isArchived: boolean 22 | posts?: any[] 23 | } 24 | subtasks: SubtaskSchema[] 25 | subtasksCount: number 26 | created: string 27 | involveMembers: string[] 28 | } 29 | 30 | export default (db: Database) => { 31 | db.defineSchema('Task', { 32 | _creatorId: { 33 | type: RDBType.STRING, 34 | }, 35 | _executorId: { 36 | type: RDBType.STRING, 37 | }, 38 | _projectId: { 39 | type: RDBType.STRING, 40 | }, 41 | _id: { 42 | type: RDBType.STRING, 43 | primaryKey: true, 44 | }, 45 | _sourceId: { 46 | type: RDBType.STRING, 47 | }, 48 | _stageId: { 49 | type: RDBType.STRING, 50 | index: true, 51 | }, 52 | _tasklistId: { 53 | type: RDBType.STRING, 54 | }, 55 | accomplished: { 56 | type: RDBType.STRING, 57 | }, 58 | content: { 59 | type: RDBType.STRING, 60 | }, 61 | note: { 62 | type: RDBType.STRING, 63 | }, 64 | project: { 65 | type: Relationship.oneToOne, 66 | virtual: { 67 | name: 'Project', 68 | where: (ref) => { 69 | return { 70 | _projectId: ref._id, 71 | } 72 | }, 73 | }, 74 | }, 75 | subtasks: { 76 | type: Relationship.oneToMany, 77 | virtual: { 78 | name: 'Subtask', 79 | where: (ref) => { 80 | return { 81 | _id: ref._taskId, 82 | } 83 | }, 84 | }, 85 | }, 86 | subtasksCount: { 87 | type: RDBType.NUMBER, 88 | }, 89 | involveMembers: { 90 | type: RDBType.LITERAL_ARRAY, 91 | }, 92 | created: { 93 | type: RDBType.DATE_TIME, 94 | }, 95 | dispose: (rootEntities, scope) => { 96 | const [matcher, disposer] = scope('Subtask') 97 | return matcher({ _taskId: { $in: rootEntities.map((entity: any) => entity._id) } }).pipe( 98 | tap(disposer), 99 | ) as Observable 100 | }, 101 | }) 102 | } 103 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const path = require('path') 3 | const os = require('os') 4 | const ExtractTextPlugin = require('extract-text-webpack-plugin') 5 | const HtmlWebpackPlugin = require('html-webpack-plugin') 6 | 7 | // Webpack Config 8 | module.exports = { 9 | entry: { 10 | 'main': './test/e2e/app.ts', 11 | 'vendor': [ 'lovefield', 'rxjs', 'sinon', 'tman', 'chai', 'sinon-chai', 'tslib' ] 12 | }, 13 | devtool: 'cheap-module-source-map', 14 | cache: true, 15 | output: { 16 | filename: '[name].js', 17 | path: path.join(__dirname, 'dist'), 18 | publicPath: '/' 19 | }, 20 | 21 | resolve: { 22 | modules: [ path.join(__dirname, 'src'), 'node_modules' ], 23 | extensions: ['.ts', '.js'], 24 | alias: { 25 | 'lovefield': path.join(process.cwd(), 'node_modules/lovefield/dist/lovefield.js'), 26 | 'sinon': path.join(process.cwd(), 'node_modules/sinon/pkg/sinon.js'), 27 | 'tman': path.join(process.cwd(), 'node_modules/tman/browser/tman.js'), 28 | 'tman-skin': path.join(process.cwd(), 'node_modules/tman/browser/tman.css') 29 | } 30 | }, 31 | 32 | devServer: { 33 | hot: true, 34 | // enable HMR on the server 35 | 36 | contentBase: path.resolve(__dirname, 'dist'), 37 | // match the output path 38 | 39 | publicPath: '/', 40 | // match the output `publicPath` 41 | 42 | watchOptions: { aggregateTimeout: 300, poll: 1000 }, 43 | }, 44 | 45 | node: { 46 | global: true 47 | }, 48 | 49 | plugins: [ 50 | new webpack.LoaderOptionsPlugin({ 51 | debug: true 52 | }), 53 | new ExtractTextPlugin({ filename: 'style.css' }), 54 | new webpack.HotModuleReplacementPlugin(), 55 | new webpack.NoEmitOnErrorsPlugin(), 56 | new HtmlWebpackPlugin({ 57 | filename: 'index.html', 58 | template: `test/e2e/index.html`, 59 | inject: true 60 | }), 61 | new webpack.DefinePlugin({ 62 | 'process.env': { 63 | NODE_ENV: JSON.stringify('development') 64 | } 65 | }), 66 | ], 67 | 68 | mode: 'development', 69 | 70 | module: { 71 | noParse: [/tman\/browser\/tman\.js/, /sinon\/pkg\/sinon\.js/], 72 | rules: [ 73 | { 74 | test: /\.tsx?$/, 75 | enforce: 'pre', 76 | exclude: /node_modules/, 77 | loader: 'tslint-loader' 78 | }, 79 | { 80 | test: /\.js$/, 81 | enforce: 'pre', 82 | loaders: [ 'source-map-loader' ], 83 | include: /rxjs/ 84 | }, 85 | { 86 | test: /\.ts$/, 87 | use: 'ts-loader', 88 | exclude: /node_modules/ 89 | }, 90 | { test: /\.css$/, loaders: [ 'style-loader', 'css-loader' ] }, 91 | { test: /\.html$/, loaders: ['raw-loader'] }, 92 | ] 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/storage/modules/Mutation.ts: -------------------------------------------------------------------------------- 1 | import * as lf from 'lovefield' 2 | 3 | import { forEach, assertValue, warn } from '../../utils' 4 | import { fieldIdentifier } from '../symbols' 5 | import * as Exception from '../../exception' 6 | 7 | export class Mutation { 8 | private params: Object 9 | private meta: 10 | | { 11 | key: string 12 | val: any 13 | } 14 | | undefined 15 | 16 | constructor(private db: lf.Database, private table: lf.schema.Table, initialParams: Object = {}) { 17 | this.params = { 18 | ...initialParams, 19 | } 20 | } 21 | 22 | static aggregate( 23 | db: lf.Database, 24 | insert: Mutation[], 25 | update: Mutation[], 26 | ): { 27 | contextIds: any[] 28 | queries: lf.query.Insert[] 29 | } { 30 | const keys: any[] = [] 31 | const insertQueries: lf.query.Insert[] = [] 32 | 33 | const map = new Map() 34 | for (let i = 0; i < insert.length; i++) { 35 | const curr = insert[i] 36 | const { table, row } = curr.toRow() 37 | const tableName = table.getName() 38 | const acc = map.get(tableName) 39 | 40 | keys.push(fieldIdentifier(tableName, curr.refId())) 41 | 42 | if (acc) { 43 | acc.push(row) 44 | } else { 45 | map.set(tableName, [row]) 46 | } 47 | } 48 | 49 | if (map.size) { 50 | map.forEach((rows: lf.Row[], name) => { 51 | const target = db.getSchema().table(name) 52 | const query = db 53 | .insertOrReplace() 54 | .into(target) 55 | .values(rows) 56 | insertQueries.push(query) 57 | }) 58 | } 59 | 60 | const updateQueries: lf.query.Update[] = [] 61 | for (let i = 0; i < update.length; i++) { 62 | if (Object.keys(update[i].params).length > 0) { 63 | updateQueries.push(update[i].toUpdater()) 64 | } 65 | } 66 | 67 | return { 68 | contextIds: keys, 69 | queries: insertQueries.concat(updateQueries as any[]), 70 | } 71 | } 72 | 73 | private toUpdater() { 74 | assertValue(this.meta, Exception.PrimaryKeyNotProvided) 75 | const query = this.db.update(this.table) 76 | query.where(this.table[this.meta.key].eq(this.meta.val)) 77 | 78 | forEach(this.params, (val, key) => { 79 | const column = this.table[key] 80 | if (column) { 81 | query.set(column, val) 82 | } else { 83 | warn(`Column: ${key} is not existent on table:${this.table.getName()}`) 84 | } 85 | }) 86 | 87 | return query 88 | } 89 | 90 | private toRow() { 91 | assertValue(this.meta, Exception.PrimaryKeyNotProvided) 92 | return { 93 | table: this.table, 94 | row: this.table.createRow({ 95 | [this.meta.key]: this.meta.val, 96 | ...this.params, 97 | }), 98 | } 99 | } 100 | 101 | patch(patch: Object) { 102 | forEach(patch, (val, key) => { 103 | this.params[key] = val 104 | }) 105 | return this 106 | } 107 | 108 | withId(key: string, val: any) { 109 | this.meta = { key, val } 110 | return this 111 | } 112 | 113 | refId() { 114 | return this.meta ? this.meta.val : null 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /example/rdb/defineSchema.ts: -------------------------------------------------------------------------------- 1 | import { RDBType, SchemaDef, Relationship } from 'reactivedb' 2 | import Database from './Database' 3 | 4 | interface BasicSchema { 5 | _id: string 6 | _demoId: string 7 | content: string 8 | name: string 9 | color: string 10 | avatar: { 11 | url: string 12 | preview: string 13 | } 14 | demo: { 15 | _id: string 16 | name: string 17 | isDone: boolean 18 | } 19 | } 20 | 21 | interface DemoSchema { 22 | _id: string 23 | _otherId: string 24 | basicIds: string[] 25 | basics: BasicSchema[] 26 | other: { 27 | _id: string 28 | basic: { 29 | _id: string 30 | name: string 31 | avatar: { 32 | url: string 33 | preview: string 34 | } 35 | } 36 | } 37 | name: string 38 | isDone: boolean 39 | order: number 40 | } 41 | 42 | interface OtherSchema { 43 | _id: string 44 | _basicId: string 45 | basic: Partial 46 | content: string 47 | fields: string[] 48 | startDate: string 49 | endDate: string 50 | } 51 | 52 | const basicSchema: SchemaDef = { 53 | _id: { 54 | type: RDBType.STRING, 55 | primaryKey: true 56 | }, 57 | _demoId: { 58 | type: RDBType.STRING, 59 | index: true 60 | }, 61 | color: { 62 | type: RDBType.STRING 63 | }, 64 | content: { 65 | type: RDBType.STRING 66 | }, 67 | name: { 68 | type: RDBType.STRING 69 | }, 70 | avatar: { 71 | type: RDBType.OBJECT 72 | }, 73 | demo: { 74 | type: Relationship.oneToOne, 75 | virtual: { 76 | name: 'Demo', 77 | where: demoTable => ({ 78 | _demoId: demoTable._id 79 | }) 80 | } 81 | } 82 | } 83 | 84 | const demoSchema: SchemaDef = { 85 | _id: { 86 | type: RDBType.STRING, 87 | primaryKey: true 88 | }, 89 | _otherId: { 90 | type: RDBType.STRING 91 | }, 92 | basicIds: { 93 | type: RDBType.LITERAL_ARRAY 94 | }, 95 | basics: { 96 | type: Relationship.oneToMany, 97 | virtual: { 98 | name: 'Basic', 99 | where: basicTable => ({ 100 | _id: basicTable._demoId 101 | }) 102 | } 103 | }, 104 | other: { 105 | type: Relationship.oneToOne, 106 | virtual: { 107 | name: 'Other', 108 | where: otherTable => ({ 109 | _otherId: otherTable._id 110 | }) 111 | } 112 | }, 113 | name: { 114 | type: RDBType.STRING 115 | }, 116 | isDone: { 117 | type: RDBType.BOOLEAN 118 | }, 119 | order: { 120 | type: RDBType.NUMBER 121 | } 122 | } 123 | 124 | const otherSchema: SchemaDef = { 125 | _id: { 126 | type: RDBType.STRING, 127 | primaryKey: true 128 | }, 129 | _basicId: { 130 | type: RDBType.STRING 131 | }, 132 | basic: { 133 | type: Relationship.oneToOne, 134 | virtual: { 135 | name: 'Basic', 136 | where: basicTable => ({ 137 | _basicId: basicTable._id 138 | }) 139 | } 140 | }, 141 | content: { 142 | type: RDBType.STRING 143 | }, 144 | fields: { 145 | type: RDBType.ARRAY_BUFFER 146 | }, 147 | startDate: { 148 | type: RDBType.DATE_TIME 149 | }, 150 | endDate: { 151 | type: RDBType.DATE_TIME 152 | } 153 | } 154 | 155 | Database.defineSchema('Basic', basicSchema) 156 | Database.defineSchema('Demo', demoSchema) 157 | Database.defineSchema('Other', otherSchema) 158 | 159 | 160 | Database.connect() 161 | -------------------------------------------------------------------------------- /test/schemas/Activity.ts: -------------------------------------------------------------------------------- 1 | // 'use strict' 2 | // import { Database, RDBType, Association } from '../index' 3 | // import { PostSchema } from './Posts' 4 | // import { TaskSchema } from './Task' 5 | // import { SubtaskSchema } from './Subtask' 6 | 7 | // export interface Locales { 8 | // en: { 9 | // title: string 10 | // } 11 | // zh: { 12 | // title: string 13 | // }, 14 | // ko: { 15 | // title: string 16 | // } 17 | // zh_tw: { 18 | // title: string 19 | // } 20 | // ja: { 21 | // title: string 22 | // } 23 | // } 24 | 25 | // export interface Voice { 26 | // source: string 27 | // fileType: 'amr' 28 | // fileCategory: string 29 | // fileName: string 30 | // thumbnailUrl: string 31 | // previewUrl: string 32 | // mimeType: string 33 | // downloadUrl: string 34 | // fileSize: number 35 | // duration: number 36 | // fileKey: string 37 | // thumbnail: string 38 | // } 39 | 40 | // export interface ActivitySchema { 41 | // _boundToObjectId: string 42 | // _creatorId: string 43 | // _id: string 44 | // action: string 45 | // boundToObjectType: string 46 | // content: { 47 | // comment?: string 48 | // content?: string 49 | // attachments?: File[] 50 | // voice?: Voice 51 | // mentionsArray?: string[] 52 | // mentions?: { 53 | // [index: string]: string 54 | // } 55 | // attachmentsName?: string 56 | // creator?: string 57 | // executor?: string 58 | // note?: string 59 | // subtask?: string 60 | // count?: string 61 | // dueDate?: string 62 | // linked?: { 63 | // _id: string 64 | // _projectId: string 65 | // _objectId: string 66 | // objectType: string 67 | // title: string 68 | // url: string 69 | // } 70 | // linkedCollection?: { 71 | // _id: string 72 | // title: string 73 | // objectType: 'collection' 74 | // } 75 | // uploadWorks?: { 76 | // _id: string 77 | // fileName: string 78 | // objectType: 'work' 79 | // }[] 80 | // collection: { 81 | // _id: string 82 | // title: string 83 | // objectType: 'collection' 84 | // } 85 | // work?: { 86 | // _id: string 87 | // fileName: string 88 | // objectType: 'work' 89 | // } 90 | // } 91 | // created: number 92 | // locales?: Locales 93 | // entity: PostSchema | TaskSchema | SubtaskSchema 94 | // } 95 | 96 | // export default (db: Database) => db.defineSchema('Activity', { 97 | // _boundToObjectId: { 98 | // type: RDBType.STRING 99 | // }, 100 | // _creatorId: { 101 | // type: RDBType.STRING 102 | // }, 103 | // _id: { 104 | // type: RDBType.STRING, 105 | // primaryKey: true 106 | // }, 107 | // action: { 108 | // type: RDBType.STRING 109 | // }, 110 | // boundToObjectType: { 111 | // type: RDBType.STRING 112 | // }, 113 | // content: { 114 | // type: RDBType.OBJECT 115 | // }, 116 | // created: { 117 | // type: RDBType.NUMBER 118 | // }, 119 | // locales: { 120 | // type: RDBType.OBJECT 121 | // }, 122 | // entity: { 123 | // type: Association.oneToOne, 124 | // virtual: { 125 | // name: (entity: ActivitySchema) => entity._boundToObjectId, 126 | // where(activityTable: lf.schema.Table) { 127 | // return { 128 | // _boundToObjectId: activityTable['_id'] 129 | // } 130 | // } 131 | // } 132 | // } 133 | // }) 134 | -------------------------------------------------------------------------------- /docs/API-description/QueryToken.md: -------------------------------------------------------------------------------- 1 | # QueryToken 2 | ## Instance Method 3 | 实例方法 4 | ```ts 5 | const queryToken = database.get(...args) 6 | ``` 7 | 8 | ## QueryToken.prototype.values() 9 | ```ts 10 | queryToken.values(): Observable 11 | ``` 12 | 对已定义的query条件做单次求值操作. (complete immediately stream) 13 | 14 | - ```Method: queryToken.values()``` 15 | 16 | ## QueryToken.prototype.changes() 17 | ```ts 18 | queryToken.changes(): Observable 19 | ``` 20 | 对已定义的query条件做持续求值操作, 每当监听的query匹配的集合数据发生变化, 数据都将从该接口被推送出来. (live stream) 21 | 22 | ## QueryToken.prototype.map(fn: (stream$: Observable) => Observable): QueryToken 23 | ```ts 24 | queryToken.map(stream$ => stream$ 25 | .switchMap(r => request(r._id)) 26 | ) 27 | .changes() 28 | .subscribe() 29 | ``` 30 | 31 | 下面的 combine 接口会保留 map 的行为,比如: 32 | 33 | ```ts 34 | const qt1 = queryToken.map(s$ => s$.map(() => 1)) 35 | const qt2 = queryToken.map(s$ => s$.map(() => 2)) 36 | 37 | qt1.combine(qt2) 38 | .values() 39 | .subscribe(r => { 40 | // r[0] === 1 41 | // r[1] === 2 42 | }) 43 | ``` 44 | 45 | ## QueryToken.prototype.concat(...tokens) 46 | ```ts 47 | queryToken.concat(...tokens: QueryToken[]): QueryToken 48 | ``` 49 | 对已有的单个或多个QueryToken进行合并操作。在 ReactiveDB 内部会合并这些 `QueryToken` 的 `query`,并且停止被 `concat` query 的 `observe`。具体对应的场景是数据分页。 50 | 使用 `concat` 连接的所有 `QueryToken` 需要满足以下要求: 51 | 52 | 1. 它们的 `predicate` 与 `select` 与 `maFn.toString()` 都必须完全相等 53 | 54 | 2. 它们的查询的数据必须连起来是一块连续的区域,比如: 55 | ```ts 56 | // valid 57 | new QueryToken(limit = 20, skip = 0) 58 | .concat( 59 | new QueryToken(limit = 20, skip = 20), 60 | new QueryToken(limit = 20, skip = 40), 61 | new QueryToken(limit = 20, skip = 60) 62 | ) 63 | 64 | // valid 65 | new QueryToken(limit = 10, skip = 0) 66 | .concat( 67 | new QueryToken(limit = 20, skip = 10), 68 | new QueryToken(limit = 10, skip = 30), 69 | new QueryToken(limit = 20, skip = 40) 70 | ) 71 | 72 | // invalid 73 | new QueryToken(limit = 11, skip = 0) 74 | .concat( 75 | new QueryToken(limit = 20, skip = 10) 76 | ... 77 | ) 78 | ``` 79 | 80 | 81 | - ```Method: queryToken.concat(...tokens: QueryToken[]) ``` 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 |
ParameterTypeRequiredDescription
tokenQueryTokenrequiredQueryToken实例
...
100 | 101 | ***使用 concat 可以显著减少资源的消耗,在一个长分页列表中始终保持只有一个 query 被 observe*** 102 | 103 | ## QueryToken.prototype.combine(...tokens) 104 | ```ts 105 | queryToken.combine(...tokens: QueryToken[]): QueryToken 106 | ``` 107 | 对已有的单个或多个 QueryToken 进行合并操作。 108 | 如果调用新的 QueryToken 的 `change` 方法, 这些旧的 QueryToken 并不会被 `unobserve`,而是被 `combineLatest` 到一起,在每一个 `QueryToken#change` 被触发时,被 combine 的结果 QueryToken 都会重新发射一个新的重新计算后的值。 109 | 110 | ***相对于 concat,combine 不会减少资源的消耗,它只是一个 RxJS 多种接口的语法糖*** 111 | 112 | - ```Method: queryToken.combine(...tokens: QueryToken[]) ``` 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 |
ParameterTypeRequiredDescription
tokenQueryTokenrequiredQueryToken实例
...
131 | -------------------------------------------------------------------------------- /src/storage/modules/QueryToken.ts: -------------------------------------------------------------------------------- 1 | import { Observable, OperatorFunction, from } from 'rxjs' 2 | import { 3 | combineAll, 4 | filter, 5 | map, 6 | pairwise, 7 | publishReplay, 8 | refCount, 9 | skipWhile, 10 | switchMap, 11 | startWith, 12 | take, 13 | tap, 14 | } from 'rxjs/operators' 15 | import { Selector } from './Selector' 16 | import { ProxySelector } from './ProxySelector' 17 | import { assert } from '../../utils/assert' 18 | import { TokenConsumed } from '../../exception/token' 19 | import { diff, Ops, OpsType, OpType } from '../../utils/diff' 20 | 21 | export type TraceResult = Ops & { 22 | result: ReadonlyArray 23 | } 24 | 25 | function initialTraceResult(list: ReadonlyArray): TraceResult { 26 | return { 27 | type: OpsType.Success, 28 | ops: list.map((_value, index) => ({ type: OpType.New, index })), 29 | result: list, 30 | } 31 | } 32 | 33 | export type SelectorMeta = Selector | ProxySelector 34 | 35 | const skipWhileProxySelector = skipWhile((v) => v instanceof ProxySelector) as ( 36 | x: Observable>, 37 | ) => Observable> 38 | 39 | export class QueryToken { 40 | selector$: Observable> 41 | 42 | private consumed = false 43 | private lastEmit: ReadonlyArray | undefined 44 | private trace: ReadonlyArray | undefined 45 | 46 | constructor(selector$: Observable>, trace?: ReadonlyArray) { 47 | this.selector$ = selector$.pipe( 48 | publishReplay(1), 49 | refCount(), 50 | ) 51 | this.trace = trace 52 | } 53 | 54 | setTrace(data: T[]) { 55 | this.trace = data 56 | } 57 | 58 | map(fn: OperatorFunction) { 59 | this.selector$ = this.selector$.pipe(tap((selector) => (selector as any).map(fn))) 60 | return (this as any) as QueryToken 61 | } 62 | 63 | values(): Observable { 64 | assert(!this.consumed, TokenConsumed) 65 | 66 | this.consumed = true 67 | return this.selector$.pipe( 68 | switchMap((s) => s.values()), 69 | take(1), 70 | ) 71 | } 72 | 73 | changes(): Observable { 74 | assert(!this.consumed, TokenConsumed) 75 | 76 | this.consumed = true 77 | return this.selector$.pipe(switchMap((s) => s.changes())) 78 | } 79 | 80 | traces(pk?: string): Observable> { 81 | return this.changes().pipe( 82 | startWith>(this.trace), 83 | pairwise(), 84 | map(([prev, curr]) => { 85 | const result = curr! 86 | if (!prev) { 87 | return initialTraceResult(result) 88 | } 89 | const ops = diff(prev, result, pk) 90 | return { result, ...ops } 91 | }), 92 | filter(({ type }) => type !== OpsType.ShouldSkip), 93 | tap(({ result }) => (this.lastEmit = result)), 94 | ) 95 | } 96 | 97 | concat(...tokens: QueryToken[]) { 98 | tokens.unshift(this) 99 | const newSelector$ = from(tokens).pipe( 100 | map((token) => token.selector$.pipe(skipWhileProxySelector)), 101 | combineAll>(), 102 | map((r) => { 103 | const first = r.shift() 104 | return first!.concat(...r) 105 | }), 106 | ) 107 | return new QueryToken(newSelector$, this.lastEmit) 108 | } 109 | 110 | combine(...tokens: QueryToken[]) { 111 | tokens.unshift(this) 112 | const newSelector$ = from(tokens).pipe( 113 | map((token) => token.selector$.pipe(skipWhileProxySelector)), 114 | combineAll>(), 115 | map((r) => { 116 | const first = r.shift() 117 | return first!.combine(...r) 118 | }), 119 | ) 120 | return new QueryToken(newSelector$, this.lastEmit) 121 | } 122 | 123 | toString() { 124 | return this.selector$.pipe(map((r) => r.toString())) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /docs/API-description/QueryDescription.md: -------------------------------------------------------------------------------- 1 | ## QueryDescription 2 | 3 | ```ts 4 | interface QueryDescription extends ClauseDescription { 5 | fields?: FieldsValue[] 6 | limit?: number 7 | skip?: number 8 | orderBy?: OrderDescription[] 9 | } 10 | 11 | interface ClauseDescription { 12 | where?: PredicateDescription 13 | } 14 | 15 | interface OrderDescription { 16 | fieldName: string 17 | orderBy?: 'DESC' | 'ASC' 18 | } 19 | ``` 20 | 21 | ### Description 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 |
字段名描述
fields查询哪些字段,数组,合法值为字符串或字面量对象
limit最多查询多少条记录,整数
skip跳过多少条记录,整数
orderBy排序,数组,合法值是 OrderDescription
where查询条件,一个字面量对象。合法值是 PredicateDescription
48 | 49 | ### *example* 50 | 51 | ```ts 52 | { 53 | fields: ['_id', 'name', 'content', { 54 | project: ['_id', 'name'], 55 | executor: ['_id', 'name', 'avatarUrl'] 56 | }], 57 | limit: 20, 58 | skip: 40, 59 | orderBy: [ 60 | { 61 | fieldName: 'priority', 62 | orderBy: 'ASC' 63 | }, 64 | { 65 | fieldName: 'dueDate', 66 | orderBy: 'DESC' 67 | } 68 | ], 69 | where: { 70 | dueDate: { 71 | $lte: moment().add(7, 'day').startOf('day').valueOf() 72 | }, 73 | startDate: { 74 | $gte: moment().add(1, 'day').endOf('day').valueOf() 75 | }, 76 | involveMembers: { 77 | $has: 'xxxuserId' 78 | } 79 | } 80 | } 81 | ``` 82 | 83 |

OrderDescription

84 | 85 | 86 | ```ts 87 | interface OrderDescription { 88 | fieldName: string 89 | orderBy?: 'DESC' | 'ASC' 90 | } 91 | ``` 92 | 93 | ### Description 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 |
字段名描述
fieldName排序的字段
orderBy排序方法。ASC 升序,DESC 降序
108 | 109 | ### *example*: 110 | 111 | ```ts 112 | { 113 | fieldName: 'priority', 114 | orderBy: 'ASC' 115 | } 116 | ``` 117 | 118 |

PredicateDescription

119 | 120 | ```ts 121 | type ValueLiteral = string | number | boolean 122 | type VaildEqType = ValueLiteral | lf.schema.Column | lf.Binder 123 | 124 | type PredicateDescription = { 125 | [P in keyof T & PredicateMeta]?: Partial> | ValueLiteral | PredicateDescription 126 | } 127 | 128 | interface PredicateMeta { 129 | $ne: ValueLiteral 130 | $eq: ValueLiteral 131 | $and: PredicateDescription 132 | $or: PredicateDescription | PredicateDescription[] 133 | $not: PredicateDescription 134 | $lt: ValueLiteral 135 | $lte: ValueLiteral 136 | $gt: ValueLiteral 137 | $gte: ValueLiteral 138 | $match: RegExp 139 | $notMatch: RegExp 140 | $has: ValueLiteral 141 | $between: [ number, number ] 142 | $in: ValueLiteral[] 143 | $isNull: boolean 144 | $isNotNull: boolean 145 | } 146 | ``` 147 | 148 | ### Description 149 | 字面量对象,它的 key 为 PredicateMeta 的 key 时受到 PredicateMeta 接口的约束。 150 | 比如 151 | ```ts 152 | { 153 | // 只能为正则 154 | $match: RegExp 155 | } 156 | ``` 157 | 当它的 key 为其它值时,它的值可能为新的 `PredicateDescription`, `PredicateMeta`, `ValueLiteral`, 它们可以一层层的递归的定义。 158 | 159 | 第一层定义的 key 默认用 `$and` 连接,比如: 160 | ```ts 161 | { 162 | dueDate: { 163 | $lte: moment().add(7, 'day').endOf('day').valueOf() 164 | }, 165 | startDate: { 166 | $gte: moment().add(1, 'day').startOf('day').valueOf() 167 | } 168 | } 169 | ``` 170 | 默认表示 `lf.op.and(taskTable.dueDate.lte(...), taskTable.startDate.gte(...) )` 171 | 172 | ### *example:* 173 | ```ts 174 | { 175 | $or: [ 176 | { 177 | dueDate: { 178 | $and: [ 179 | { $lte: moment().add(7, 'day').startOf('day').valueOf() }, 180 | { $gte: moment().add(1, 'day').endtOf('day').valueOf() } 181 | ] 182 | }, 183 | startDate: { 184 | $and: [ 185 | { $lte: moment().add(7, 'day').startOf('day').valueOf() }, 186 | { $gte: moment().add(1, 'day').endtOf('day').valueOf() } 187 | ] 188 | }, 189 | } 190 | ], 191 | involveMembers: { 192 | $has: 'xxxuserId' 193 | } 194 | } 195 | ``` -------------------------------------------------------------------------------- /src/shared/Logger.ts: -------------------------------------------------------------------------------- 1 | export enum Level { 2 | debug = 10, 3 | info = 20, 4 | warning = 30, 5 | error = 40, 6 | test = 1000, 7 | } 8 | 9 | export interface LoggerAdapter { 10 | info(...message: string[]): void 11 | warn(...message: string[]): void 12 | error(...message: string[]): void 13 | debug(...message: string[]): void 14 | } 15 | 16 | export type Formatter = (name: string, level: Level, ...message: any[]) => string 17 | 18 | export class ContextLogger { 19 | public destroy = (): void => void 0 20 | private effects: Map = new Map() 21 | 22 | constructor( 23 | private name: string, 24 | private level: Level, 25 | private formatter?: Formatter, 26 | private adapter: LoggerAdapter = console, 27 | ) {} 28 | 29 | private invoke(method: string, message: any[]) { 30 | let output = '' 31 | if (this.formatter) { 32 | const params: [string, Level, ...any[]] = [this.name, this.level, ...message] 33 | output = this.formatter.apply(this, params) 34 | } 35 | this.adapter[method].call(this.adapter, output) 36 | const fns = this.effects.get(method as keyof LoggerAdapter) || [] 37 | fns.forEach((fn) => fn(...message)) 38 | } 39 | 40 | info(...message: any[]) { 41 | if (Level.info >= this.level) { 42 | this.invoke('info', message) 43 | } 44 | } 45 | 46 | warn(...message: any[]) { 47 | if (Level.warning >= this.level) { 48 | this.invoke('warn', message) 49 | } 50 | } 51 | 52 | error(...message: any[]) { 53 | if (Level.error >= this.level) { 54 | this.invoke('error', message) 55 | } 56 | } 57 | 58 | debug(...message: any[]) { 59 | if (Level.debug >= this.level) { 60 | this.invoke('debug', message) 61 | } 62 | } 63 | 64 | setLevel(level: Level) { 65 | this.level = level 66 | } 67 | 68 | replaceAdapter(adapter: LoggerAdapter) { 69 | if (adapter !== this.adapter) { 70 | this.adapter = adapter 71 | } 72 | } 73 | 74 | replaceFormatter(formatter: Formatter) { 75 | if (formatter !== this.formatter) { 76 | this.formatter = formatter 77 | } 78 | } 79 | 80 | effect(method: keyof LoggerAdapter, callback: Function) { 81 | if (this.effects.has(method)) { 82 | const fns = this.effects.get(method)! 83 | if (fns.every((fn) => fn !== callback)) { 84 | fns.push(callback) 85 | } 86 | } else { 87 | this.effects.set(method, [callback]) 88 | } 89 | } 90 | 91 | clearEffects() { 92 | this.effects.clear() 93 | } 94 | } 95 | 96 | export class Logger { 97 | private static contextMap = new Map() 98 | private static defaultLevel = Level.debug 99 | private static outputLogger = new ContextLogger('[ReactiveDB]', Logger.defaultLevel, (name, _, message) => { 100 | const output = Array.isArray(message) ? message.join('') : message 101 | const current = new Date() 102 | const prefix = name ? `[${name}] ` : '' 103 | return `${prefix}at ${current.toLocaleString()}: \r\n ` + output 104 | }) 105 | 106 | static get(name: string, formatter?: Formatter, level?: Level, adapter: LoggerAdapter = console) { 107 | const logger = Logger.contextMap.get(name) 108 | 109 | if (!logger) { 110 | const ctxLogger = new ContextLogger(name, level || Logger.defaultLevel, formatter, adapter) 111 | Logger.contextMap.set(name, ctxLogger) 112 | ctxLogger.destroy = () => { 113 | Logger.contextMap.delete(name) 114 | ctxLogger.clearEffects() 115 | } 116 | return ctxLogger 117 | } 118 | 119 | return logger 120 | } 121 | 122 | static setLevel(level: Level) { 123 | Logger.outputLogger.setLevel(level) 124 | } 125 | 126 | static warn(...message: string[]) { 127 | Logger.outputLogger.warn(...message) 128 | } 129 | 130 | static info(...message: string[]) { 131 | Logger.outputLogger.info(...message) 132 | } 133 | 134 | static debug(...message: string[]) { 135 | Logger.outputLogger.debug(...message) 136 | } 137 | 138 | static error(...message: string[]) { 139 | Logger.outputLogger.error(...message) 140 | } 141 | } 142 | 143 | const envifyLevel = () => { 144 | const env = (process && process.env && process.env.NODE_ENV) || 'production' 145 | 146 | switch (env) { 147 | case 'production': 148 | return Level.error 149 | case 'test': 150 | return Level.test 151 | default: 152 | return Level.debug 153 | } 154 | } 155 | 156 | Logger.setLevel(envifyLevel()) 157 | -------------------------------------------------------------------------------- /test/specs/storage/Database.before.connect.spec.ts: -------------------------------------------------------------------------------- 1 | import * as lf from 'lovefield' 2 | import { describe, it, beforeEach } from 'tman' 3 | import { expect } from 'chai' 4 | import { concatMap } from 'rxjs/operators' 5 | 6 | import { 7 | RDBType, 8 | Database, 9 | Relationship, 10 | DataStoreType, 11 | PrimaryKeyNotProvided, 12 | DatabaseIsNotEmpty, 13 | UnmodifiableTable, 14 | } from '../../index' 15 | 16 | export default describe('Database Method before Connect', () => { 17 | let i = 1 18 | let database: Database 19 | const tablename = 'TestTable' 20 | 21 | beforeEach(() => { 22 | const dbname = `TestDatabase${i++}` 23 | database = new Database(DataStoreType.MEMORY, false, dbname, i) 24 | }) 25 | 26 | describe('Database.prototype.defineSchema', () => { 27 | it("should throw since primaryKey wasn't specified", () => { 28 | const metaData = { 29 | _id: { 30 | type: RDBType.STRING, 31 | }, 32 | } 33 | const define = () => { 34 | database.defineSchema(tablename, metaData) 35 | } 36 | const err = PrimaryKeyNotProvided() 37 | expect(define).to.throw(err.message) 38 | }) 39 | 40 | it('should throw when user try to re-define a table', () => { 41 | const metaData = { 42 | _id: { 43 | type: RDBType.STRING, 44 | primaryKey: true, 45 | }, 46 | } 47 | const define = () => { 48 | database.defineSchema(tablename, metaData) 49 | database.defineSchema(tablename, metaData) 50 | } 51 | const err = UnmodifiableTable() 52 | expect(define).to.throw(err.message) 53 | }) 54 | 55 | it('should store in Database', () => { 56 | const metaData = { 57 | _id: { 58 | type: RDBType.STRING, 59 | primaryKey: true, 60 | }, 61 | name: { 62 | type: RDBType.STRING, 63 | }, 64 | juju: { 65 | type: Relationship.oneToOne, 66 | virtual: { 67 | name: 'JuJu', 68 | where: (data: lf.schema.Table) => ({ 69 | name: data['name'], 70 | }), 71 | }, 72 | }, 73 | } 74 | database.defineSchema(tablename, metaData) 75 | expect(database['schemaDefs'].get(tablename)).to.equal(metaData) 76 | }) 77 | 78 | it('should throw since try to define a table after connect', () => { 79 | const db = new Database(DataStoreType.MEMORY, false) 80 | 81 | const metaData = { 82 | _id: { 83 | type: RDBType.STRING, 84 | primaryKey: true, 85 | }, 86 | } 87 | 88 | db.connect() 89 | 90 | const define = () => { 91 | db.defineSchema(tablename, metaData) 92 | db.defineSchema(tablename, metaData) 93 | } 94 | 95 | const err = UnmodifiableTable() 96 | expect(db).is.not.null 97 | expect(define).to.throw(err.message) 98 | }) 99 | }) 100 | 101 | describe('Database.prototype.load', () => { 102 | let db: Database 103 | let fixedVersion = 1000 104 | let dbname: string = null 105 | 106 | beforeEach(() => { 107 | dbname = `TestDatabase${fixedVersion++}` 108 | db = new Database(DataStoreType.MEMORY, false, dbname, fixedVersion) 109 | }) 110 | 111 | it('should be preload data before connect', (done) => { 112 | const metaData = { 113 | _id: { 114 | type: RDBType.STRING, 115 | primaryKey: true, 116 | }, 117 | } 118 | db.defineSchema('Preload', metaData) 119 | 120 | const tableName = 'Preload' 121 | const fixture = { name: dbname, version: fixedVersion, tables: { [tableName]: [{ _id: 'foo' }, { _id: 'bar' }] } } 122 | 123 | db.load(fixture) 124 | .pipe( 125 | concatMap(() => { 126 | return db.dump() 127 | }), 128 | ) 129 | .subscribe((dump: any) => { 130 | expect(dump.tables[tableName]).have.lengthOf(2) 131 | expect(db['storedIds'].size).to.equal(2) 132 | done() 133 | }) 134 | 135 | db.connect() 136 | }) 137 | 138 | it('should throw once database is connected', () => { 139 | const metaData = { 140 | _id: { 141 | type: RDBType.STRING, 142 | primaryKey: true, 143 | }, 144 | } 145 | db.defineSchema('Preload', metaData) 146 | 147 | db.connect() 148 | const check = () => { 149 | const tableName = 'Preload' 150 | const fixture = { name: dbname, version: 2, tables: { [tableName]: [{ _id: 'foo' }, { _id: 'bar' }] } } 151 | db.load(fixture) 152 | } 153 | 154 | expect(check).to.throw(DatabaseIsNotEmpty().message) 155 | }) 156 | }) 157 | }) 158 | -------------------------------------------------------------------------------- /src/storage/modules/PredicateProvider.ts: -------------------------------------------------------------------------------- 1 | import * as lf from 'lovefield' 2 | import { forEach, warn } from '../../utils' 3 | import { ValueLiteral, VaildEqType, Predicate, PredicateMeta } from '../../interface' 4 | 5 | const predicateFactory = { 6 | $ne(column: lf.schema.Column, value: T): lf.Predicate { 7 | return lf.op.not(column.eq(value)) 8 | }, 9 | 10 | $lt(column: lf.schema.Column, value: T): lf.Predicate { 11 | return column.lt(value) 12 | }, 13 | 14 | $lte(column: lf.schema.Column, value: T): lf.Predicate { 15 | return column.lte(value) 16 | }, 17 | 18 | $gt(column: lf.schema.Column, value: T): lf.Predicate { 19 | return column.gt(value) 20 | }, 21 | 22 | $gte(column: lf.schema.Column, value: T): lf.Predicate { 23 | return column.gte(value) 24 | }, 25 | 26 | $match(column: lf.schema.Column, reg: RegExp): lf.Predicate { 27 | return column.match(reg) 28 | }, 29 | 30 | $notMatch(column: lf.schema.Column, reg: RegExp): lf.Predicate { 31 | return lf.op.not(column.match(reg)) 32 | }, 33 | 34 | $between(column: lf.schema.Column, values: [number, number]): lf.Predicate { 35 | return column.between(values[0], values[1]) 36 | }, 37 | 38 | $has(column: lf.schema.Column, value: string): lf.Predicate { 39 | return column.match(new RegExp(`(${value}\\b)`)) 40 | }, 41 | 42 | $in(column: lf.schema.Column, range: ValueLiteral[]): lf.Predicate { 43 | return column.in(range) 44 | }, 45 | 46 | $isNull(column: lf.schema.Column): lf.Predicate { 47 | return column.isNull() 48 | }, 49 | 50 | $isNotNull(column: lf.schema.Column): lf.Predicate { 51 | return column.isNotNull() 52 | }, 53 | } 54 | 55 | const compoundPredicateFactory = { 56 | $and(predicates: lf.Predicate[]): lf.Predicate { 57 | return lf.op.and(...predicates) 58 | }, 59 | 60 | $or(predicates: lf.Predicate[]): lf.Predicate { 61 | return lf.op.or(...predicates) 62 | }, 63 | 64 | $not(predicates: lf.Predicate[]): lf.Predicate { 65 | return lf.op.not(predicates[0]) 66 | }, 67 | } 68 | 69 | export class PredicateProvider { 70 | constructor(private table: lf.schema.Table, private meta?: Predicate) {} 71 | 72 | getPredicate(): lf.Predicate | null { 73 | const predicates = this.meta ? this.normalizeMeta(this.meta) : [] 74 | if (predicates.length) { 75 | if (predicates.length === 1) { 76 | return predicates[0] 77 | } else { 78 | return lf.op.and(...predicates) 79 | } 80 | } else { 81 | return null 82 | } 83 | } 84 | 85 | toString(): string | void { 86 | const pred = this.getPredicate() 87 | return pred ? JSON.stringify(this.meta) : '' 88 | } 89 | 90 | private normalizeMeta(meta: Predicate, column?: lf.schema.Column): lf.Predicate[] { 91 | const buildSinglePred = (col: lf.schema.Column, val: any, key: string): lf.Predicate => 92 | this.checkMethod(key) ? predicateFactory[key](col, val) : col.eq(val as ValueLiteral) 93 | 94 | const predicates: lf.Predicate[] = [] 95 | 96 | forEach(meta, (val: Partial> | ValueLiteral, key) => { 97 | let nestedPreds: lf.Predicate[] 98 | let resultPred: lf.Predicate 99 | 100 | if (this.checkCompound(key)) { 101 | nestedPreds = this.normalizeMeta(val as Predicate, column) 102 | resultPred = compoundPredicateFactory[key](nestedPreds) 103 | } else if (this.checkPredicate(val)) { 104 | nestedPreds = this.normalizeMeta(val as any, this.table[key]) 105 | resultPred = compoundPredicateFactory['$and'](nestedPreds) 106 | } else { 107 | const _column = column || this.table[key] 108 | if (_column) { 109 | resultPred = buildSinglePred(_column, val, key) 110 | } else { 111 | warn(`Failed to build predicate, since column: ${key} is not exist, on table: ${this.table.getName()}`) 112 | return 113 | } 114 | } 115 | 116 | predicates.push(resultPred) 117 | }) 118 | 119 | return predicates 120 | } 121 | 122 | private checkMethod(methodName: string) { 123 | return typeof predicateFactory[methodName] === 'function' 124 | } 125 | 126 | private checkCompound(methodName: string) { 127 | return typeof compoundPredicateFactory[methodName] === 'function' 128 | } 129 | 130 | private checkPredicate(val: Partial> | ValueLiteral): boolean { 131 | return ( 132 | !!val && 133 | typeof val === 'object' && 134 | !(val instanceof Array) && 135 | !(val instanceof RegExp) && 136 | !(val instanceof (lf.schema as any).BaseColumn) 137 | ) 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/utils/diff.ts: -------------------------------------------------------------------------------- 1 | export enum OpType { 2 | // 0 = reuse 3 | // 1 = use new item 4 | Reuse, 5 | New, 6 | } 7 | 8 | export type Op = { 9 | type: OpType 10 | index: number 11 | } 12 | 13 | export enum OpsType { 14 | // 0 = error 15 | // 1 = success 16 | // 2 = success but should skip 17 | Error, 18 | Success, 19 | ShouldSkip, 20 | } 21 | 22 | export type Ops = { 23 | type: OpsType 24 | ops: Op[] 25 | message?: string 26 | } 27 | 28 | // as an example, use diff to patch data 29 | export const patch = (ops: ReadonlyArray, oldList: ReadonlyArray, newList: ReadonlyArray) => { 30 | if (!oldList.length) { 31 | return newList 32 | } 33 | 34 | return newList.map((data, i) => { 35 | const op = ops[i] 36 | 37 | if (op.type === OpType.Reuse) { 38 | return oldList[op.index] 39 | } 40 | 41 | return data 42 | }) 43 | } 44 | 45 | export const getPatchResult = (oldList: ReadonlyArray, newList: ReadonlyArray, ops: Ops): ReadonlyArray => { 46 | switch (ops.type) { 47 | case OpsType.Error: 48 | return newList 49 | case OpsType.ShouldSkip: 50 | return oldList 51 | case OpsType.Success: 52 | default: 53 | return patch(ops.ops, oldList, newList) 54 | } 55 | } 56 | 57 | function fastEqual(left: object, right: object) { 58 | if (left === right) { 59 | return true 60 | } 61 | 62 | if (left && right && typeof left == 'object' && typeof right == 'object') { 63 | const isLeftArray = Array.isArray(left) 64 | const isRightArray = Array.isArray(right) 65 | 66 | if (isLeftArray && isRightArray) { 67 | const length = (left as any[]).length 68 | 69 | if (length != (right as any[]).length) { 70 | return false 71 | } 72 | 73 | for (let i = length; i-- !== 0; ) { 74 | if (!fastEqual(left[i], right[i])) { 75 | return false 76 | } 77 | } 78 | 79 | return true 80 | } 81 | 82 | if (isLeftArray !== isRightArray) { 83 | return false 84 | } 85 | 86 | const isLeftDate = left instanceof Date 87 | const isRightDate = right instanceof Date 88 | 89 | if (isLeftDate != isRightDate) { 90 | return false 91 | } 92 | 93 | if (isLeftDate && isRightDate) { 94 | return (left as Date).getTime() == (right as Date).getTime() 95 | } 96 | 97 | const keys = Object.keys(left) 98 | const LeftLen = keys.length 99 | 100 | if (LeftLen !== Object.keys(right).length) { 101 | return false 102 | } 103 | 104 | for (let k = LeftLen; k-- !== 0; ) { 105 | if (!right.hasOwnProperty(keys[k])) { 106 | return false 107 | } 108 | } 109 | 110 | for (let j = LeftLen; j-- !== 0; ) { 111 | const key = keys[j] 112 | if (!fastEqual(left[key], right[key])) { 113 | return false 114 | } 115 | } 116 | 117 | return true 118 | } 119 | 120 | return left !== left && right !== right 121 | } 122 | 123 | export function diff(oldList: ReadonlyArray, newList: ReadonlyArray, pk = '_id'): Ops { 124 | const prev = oldList 125 | const curr = newList 126 | 127 | if (!Array.isArray(prev) || !Array.isArray(curr)) { 128 | return { 129 | type: OpsType.Error, 130 | ops: [], 131 | message: `cannot compare non-list object`, 132 | } 133 | } 134 | 135 | const index = {} 136 | for (let i = 0; i < prev.length; i++) { 137 | const value = prev[i][pk] 138 | if (value === undefined) { 139 | return { 140 | type: OpsType.Error, 141 | ops: [], 142 | message: `cannot find pk: ${pk} at prev.${i}`, 143 | } 144 | } 145 | index[value] = i 146 | } 147 | 148 | const ret: Op[] = [] 149 | let reused = 0 150 | 151 | for (let k = 0; k < curr.length; k++) { 152 | const key = curr[k][pk] 153 | if (key === undefined) { 154 | return { 155 | type: OpsType.Error, 156 | ops: [], 157 | message: `cannot find pk: ${pk} at curr.${k}`, 158 | } 159 | } 160 | 161 | const prevIndex = index[key] 162 | 163 | if (prevIndex !== undefined) { 164 | const isEqual = fastEqual((curr as any)[k], (prev as any)[prevIndex]) 165 | // if equal then reuse the previous data otherwise use the new data 166 | const op: Op = isEqual ? { type: OpType.Reuse, index: prevIndex } : { type: OpType.New, index: k } 167 | 168 | if (prevIndex === k && isEqual) { 169 | reused++ 170 | } 171 | ret.push(op) 172 | } else { 173 | ret.push({ type: OpType.New, index: k }) 174 | } 175 | } 176 | 177 | const arrayIsSame = reused === curr.length && prev.length === curr.length 178 | return { 179 | type: arrayIsSame ? OpsType.ShouldSkip : OpsType.Success, 180 | ops: ret, 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/interface/index.ts: -------------------------------------------------------------------------------- 1 | import { Observable, PartialObserver } from 'rxjs' 2 | import { RDBType, Relationship, LeafType, StatementType, JoinMode, DataStoreType } from './enum' 3 | 4 | export type DeepPartial = { [K in keyof T]?: Partial } 5 | 6 | export interface SchemaMetadata { 7 | type: RDBType | Relationship 8 | primaryKey?: boolean 9 | index?: boolean 10 | unique?: boolean 11 | /** 12 | * ref to other table 13 | * 这里需要定义表名,字段和查询条件 14 | */ 15 | virtual?: { 16 | name: string 17 | where(ref: TableShape): Predicate 18 | } 19 | } 20 | 21 | export type TableShape = lf.schema.Table & { [P in keyof T]: lf.schema.Column } 22 | 23 | export type SchemaDef = { [P in keyof T]: SchemaMetadata } & { 24 | dispose?: SchemaDisposeFunction 25 | ['@@dispose']?: SchemaDisposeFunction 26 | } 27 | 28 | export interface Association { 29 | name: string 30 | type?: Relationship 31 | where(targetTable: lf.schema.Table): lf.Predicate 32 | } 33 | 34 | export interface ColumnDef { 35 | column: string 36 | id: boolean 37 | type?: string 38 | } 39 | 40 | export interface ParsedSchema { 41 | associations: Map 42 | mapper: Map 43 | columns: Map 44 | dispose?: SchemaDisposeFunction 45 | pk: string 46 | } 47 | 48 | export type Field = string | { [index: string]: Field[] } 49 | 50 | export interface Clause { 51 | where?: Predicate 52 | } 53 | 54 | export interface OrderDescription { 55 | fieldName: string 56 | orderBy?: 'DESC' | 'ASC' 57 | } 58 | 59 | export interface Query extends Clause { 60 | fields?: Field[] 61 | limit?: number 62 | skip?: number 63 | orderBy?: OrderDescription[] 64 | } 65 | 66 | export interface JoinInfo { 67 | table: lf.schema.Table 68 | predicate: lf.Predicate 69 | } 70 | 71 | export interface Record { 72 | [property: string]: number 73 | } 74 | 75 | export interface ExecutorResult { 76 | result: boolean 77 | insert: number 78 | delete: number 79 | update: number 80 | } 81 | 82 | export interface TraverseContext { 83 | skip: () => void 84 | type: () => string 85 | isLeaf: boolean 86 | path: string[] 87 | parent: any 88 | children: any[] 89 | key: string 90 | node: any 91 | isRoot: boolean 92 | index: number 93 | } 94 | 95 | export interface UpsertContext { 96 | mapper: Function | null 97 | isNavigatorLeaf: boolean 98 | visited: boolean 99 | } 100 | 101 | export interface SelectContext { 102 | type: LeafType 103 | leaf: ColumnLeaf | NavigatorLeaf | null 104 | } 105 | 106 | export interface ColumnLeaf { 107 | column: lf.schema.Column 108 | identifier: string 109 | } 110 | 111 | export interface NavigatorLeaf { 112 | fields: Array | Set 113 | containKey: boolean 114 | assocaiation: Association 115 | } 116 | 117 | export type ScopedHandler = [(where?: Predicate) => Observable, (ret: any[]) => void] 118 | 119 | export type SchemaDisposeFunction = ( 120 | entities: Partial[], 121 | scopedHandler: (name: string) => ScopedHandler, 122 | ) => Observable> 123 | 124 | export interface ShapeMatcher { 125 | mainTable: lf.schema.Table 126 | pk: { 127 | name: string 128 | queried: boolean 129 | } 130 | definition: Object 131 | } 132 | 133 | export interface OrderInfo { 134 | column: lf.schema.Column 135 | orderBy: lf.Order | null 136 | } 137 | 138 | export interface LfFactoryInit { 139 | storeType: DataStoreType 140 | enableInspector: boolean 141 | } 142 | 143 | export type ValueLiteral = string | number | boolean 144 | export type VaildEqType = ValueLiteral | lf.schema.Column | lf.Binder 145 | 146 | export interface PredicateMeta { 147 | $ne: ValueLiteral 148 | $eq: ValueLiteral 149 | $and: Predicate 150 | $or: Predicate | Predicate[] 151 | $not: Predicate 152 | $lt: ValueLiteral 153 | $lte: ValueLiteral 154 | $gt: ValueLiteral 155 | $gte: ValueLiteral 156 | $match: RegExp 157 | $notMatch: RegExp 158 | $has: ValueLiteral 159 | $between: [number, number] 160 | $in: ValueLiteral[] 161 | $isNull: boolean 162 | $isNotNull: boolean 163 | } 164 | 165 | export type Predicate = { 166 | [P in keyof T & PredicateMeta]?: Partial> | ValueLiteral | Predicate 167 | } 168 | 169 | export { StatementType, JoinMode, LeafType, Relationship, DataStoreType, RDBType } 170 | 171 | export type TransactionDescriptor = { [P in keyof T]: PropertyDescriptor } 172 | 173 | export type TransactionHandler = { 174 | commit: () => Observable 175 | abort: () => void 176 | } 177 | 178 | export type Transaction = [T, TransactionHandler] 179 | 180 | export type TransactionEffects = 181 | | PartialObserver 182 | | { next: (x: T) => void; error?: (e: any) => void; complete?: () => void } 183 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reactivedb", 3 | "version": "0.11.0", 4 | "description": "Reactive ORM for Lovefield", 5 | "main": "dist/cjs/index.js", 6 | "scripts": { 7 | "build_all": "npm-run-all build_cjs build_module_es build_test", 8 | "build_cjs": "npm-run-all clean_dist_cjs copy_src_cjs compile_cjs", 9 | "build_module_es": "npm-run-all clean_dist_es copy_src_es compile_module_es", 10 | "build_test": "rm -rf spec-js && tsc -p test/tsconfig.json", 11 | "clean_dist_cjs": "rm -rf ./dist/cjs", 12 | "clean_dist_es": "rm -rf ./dist/es", 13 | "check_circular_dependencies": "madge ./dist/cjs --circular", 14 | "compile_cjs": " tsc dist/cjs/src/index.ts dist/cjs/src/proxy/index.ts -m commonjs --outDir dist/cjs --sourcemap --target ES5 -d --diagnostics --pretty --strict --skipLibCheck --noImplicitReturns --noUnusedLocals --noUnusedParameters --strict --suppressImplicitAnyIndexErrors --moduleResolution node --noEmitHelpers --importHelpers --lib es5,es2015,es2016,es2017", 15 | "compile_module_es": "tsc dist/es/src/index.ts dist/es/src/proxy/index.ts -m ES2015 --outDir dist/es --sourcemap --target ES5 -d --diagnostics --pretty --strict --skipLibCheck --noImplicitReturns --noUnusedLocals --noUnusedParameters --strict --suppressImplicitAnyIndexErrors --moduleResolution node --noEmitHelpers --importHelpers --lib es5,es2015,es2016,es2017", 16 | "copy_src_cjs": "shx mkdir -p ./dist/cjs/src && shx cp -r ./src/* ./dist/cjs/src", 17 | "copy_src_es": "shx mkdir -p ./dist/es/src && shx cp -r ./src/* ./dist/es/src", 18 | "cover": "rm -rf ./.nyc_output ./coverage && cross-env NODE_ENV=test nyc --reporter=html --reporter=lcov --exclude=node_modules --exclude=spec-js/test --exclude=spec-js/src/storage/lovefield.js --exclude=spec-js/src/shared/Logger.js --exclude=spec-js/src/utils/option.js --exclude=spec-js/src/utils/valid.js --exclude=spec-js/src/addons/aggresive-optimizer.js tman --mocha spec-js/test/run.js && nyc report", 19 | "lint": "tslint -c tslint.json src/*.ts --project ./tsconfig.json \"src/**/*.ts\" \"./test/**/*.ts\" -e \"./test/e2e/*.ts\"", 20 | "publish_all": "ts-node ./tools/publish.ts", 21 | "start": "webpack-dev-server --inline --colors --progress --port 3000", 22 | "start-demo": "webpack-dev-server --config ./example/webpack.config.js --inline --colors --progress --port 3001 --open", 23 | "test": "npm run lint && NODE_ENV=test tman --mocha spec-js/test/run.js", 24 | "test_O1": "npm run lint && NODE_ENV=test optimize=true tman --mocha spec-js/test/run.js", 25 | "version": "ts-node tools/version.ts && git add .", 26 | "watch": "cross-env NODE_ENV=test ts-node ./tools/watch.ts & npm run watch_test", 27 | "watch_cjs": "tsc src/index.ts -m commonjs --outDir dist --sourcemap --target ES5 -d --diagnostics --pretty --strict --noImplicitReturns --suppressImplicitAnyIndexErrors --moduleResolution node --noEmitHelpers --lib es5,es2015,es2016,es2017 -w", 28 | "watch_test": "tsc -p test/tsconfig.json -w --diagnostics --pretty" 29 | }, 30 | "keywords": [ 31 | "lovefield", 32 | "RxJS", 33 | "TypeScript", 34 | "reactivedb", 35 | "orm", 36 | "orm-library", 37 | "relational-database" 38 | ], 39 | "author": "LongYinan ", 40 | "maintainers": [ 41 | { 42 | "name": "LongYinan", 43 | "email": "lynweklm@gmail.com" 44 | }, 45 | { 46 | "name": "Saviio", 47 | "email": "sirius0x9@gmail.com" 48 | }, 49 | { 50 | "name": "chuan6", 51 | "email": "chuan6.dev@gmail.com" 52 | }, 53 | { 54 | "name": "Miloas", 55 | "email": "genesis.null@gmail.com" 56 | } 57 | ], 58 | "bugs": { 59 | "url": "https://github.com/ReactiveDB/core/issues" 60 | }, 61 | "license": "MIT", 62 | "devDependencies": { 63 | "@types/chai": "^4.2.21", 64 | "@types/chai-string": "^1.4.2", 65 | "@types/node": "^16.4.13", 66 | "@types/shelljs": "^0.8.9", 67 | "@types/sinon": "^17.0.0", 68 | "@types/sinon-chai": "^3.2.5", 69 | "chai": "^4.3.4", 70 | "chai-string": "^1.5.0", 71 | "codecov": "^3.8.3", 72 | "cross-env": "^7.0.3", 73 | "css-loader": "^6.2.0", 74 | "extract-text-webpack-plugin": "^4.0.0-beta.0", 75 | "html-webpack-plugin": "^5.3.2", 76 | "husky": "^8.0.0", 77 | "lint-staged": "^11.1.2", 78 | "madge": "^6.0.0", 79 | "moment": "^2.29.1", 80 | "node-watch": "^0.7.1", 81 | "npm-run-all2": "^5.0.0", 82 | "nyc": "^15.1.0", 83 | "prettier": "^3.0.0", 84 | "raw-loader": "^4.0.2", 85 | "rxjs": "^7.3.0", 86 | "shelljs": "^0.8.4", 87 | "shx": "^0.3.3", 88 | "sinon": "^17.0.0", 89 | "sinon-chai": "^3.7.0", 90 | "source-map-loader": "^3.0.0", 91 | "style-loader": "^3.2.1", 92 | "tman": "^1.10.0", 93 | "ts-loader": "^9.2.5", 94 | "ts-node": "^10.2.0", 95 | "tslint": "^6.1.3", 96 | "tslint-config-prettier": "^1.18.0", 97 | "tslint-eslint-rules": "^5.4.0", 98 | "tslint-loader": "^3.6.0", 99 | "typescript": "^4.3.5", 100 | "webpack": "^5.50.0", 101 | "webpack-cli": "^4.7.2", 102 | "webpack-dev-server": "^4.0.0" 103 | }, 104 | "dependencies": { 105 | "@types/lovefield": "^2.1.4", 106 | "lovefield": "2.1.12", 107 | "nesthydrationjs": "^2.0.0" 108 | }, 109 | "peerDependencies": { 110 | "rxjs": "^7.3.0", 111 | "tslib": "^2.3.0" 112 | }, 113 | "typings": "./index.d.ts", 114 | "prettier": { 115 | "printWidth": 120, 116 | "semi": false, 117 | "trailingComma": "all", 118 | "singleQuote": true, 119 | "arrowParens": "always", 120 | "parser": "typescript" 121 | }, 122 | "lint-staged": { 123 | "*.ts": [ 124 | "prettier --write", 125 | "tslint -c tslint.json -p tsconfig.json --fix", 126 | "git add" 127 | ] 128 | }, 129 | "husky": { 130 | "hooks": { 131 | "pre-commit": "lint-staged" 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /test/teambition.ts: -------------------------------------------------------------------------------- 1 | export namespace TeambitionTypes { 2 | export type visibility = 'project' | 'organization' | 'all' | 'members' 3 | export type Priority = 0 | 1 | 2 4 | 5 | export interface LikeSchema { 6 | isLike: boolean 7 | likesCount: number 8 | likesGroup: ExecutorOrCreator[] 9 | } 10 | 11 | export interface TburlSchema { 12 | statusCode: number 13 | isExist: boolean 14 | code: string 15 | origin?: string 16 | } 17 | 18 | export interface ProjectInviteSchema { 19 | projectId: ProjectId 20 | invitorId: string 21 | signCode: string 22 | } 23 | 24 | export interface ExecutorOrCreator { 25 | name: string 26 | avatarUrl: string 27 | _id: IdOfMember 28 | } 29 | 30 | export interface InviteLinkSchema { 31 | inviteLink: string 32 | mobileInviteLink: string 33 | signCode: string 34 | created: string 35 | expiration: string 36 | } 37 | 38 | export interface CreatedInProjectSchema { 39 | work: number 40 | post: number 41 | event: number 42 | task: number 43 | } 44 | 45 | export interface RecommendMemberSchema { 46 | _id: IdOfMember 47 | email: string 48 | avatarUrl: string 49 | name: string 50 | latestActived: string 51 | isActive: boolean 52 | website: string 53 | title: string 54 | location: string 55 | } 56 | 57 | export interface ProjectStatisticSchema { 58 | task: { 59 | total: number 60 | done: number 61 | today: number 62 | } 63 | recent: number[] 64 | days: number[][] 65 | } 66 | 67 | export interface ReportSummarySchema { 68 | accomplishedDelayTasksCount: number 69 | accomplishedOntimeTasksCount: number 70 | accomplishedWeekSubTaskCount: number 71 | accomplishedWeekTaskCount: number 72 | inprogressDelayTasksCount: number 73 | inprogressOntimeTasksCount: number 74 | inprogressSubTasksCount: number 75 | notStartedTasksCount: number 76 | totalTasksCount: number 77 | unassignedTasksCount: number 78 | accomplishedTasksCount: number 79 | accomplishedWeekTasksCount: number 80 | accomplishedOntimeWeekTasksCount: number 81 | accomplishedWeekSubTasksCount: number 82 | accomplishedOntimeWeekSubTasksCount: number 83 | accomplishedSubTasksCount: number 84 | accomplishedOntimeSubTasksCount: number 85 | } 86 | 87 | export interface ReportAnalysisSchema { 88 | values: { 89 | unfinishedTaskCount: number 90 | // 2016-08-22 这种格式 91 | date: string 92 | }[] 93 | } 94 | 95 | export interface FavoriteResponse { 96 | _id: string 97 | _creatorId: IdOfMember 98 | _refId: DetailObjectId 99 | refType: string 100 | isFavorite: boolean 101 | isUpdated: boolean 102 | isVisible: boolean 103 | data: any 104 | created: string 105 | updated: string 106 | } 107 | 108 | export interface UndoFavoriteResponse { 109 | _refId: DetailObjectId 110 | refType: DetailObjectType 111 | isFavorite: boolean 112 | } 113 | 114 | export type PostSource = 'shimo' | 'yiqixie' | 'teambition' 115 | export type DetailObjectType = 'task' | 'event' | 'post' | 'work' | 'entry' 116 | export type DetailObjectTypes = 'posts' | 'works' | 'events' | 'tasks' | 'entries' 117 | 118 | export interface ActivityId extends String { 119 | kind?: 'ActivityId' 120 | } 121 | 122 | export interface ApplicationId extends String { 123 | kind?: 'ApplicationId' 124 | } 125 | 126 | export interface CollectionId extends String { 127 | kind?: 'CollectionId' 128 | } 129 | 130 | export interface EntryId extends String { 131 | kind?: 'EntryId' 132 | } 133 | 134 | export interface EntryCategoryId extends String { 135 | kind?: 'EntryCategoryId' 136 | } 137 | 138 | export interface EventId extends String { 139 | kind?: 'EventId' 140 | } 141 | 142 | export interface FeedbackId extends String { 143 | kind?: 'FeedbackId' 144 | } 145 | 146 | export interface FileId extends String { 147 | kind?: 'FileId' 148 | } 149 | 150 | export interface HomeActivityId extends String { 151 | kind?: 'HomeActivityId' 152 | } 153 | 154 | export interface IdOfMember extends String { 155 | kind?: 'IdOfMember' 156 | } 157 | 158 | export interface MemberId extends String { 159 | kind?: 'MemberId' 160 | } 161 | 162 | export interface MessageId extends String { 163 | kind?: 'MessageId' 164 | } 165 | 166 | export interface ObjectLinkId extends String { 167 | kind?: 'ObjectLinkId' 168 | } 169 | 170 | export interface OrganizationId extends String { 171 | kind?: 'OrganizationId' 172 | } 173 | 174 | export interface PreferenceId extends String { 175 | kind?: 'PreferenceId' 176 | } 177 | 178 | export interface PostId extends String { 179 | kind?: 'PostId' 180 | } 181 | 182 | export interface ProjectId extends String { 183 | kind?: 'ProjectId' 184 | } 185 | 186 | export type DefaultRoleId = -1 | 0 | 1 | 2 187 | 188 | export interface CustomRoleId extends String { 189 | kind?: 'CustomRoleId' 190 | } 191 | 192 | export type RoleId = DefaultRoleId | CustomRoleId 193 | 194 | export interface StageId extends String { 195 | kind?: 'StageId' 196 | } 197 | 198 | export interface SubscribeId extends String { 199 | kind?: 'SubscribeId' 200 | } 201 | 202 | export interface SubtaskId extends String { 203 | kind?: 'SubtaskId' 204 | } 205 | 206 | export interface TagId extends String { 207 | kind?: 'TagId' 208 | } 209 | 210 | export interface TaskId extends String { 211 | kind?: 'TaskId' 212 | } 213 | 214 | export interface TasklistId extends String { 215 | kind?: 'TasklistId' 216 | } 217 | 218 | export interface UserId extends String { 219 | kind?: 'UserId' 220 | } 221 | 222 | export type DetailObjectId = TaskId | PostId | EventId | FileId 223 | 224 | export type DefaultColors = 'gray' | 'red' | 'yellow' | 'green' | 'blue' | 'purple' 225 | } 226 | -------------------------------------------------------------------------------- /test/specs/storage/modules/Mutation.spec.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, it, describe, after } from 'tman' 2 | import { expect, use } from 'chai' 3 | import * as sinon from 'sinon' 4 | import * as SinonChai from 'sinon-chai' 5 | import { Mutation } from '../../../../src/storage/modules' 6 | import { PrimaryKeyNotProvided } from '../../../index' 7 | import { fieldIdentifier } from '../../../../src/storage/symbols' 8 | import { MockDatabase, MockDatabaseTable, MockUpdate, MockInsert } from '../../../utils/mocks' 9 | 10 | use(SinonChai) 11 | 12 | export default describe('Mutation Testcase: ', () => { 13 | describe('Class: Mutation', () => { 14 | let fixture: any[] 15 | let table: any 16 | let database: any 17 | const mockTableName = 'MockTable' 18 | 19 | beforeEach(() => { 20 | fixture = [ 21 | { 22 | _id: '577b996b841059b6b09f7370', 23 | content: 'foo', 24 | }, 25 | { 26 | _id: '577b996b841059b6b09f7371', 27 | content: 'bar', 28 | }, 29 | { 30 | _id: '577b996b841059b6b09f7372', 31 | content: 'baz', 32 | }, 33 | ] 34 | 35 | database = new MockDatabase() 36 | table = new MockDatabaseTable(mockTableName) 37 | }) 38 | 39 | it('should can be instantiated successfully', () => { 40 | const instance = new Mutation(database, table, fixture[0]) 41 | expect(instance).is.instanceof(Mutation) 42 | }) 43 | 44 | describe('Method: withId', () => { 45 | const originFn = Mutation.aggregate 46 | after(() => { 47 | Mutation.aggregate = originFn 48 | }) 49 | 50 | it('should be able to mount id', () => { 51 | const mut = new Mutation(database, table, { foo: 666, bar: 233 }) 52 | mut.withId('id', 42) 53 | 54 | const stub = sinon.stub(Mutation, 'aggregate') 55 | 56 | Mutation.aggregate(database, [mut], []) 57 | 58 | const [m] = stub.args[0][1] 59 | const meta = { key: 'id', val: 42 } 60 | expect(m['meta']).to.deep.equal(meta) 61 | }) 62 | }) 63 | 64 | describe('Method: refId', () => { 65 | it('should be able to return specified Id.', () => { 66 | fixture.forEach((f, index) => { 67 | const mut = new Mutation(database, table, f) 68 | mut.withId('id', index) 69 | 70 | expect(mut.refId()).to.equal(index) 71 | }) 72 | }) 73 | }) 74 | 75 | describe('Method: patch', () => { 76 | it('should be able to patch the additional compound data.', () => { 77 | const key = 'content' 78 | const muts = fixture.map((f, index) => { 79 | const mut = new Mutation(database, table, f) 80 | mut.withId('i', index).patch({ 81 | [key]: f[key] + index, 82 | }) 83 | return mut 84 | }) 85 | 86 | const { queries } = Mutation.aggregate(database, [], muts) 87 | 88 | queries.forEach((q, i) => { 89 | expect((q as any).params.content).is.ok 90 | expect((q as any).params.content).is.equal(`${fixture[i][key]}${i}`) 91 | }) 92 | }) 93 | }) 94 | 95 | describe('Static Method: aggregate', () => { 96 | it('should be able to transform mutations to queries which will be executed as update statement', () => { 97 | const muts = fixture.map((item, index) => { 98 | const mut = new Mutation(database, table, item) 99 | mut.withId('id', index) 100 | return mut 101 | }) 102 | 103 | const { contextIds, queries } = Mutation.aggregate(database, [], muts) 104 | 105 | queries.forEach((q) => expect(q).is.instanceOf(MockUpdate)) 106 | 107 | expect(contextIds).have.lengthOf(0) 108 | expect(queries).have.lengthOf(3) 109 | }) 110 | 111 | it('should be able to transform mutations to queries which will be executed as insert statement', () => { 112 | const muts = fixture.map((item, index) => { 113 | return new Mutation(database, table, item).withId('id', index) 114 | }) 115 | 116 | const { contextIds, queries } = Mutation.aggregate(database, muts, []) 117 | 118 | queries.forEach((q) => expect(q).is.instanceOf(MockInsert)) 119 | contextIds 120 | .sort((x, y) => x - y) 121 | .forEach((k, i) => expect(k).is.equal(fieldIdentifier(mockTableName, i.toString()))) 122 | 123 | expect(contextIds).have.lengthOf(3) 124 | expect(queries).have.lengthOf(1) 125 | }) 126 | 127 | it('should be able to aggregate insert mutation which is reference to the same table.', () => { 128 | const mutList = fixture.map((f, index) => { 129 | const name = index % 2 === 0 ? `Even` : 'Odd' 130 | const mut = new Mutation(database, new MockDatabaseTable(name) as any, f) 131 | mut.withId('id', index) 132 | return mut 133 | }) 134 | 135 | const { contextIds, queries } = Mutation.aggregate(database, mutList, []) 136 | 137 | expect(queries).have.lengthOf(Math.ceil(fixture.length / 2)) 138 | expect(contextIds).have.lengthOf(3) 139 | }) 140 | 141 | it('should throw when try to aggregate an unspecified mutation.', () => { 142 | const standardErr = PrimaryKeyNotProvided() 143 | 144 | const mut1 = [new Mutation(database, table, fixture[0])] 145 | const mut2 = [new Mutation(database, table, fixture[1])] 146 | 147 | const check1 = () => Mutation.aggregate(database, mut1, []) 148 | const check2 = () => Mutation.aggregate(database, [], mut2) 149 | 150 | expect(check1).throw(standardErr.message) 151 | expect(check2).throw(standardErr.message) 152 | }) 153 | 154 | it('should skip the `toUpdater` once params is empty', () => { 155 | const mut = new Mutation(database, table) 156 | const { queries } = Mutation.aggregate(database, [], [mut]) 157 | 158 | expect(queries).have.lengthOf(0) 159 | }) 160 | }) 161 | }) 162 | }) 163 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![CircleCI](https://circleci.com/gh/ReactiveDB/core.svg?style=svg)](https://circleci.com/gh/ReactiveDB/core) 2 | [![Coverage Status](https://coveralls.io/repos/github/ReactiveDB/core/badge.svg?branch=master)](https://coveralls.io/github/ReactiveDB/core?branch=master) 3 | [![Dependency Status](https://david-dm.org/ReactiveDB/core.svg)](https://david-dm.org/ReactiveDB/core) 4 | [![devDependencies Status](https://david-dm.org/ReactiveDB/core/dev-status.svg)](https://david-dm.org/ReactiveDB/core?type=dev) 5 | # ReactiveDB 6 | 7 | [![Greenkeeper badge](https://badges.greenkeeper.io/ReactiveDB/core.svg)](https://greenkeeper.io/) 8 | 9 | 一个 Reactive 风格的前端 ORM。基于 [Lovefield](https://github.com/google/lovefield) 与 [RxJS](https://github.com/ReactiveX/rxjs)。 10 | 11 | Fork from [teambition/ReactiveDB](https://github.com/teambition/reactivedb) 12 | 13 | ## Features 14 | - 响应式查询 15 | 16 | 支持以 Observable 的形式返回响应式数据 17 | - 数据一致性 18 | 19 | 所有的执行过程都是`事务性`的,在遇到环境异常时(indexDB 异常,浏览器限制,隐私模式导致的功能性缺失等) 也不会产生脏数据。 20 | 21 | - 数据持久化 22 | 23 | 大量的数据场景下,极端如单页应用不间断运行几个月的情况下,不会造成内存占用量过多。所有的数据都可以持久化在本地存储而非内存中,~~并支持丰富的数据换页配置~~[WIP]。 24 | 25 | - debug tools 26 | 27 | [Lovefield debug tool for Chrome](https://chrome.google.com/webstore/detail/lovefield-db-inspector/pcolnppcajocbhmgmljobphopnchkcig) 28 | 29 | 30 | ## Documents 31 | - [Design Document](./docs/Design-Document) 32 | - [API Description](./docs/API-description) 33 | 34 | 35 | ## Scenarios 36 | > 在单页实时性应用的场景下,抽象出在前端维护数据以及其关联的数据的变更的逻辑 37 | 38 | 考虑下面的场景,在一个单页前端应用中,需要展示 A,B, C, D 四个列表: 39 | 40 | 其中列表 A 展示所有 ownerId 为 `user1` 的 Item : 41 | 42 | ```json 43 | [ 44 | { 45 | "_id": 1, 46 | "name": "item 1", 47 | "ownerId": "user1", 48 | "creatorId": "user2", 49 | "created": "2016-01-31T16:00:00.000Z", 50 | "owner": { 51 | "_id": "user1", 52 | "name": "user1 name" 53 | }, 54 | "creator": { 55 | "_id": "user2", 56 | "name": "user2 name" 57 | } 58 | }, 59 | { 60 | "_id": 3, 61 | "name": "item 1", 62 | "ownerId": "user1", 63 | "creatorId": "user3", 64 | "created": "2016-05-03T16:00:00.000Z", 65 | "owner": { 66 | "_id": "user1", 67 | "name": "user1 name" 68 | }, 69 | "creator": { 70 | "_id": "user3", 71 | "name": "user3 name" 72 | } 73 | } 74 | ... 75 | ] 76 | ``` 77 | 78 | 列表 B 展示所有 creatorId 为 `user2` 的 Item: 79 | 80 | ```json 81 | [ 82 | { 83 | "_id": 1, 84 | "name": "item 1", 85 | "ownerId": "user1", 86 | "creatorId": "user2", 87 | "created": "2016-01-31T16:00:00.000Z", 88 | "owner": { 89 | "_id": "user1", 90 | "name": "user1 name" 91 | }, 92 | "creator": { 93 | "_id": "user2", 94 | "name": "user2 name" 95 | } 96 | }, 97 | { 98 | "_id": 2, 99 | "name": "item 1", 100 | "ownerId": "user2", 101 | "creatorId": "user3", 102 | "created": "2016-04-20T16:00:00.000Z", 103 | "owner": { 104 | "_id": "user2", 105 | "name": "user2 name" 106 | }, 107 | "creator": { 108 | "_id": "user3", 109 | "name": "user3 name" 110 | } 111 | } 112 | ... 113 | ] 114 | ``` 115 | 116 | 列表 C 展示所有 `created` 时间为 `2016年3月1日` 以后的 Item: 117 | 118 | ```json 119 | [ 120 | { 121 | "_id": 2, 122 | "name": "item 1", 123 | "ownerId": "user2", 124 | "creatorId": "user3", 125 | "created": "2016-04-20T16:00:00.000Z", 126 | "owner": { 127 | "_id": "user2", 128 | "name": "user2 name" 129 | }, 130 | "creator": { 131 | "_id": "user3", 132 | "name": "user3 name" 133 | } 134 | }, 135 | { 136 | "_id": 3, 137 | "name": "item 1", 138 | "ownerId": "user1", 139 | "creatorId": "user3", 140 | "created": "2016-05-03T16:00:00.000Z", 141 | "owner": { 142 | "_id": "user1", 143 | "name": "user1 name" 144 | }, 145 | "creator": { 146 | "_id": "user3", 147 | "name": "user3 name" 148 | } 149 | } 150 | ] 151 | ``` 152 | 153 | 列表 D 展示所有的用户信息: 154 | ```json 155 | [ 156 | { 157 | "_id": "user1", 158 | "name": "user1 name", 159 | "avatarUrl": "user1 avatarUrl", 160 | "birthday": "user1 birthday" 161 | }, 162 | { 163 | "_id": "user2", 164 | "name": "user2 name", 165 | "avatarUrl": "user2 avatarUrl", 166 | "birthday": "user2 birthday" 167 | }, 168 | { 169 | "_id": "user3", 170 | "name": "user3 name", 171 | "avatarUrl": "user3 avatarUrl", 172 | "birthday": "user3 birthday" 173 | } 174 | ] 175 | ``` 176 | 177 | 178 | 这四个列表的数据分别从四个 API 获取。在大多数单页应用的架构中,数据层会缓存这几个接口的数据,避免重复请求。而在实时性的单页应用中,这些数据的更新通常需要通过 `WebSocket` 等手段进行更新。根据缓存策略的不同(单例存储/同一 ID 存储多份数据),则有不同的更新方式。但这个过程一般是 *业务化且难以抽象* 的。 179 | 180 | 181 | 比如单一引用存储数据时, 上面场景中列举到的数据只会存储为: 182 | 183 | ``` 184 | { 185 | item1, item2, item3, 186 | user1, user2, user3 187 | } 188 | ``` 189 | 190 | 在这种缓存策略下,一个数据变更后,将变更后的结果通知到所属的集合是一件非常麻烦的事情。 191 | 假设现在我们的应用收到一条 socket 消息: 192 | 193 | ```json 194 | { 195 | "change:item1": { 196 | "ownerId": "user3" 197 | } 198 | } 199 | ``` 200 | 201 | 按照业务需求我们应该将 `item1` 从 `ListA` 中移除。在这种缓存策略中,如果使用的 `pub/sub` 的模型进行通知(Backbone 之类),则会导致数据层外的代码不得不进行大量的计算,不停的 `filter` 一个变更是否满足某个列表的需求。这种重复的过程是非常难以维护,业务化,且难以抽象的。 202 | 而按照 `ReactiveDB` 的设计理念,所有的数据都有可选的响应模式,即任何与之相关的变动都会让数据`自行`更新为最新的值: 203 | 204 | 伪代码如下: 205 | 206 | ```ts 207 | /** 208 | * @param tableName 209 | * @param queryOptions 210 | * @return QueryToken 211 | **/ 212 | database.get('Item', { 213 | where: { 214 | ownerId: 'user1' 215 | }, 216 | fields: [ 217 | '_id', 'name', 'ownerId', 218 | { 219 | owner: ['_id', 'name'] 220 | } 221 | ] 222 | }) 223 | .changes() 224 | .subscribe(items => { 225 | console.log(items) 226 | }) 227 | ``` 228 | 229 | 使用 ReactiveDB 的情况下,无论是 `Item` 本身的变更还是与之关联的 `User` 变更,都会产生新的 items 值。 230 | 更复杂的比如 `ListC`: 231 | 232 | ```ts 233 | /** 234 | * @param tableName 235 | * @param queryOptions 236 | * @return QueryToken 237 | **/ 238 | database.get('Item', { 239 | where: { 240 | created: { 241 | // $gte means great than and equal 242 | // 更多操作符参见详细的使用文档 243 | '$gte': new Date(2016, 3, 1).valueOf() 244 | } 245 | }, 246 | fields: [ 247 | '_id', 'name', 'ownerId', 248 | { 249 | owner: ['_id', 'name'] 250 | } 251 | ] 252 | }) 253 | .changes() 254 | .subscribe(items => { 255 | console.log(items) 256 | }) 257 | ``` 258 | 259 | 260 | ## Publish 261 | On `master` branch, 262 | ``` 263 | > npm version [version name] 264 | > git push --follow-tags 265 | ``` 266 | The [./.circleci/config.yml](./.circleci/config.yml) script should take care of the following jobs: 267 | 1. build ReactiveDB/core packages; 268 | 2. verify that all tests are passing; 269 | 3. given that the current commit is a release commit, publish the built packages to [NPM repository](https://www.npmjs.com/package/reactivedb). 270 | 271 | 272 | Done :) 273 | 274 | 275 | If a release is to be published from a branch other than `master`, please make sure to version it as an _alpha_ or a _beta_; `v0.9.15-alpha.1-description` for example. And you will have to build (`npm run build_all`) and publish (`npm run publish_all`) from local. 276 | -------------------------------------------------------------------------------- /test/specs/shared/Traversable.spec.ts: -------------------------------------------------------------------------------- 1 | import { Traversable, getType } from '../../index' 2 | import { describe, it, beforeEach } from 'tman' 3 | import { expect, use } from 'chai' 4 | import * as sinon from 'sinon' 5 | import * as SinonChai from 'sinon-chai' 6 | 7 | use(SinonChai) 8 | 9 | export default describe('Traversable Testcase: ', () => { 10 | describe('Class: Traversable', () => { 11 | let fixture: any 12 | let traversable: Traversable 13 | let nodeOrder: any[] 14 | let keyOrder: any[] 15 | let parentOrder: any[] 16 | let pathOrder: any[] 17 | let childrenOrder: any[] 18 | 19 | beforeEach(() => { 20 | fixture = [ 21 | { 22 | _id: '1', 23 | ownerId: '2', 24 | owner: { 25 | _id: '3', 26 | name: 'teh0diarsz', 27 | }, 28 | modules: [ 29 | { 30 | _id: 'c531fd0f', 31 | name: 'ljz6eexwd4', 32 | ownerId: '4', 33 | parentId: '5', 34 | programmer: { 35 | _id: '6', 36 | name: 'teh0diarsz', 37 | }, 38 | }, 39 | ], 40 | }, 41 | ] 42 | 43 | nodeOrder = [ 44 | fixture, 45 | fixture[0], 46 | fixture[0]._id, 47 | fixture[0].ownerId, 48 | fixture[0].owner, 49 | fixture[0].owner._id, 50 | fixture[0].owner.name, 51 | fixture[0].modules, 52 | fixture[0].modules[0], 53 | fixture[0].modules[0]._id, 54 | fixture[0].modules[0].name, 55 | fixture[0].modules[0].ownerId, 56 | fixture[0].modules[0].parentId, 57 | fixture[0].modules[0].programmer, 58 | fixture[0].modules[0].programmer._id, 59 | fixture[0].modules[0].programmer.name, 60 | ] 61 | 62 | keyOrder = [ 63 | undefined, 64 | 0, 65 | '_id', 66 | 'ownerId', 67 | 'owner', 68 | '_id', 69 | 'name', 70 | 'modules', 71 | 0, 72 | '_id', 73 | 'name', 74 | 'ownerId', 75 | 'parentId', 76 | 'programmer', 77 | '_id', 78 | 'name', 79 | ] 80 | 81 | parentOrder = [ 82 | undefined, 83 | fixture, 84 | fixture[0], 85 | fixture[0], 86 | fixture[0], 87 | fixture[0].owner, 88 | fixture[0].owner, 89 | fixture[0], 90 | fixture[0].modules, 91 | fixture[0].modules[0], 92 | fixture[0].modules[0], 93 | fixture[0].modules[0], 94 | fixture[0].modules[0], 95 | fixture[0].modules[0], 96 | fixture[0].modules[0].programmer, 97 | fixture[0].modules[0].programmer, 98 | ] 99 | 100 | pathOrder = [ 101 | [], 102 | [0], 103 | [0, '_id'], 104 | [0, 'ownerId'], 105 | [0, 'owner'], 106 | [0, 'owner', '_id'], 107 | [0, 'owner', 'name'], 108 | [0, 'modules'], 109 | [0, 'modules', 0], 110 | [0, 'modules', 0, '_id'], 111 | [0, 'modules', 0, 'name'], 112 | [0, 'modules', 0, 'ownerId'], 113 | [0, 'modules', 0, 'parentId'], 114 | [0, 'modules', 0, 'programmer'], 115 | [0, 'modules', 0, 'programmer', '_id'], 116 | [0, 'modules', 0, 'programmer', 'name'], 117 | ] 118 | 119 | childrenOrder = [ 120 | [0], 121 | ['_id', 'ownerId', 'owner', 'modules'], 122 | [], 123 | [], 124 | ['_id', 'name'], 125 | [], 126 | [], 127 | [0], 128 | ['_id', 'name', 'ownerId', 'parentId', 'programmer'], 129 | [], 130 | [], 131 | [], 132 | [], 133 | ['_id', 'name'], 134 | [], 135 | [], 136 | ] 137 | 138 | traversable = new Traversable(fixture) 139 | }) 140 | 141 | it('should can be instantiated correctly', () => { 142 | expect(traversable).is.instanceof(Traversable) 143 | }) 144 | 145 | describe('Method: keys', () => { 146 | it('should be able to handle Object correctly', () => { 147 | const obj = { a: 1, b: 2, c: 3, d: [4] } 148 | const ret = traversable.keys(obj) 149 | 150 | expect(ret).is.deep.equal(Object.keys(obj)) 151 | }) 152 | 153 | it('should be able to handle Array correctly', () => { 154 | const array = [0, 1, 2, 3, 4, 5] 155 | const ret = traversable.keys(array) 156 | 157 | expect(ret).is.deep.equal(Array.from(array.keys())) 158 | }) 159 | 160 | it('should be able to handle sorts of basic type', () => { 161 | const array = [/\w*/, true, false, 1, 'str', new Date(), (): void => void 0] 162 | array.forEach((item) => { 163 | const ret = traversable.keys(item) 164 | expect(ret).is.deep.equal([]) 165 | }) 166 | }) 167 | }) 168 | 169 | describe('Method: context', () => { 170 | it('should mount ctxgen function successfully', () => { 171 | const spy = sinon.spy() 172 | traversable.context(spy) 173 | sinon.stub(traversable, 'forEach').callsFake(function(this: any) { 174 | this.ctxgen() 175 | }) 176 | 177 | traversable.forEach(() => void 0) 178 | expect(spy).to.be.called 179 | }) 180 | }) 181 | 182 | describe('Method: forEach', () => { 183 | it('should skip the eachFunc when returanValue of ctxgen is false', () => { 184 | const fn = () => false 185 | const spy = sinon.spy() 186 | traversable.context(fn).forEach(spy) 187 | 188 | expect(spy).is.not.be.called 189 | }) 190 | 191 | it('should execute the eachFunc when returanValue of ctxgen is true', () => { 192 | const fn = () => true 193 | const spy = sinon.spy() 194 | traversable.context(fn).forEach(spy) 195 | 196 | expect(spy.callCount).is.equal(16) 197 | }) 198 | 199 | it('should take the returnValue of ctxgen as eachFunc first parameter', () => { 200 | const ctx = { foo: 1, bar: 2 } 201 | const fn = () => ctx 202 | const spy = sinon.spy() 203 | traversable.context(fn).forEach(spy) 204 | 205 | expect(spy.callCount).is.equal(16) 206 | for (let i = 0; i < spy.callCount; i++) { 207 | expect(spy.getCall(i).args[0]).to.have.property('foo', 1) 208 | expect(spy.getCall(i).args[0]).to.have.property('bar', 2) 209 | } 210 | }) 211 | 212 | it('should iterate every node', () => { 213 | let index = 0 214 | traversable.forEach((ctx, node) => { 215 | expect(ctx.node).to.equal(nodeOrder[index]) 216 | expect(node).to.equal(nodeOrder[index]) 217 | index++ 218 | }) 219 | }) 220 | 221 | it('should be able to use `context.isRoot`', () => { 222 | traversable.forEach((ctx, node) => { 223 | if (node === fixture) { 224 | expect(ctx.isRoot).to.equal(true) 225 | } else { 226 | expect(ctx.isRoot).to.equal(false) 227 | } 228 | }) 229 | }) 230 | 231 | it('should be able to use `context.isLeaf`', () => { 232 | traversable.forEach((ctx, node) => { 233 | if ( 234 | node === fixture || 235 | node === fixture[0] || 236 | node === fixture[0].owner || 237 | node === fixture[0].modules || 238 | node === fixture[0].modules[0] || 239 | node === fixture[0].modules[0].programmer 240 | ) { 241 | expect(ctx.isLeaf).to.equal(false) 242 | } else { 243 | expect(ctx.isLeaf).to.equal(true) 244 | } 245 | }) 246 | }) 247 | 248 | it('should be able to use `context.index`', () => { 249 | let index = 0 250 | traversable.forEach((ctx) => { 251 | expect(ctx.index).to.equal(index) 252 | index++ 253 | }) 254 | }) 255 | 256 | it('should be able to skip iteration of children when `context.skip` is called', () => { 257 | traversable.forEach((ctx, node) => { 258 | if (ctx.isRoot) { 259 | ctx.skip() 260 | } else { 261 | node['@@tag'] = true 262 | } 263 | }) 264 | 265 | nodeOrder.forEach((n) => expect(n['@@tag']).to.not.equal(true)) 266 | }) 267 | 268 | it('should be able to get nodeType via `context.type`', () => { 269 | traversable.forEach((ctx, node) => { 270 | expect(ctx.type()).to.equal(getType(node)) 271 | }) 272 | }) 273 | 274 | it('should be able to get node from `context.node` and second parameter of eachFunc', () => { 275 | traversable.forEach((ctx, node) => { 276 | expect(ctx.node).to.equal(node) 277 | }) 278 | }) 279 | 280 | it("should be able to get node's keyIndex via `context.key`", () => { 281 | let index = 0 282 | traversable.forEach((ctx) => { 283 | expect(ctx.key).to.equal(keyOrder[index]) 284 | index++ 285 | }) 286 | }) 287 | 288 | it('should be able to get parent of current node via `context.parent`', () => { 289 | let index = 0 290 | traversable.forEach((ctx) => { 291 | if (ctx.isRoot) { 292 | expect(ctx.parent).to.equal(undefined) 293 | } else { 294 | expect(ctx.parent).to.equal(parentOrder[index]) 295 | } 296 | index++ 297 | }) 298 | }) 299 | 300 | it('should be able to get traverse path via `context.path`', () => { 301 | let index = 0 302 | traversable.forEach((ctx) => { 303 | expect(ctx.path).to.deep.equal(pathOrder[index]) 304 | index++ 305 | }) 306 | }) 307 | 308 | it("should be able to get keyIndex of node's children via `context.children`", () => { 309 | let index = 0 310 | traversable.forEach((ctx) => { 311 | expect(ctx.children).to.deep.equal(childrenOrder[index]) 312 | index++ 313 | }) 314 | }) 315 | }) 316 | }) 317 | }) 318 | -------------------------------------------------------------------------------- /src/storage/modules/Selector.ts: -------------------------------------------------------------------------------- 1 | import { Observable, OperatorFunction, concat, from, asyncScheduler } from 'rxjs' 2 | import { 3 | combineAll, 4 | debounceTime, 5 | map, 6 | mergeMap, 7 | publishReplay, 8 | reduce, 9 | refCount, 10 | startWith, 11 | switchMap, 12 | } from 'rxjs/operators' 13 | import * as lf from 'lovefield' 14 | import * as Exception from '../../exception' 15 | import { predicatableQuery, graph } from '../helper' 16 | import { identity, forEach, assert, warn } from '../../utils' 17 | import { PredicateProvider } from './PredicateProvider' 18 | import { ShapeMatcher, OrderInfo, StatementType } from '../../interface' 19 | import { mapFn } from './mapFn' 20 | 21 | const observeQuery = (db: lf.Database, query: lf.query.Select) => { 22 | return new Observable((observer) => { 23 | const listener = () => observer.next() 24 | db.observe(query, listener) 25 | return () => db.unobserve(query, listener) 26 | }) 27 | } 28 | 29 | export class Selector { 30 | private static concatFactory(...metaDatas: Selector[]) { 31 | const [meta] = metaDatas 32 | const skipsAndLimits = metaDatas.map((m) => ({ skip: m.skip, limit: m.limit })).sort((x, y) => x.skip! - y.skip!) 33 | const { db, lselect, shape, predicateProvider } = meta 34 | const [minSkip] = skipsAndLimits 35 | const maxLimit = skipsAndLimits.reduce((acc, current) => { 36 | const nextSkip = acc.skip! + acc.limit! 37 | assert( 38 | current.skip === nextSkip, 39 | Exception.TokenConcatFailed, 40 | ` 41 | skip should be serial, 42 | expect: ${JSON.stringify(acc, null, 2)} 43 | actual: ${nextSkip} 44 | `, 45 | ) 46 | return current 47 | }) 48 | return new Selector( 49 | db, 50 | lselect, 51 | shape, 52 | predicateProvider, 53 | maxLimit.limit! + maxLimit.skip!, 54 | minSkip.skip, 55 | meta.orderDescriptions, 56 | ).map(meta.mapFn) 57 | } 58 | 59 | private static combineFactory(...metaDatas: Selector[]) { 60 | const [originalToken] = metaDatas 61 | const fakeQuery = { toSql: identity } 62 | // 初始化一个空的 QuerySelector,然后在初始化以后替换它上面的属性和方法 63 | const dist = new Selector(originalToken.db, fakeQuery as any, {} as any) 64 | dist.change$ = from(metaDatas).pipe( 65 | map((metas) => metas.mapFn(metas.change$)), 66 | combineAll(), 67 | map((r) => r.reduce((acc, val) => acc.concat(val))), 68 | debounceTime(0, asyncScheduler), 69 | publishReplay(1), 70 | refCount(), 71 | ) 72 | dist.values = () => { 73 | assert(!dist.consumed, Exception.TokenConsumed) 74 | dist.consumed = true 75 | return from(metaDatas).pipe( 76 | mergeMap((metaData) => metaData.values()), 77 | reduce((acc, val) => acc.concat(val)), 78 | ) 79 | } 80 | dist.toString = () => { 81 | const querys = metaDatas.map((m) => m.toString()) 82 | return JSON.stringify(querys, null, 2) 83 | } 84 | dist.select = originalToken.select 85 | return dist 86 | } 87 | 88 | private static stringifyOrder(orderInfo: OrderInfo[]) { 89 | if (!orderInfo) { 90 | return 0 91 | } 92 | let orderStr = '' 93 | forEach(orderInfo, (order) => { 94 | const name = order.column.getName() 95 | const o = order.orderBy 96 | orderStr += `${name}:${o}` 97 | }) 98 | return orderStr 99 | } 100 | 101 | private mapFn: (stream$: Observable) => Observable = mapFn 102 | 103 | public select: string 104 | 105 | private _change$: Observable | null = null 106 | 107 | private get change$(): Observable { 108 | if (this._change$) { 109 | return this._change$ 110 | } 111 | const { db, limit } = this 112 | let { skip } = this 113 | skip = limit && !skip ? 0 : skip 114 | 115 | const observeOn = (query: lf.query.Select) => { 116 | const queryOnce = () => from(this.getValue(query)) 117 | // 下面的语句针对两个 lovefield issue 做了特殊调整: 118 | // issue#209: 确保 db.observe 之后立即执行一次查询; 119 | // issue#215: 确保 db.observe “不正确地”立即调用回调的行为,不会给消费方造成初始的重复推送。 120 | return observeQuery(db, query).pipe(startWith(void 0), switchMap(queryOnce)) 121 | } 122 | 123 | const changesOnQuery = 124 | limit || skip 125 | ? this.buildPrefetchingObserve().pipe(switchMap((pks) => observeOn(this.getQuery(this.inPKs(pks))))) 126 | : observeOn(this.getQuery()) 127 | 128 | return changesOnQuery.pipe(publishReplay(1), refCount()) 129 | } 130 | 131 | private set change$(dist$: Observable) { 132 | this._change$ = dist$ 133 | } 134 | 135 | private consumed = false 136 | private predicateBuildErr = false 137 | 138 | private get rangeQuery(): lf.query.Select { 139 | let predicate: lf.Predicate | null = null 140 | const { predicateProvider } = this 141 | if (predicateProvider && !this.predicateBuildErr) { 142 | predicate = predicateProvider.getPredicate() 143 | } 144 | const { pk, mainTable } = this.shape 145 | 146 | const column = mainTable[pk.name] 147 | const rangeQuery = predicatableQuery(this.db, mainTable, predicate, StatementType.Select, column) 148 | 149 | if (this.orderDescriptions && this.orderDescriptions.length) { 150 | forEach(this.orderDescriptions, (orderInfo) => rangeQuery.orderBy(orderInfo.column, orderInfo.orderBy!)) 151 | } 152 | 153 | rangeQuery.limit(this.limit!).skip(this.skip!) 154 | 155 | return rangeQuery 156 | } 157 | 158 | private get query(): lf.query.Select { 159 | const q = this.lselect.clone() 160 | 161 | if (this.orderDescriptions && this.orderDescriptions.length) { 162 | forEach(this.orderDescriptions, (orderInfo) => q.orderBy(orderInfo.column, orderInfo.orderBy!)) 163 | } 164 | 165 | return q 166 | } 167 | 168 | // returns the given PredicateProvider if it is not 'empty'; 169 | // otherwise, returns undefined 170 | private normPredicateProvider(pp?: PredicateProvider): PredicateProvider | undefined { 171 | try { 172 | return pp && pp.getPredicate() ? pp : undefined 173 | } catch (err) { 174 | this.predicateBuildErr = true 175 | warn(`Failed to build predicate, since ${err.message}` + `, on table: ${this.shape.mainTable.getName()}`) 176 | return undefined 177 | } 178 | } 179 | 180 | constructor( 181 | public db: lf.Database, 182 | private lselect: lf.query.Select, 183 | private shape: ShapeMatcher, 184 | public predicateProvider?: PredicateProvider, 185 | private limit?: number, 186 | private skip?: number, 187 | private orderDescriptions?: OrderInfo[], 188 | ) { 189 | this.predicateProvider = this.normPredicateProvider(predicateProvider) 190 | this.select = lselect.toSql() 191 | } 192 | 193 | toString(): string { 194 | return this.getQuery().toSql() 195 | } 196 | 197 | values(): Observable | never { 198 | if (typeof this.limit !== 'undefined' || typeof this.skip !== 'undefined') { 199 | const p = this.rangeQuery 200 | .exec() 201 | .then((r) => r.map((v) => v[this.shape.pk.name])) 202 | .then((pks) => this.getValue(this.getQuery(this.inPKs(pks)))) 203 | return this.mapFn(from(p)) 204 | } else { 205 | return this.mapFn(from(this.getValue(this.getQuery()) as Promise)) 206 | } 207 | } 208 | 209 | combine(...selectors: Selector[]): Selector { 210 | return Selector.combineFactory(this, ...selectors) 211 | } 212 | 213 | concat(...selectors: Selector[]): Selector { 214 | const orderStr = Selector.stringifyOrder(this.orderDescriptions!) 215 | const equal = selectors.every( 216 | (m) => 217 | m.select === this.select && 218 | Selector.stringifyOrder(m.orderDescriptions!) === orderStr && 219 | m.mapFn.toString() === this.mapFn.toString() && 220 | (m.predicateProvider === this.predicateProvider || 221 | (!!(m.predicateProvider && this.predicateProvider) && 222 | m.predicateProvider!.toString() === this.predicateProvider!.toString())), 223 | ) 224 | assert(equal, Exception.TokenConcatFailed) 225 | 226 | return Selector.concatFactory(this, ...selectors) 227 | } 228 | 229 | changes(): Observable | never { 230 | return this.mapFn(this.change$) 231 | } 232 | 233 | map(fn: OperatorFunction) { 234 | this.mapFn = fn 235 | return (this as any) as Selector 236 | } 237 | 238 | private inPKs(pks: (string | number)[]): lf.Predicate { 239 | const { pk, mainTable } = this.shape 240 | return mainTable[pk.name].in(pks) 241 | } 242 | 243 | private getValue(query: lf.query.Select) { 244 | return query.exec().then((rows: any[]) => { 245 | const result = graph(rows, this.shape.definition) 246 | const col = this.shape.pk.name 247 | return !this.shape.pk.queried ? this.removeKey(result, col) : result 248 | }) 249 | } 250 | 251 | private getQuery(additional?: lf.Predicate): lf.query.Select { 252 | if (this.predicateBuildErr) { 253 | return additional ? this.query.where(additional) : this.query 254 | } 255 | // !this.predicateBuildErr 256 | 257 | const preds: lf.Predicate[] = [] 258 | if (this.predicateProvider) { 259 | preds.push(this.predicateProvider.getPredicate()!) 260 | } 261 | if (additional) { 262 | preds.push(additional) 263 | } 264 | 265 | switch (preds.length) { 266 | case 0: 267 | return this.query 268 | case 1: 269 | return this.query.where(preds[0]) 270 | default: 271 | return this.query.where(lf.op.and(...preds)) 272 | } 273 | } 274 | 275 | private removeKey(data: any[], key: string) { 276 | data.forEach((entity) => delete entity[key]) 277 | return data 278 | } 279 | 280 | private buildPrefetchingObserve(): Observable<(string | number)[]> { 281 | const { rangeQuery } = this 282 | const queryOnce = () => from(rangeQuery.exec()) 283 | const update$ = observeQuery(this.db, rangeQuery).pipe(switchMap(queryOnce)) 284 | return concat(queryOnce(), update$).pipe(map((r) => r.map((v) => v[this.shape.pk.name]))) 285 | } 286 | } 287 | -------------------------------------------------------------------------------- /docs/API-description/README.md: -------------------------------------------------------------------------------- 1 | ## Database 2 | 3 | ### Constructor 4 | 5 | ```ts 6 | constructor( 7 | storeType: DataStoreType = DataStoreType.MEMORY, 8 | enableInspector: boolean = false, 9 | name = 'ReactiveDB', 10 | version = 1 11 | ) 12 | ``` 13 | 构造函数 14 | 15 | - ```Enum: DataStoreType``` 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 |
ValueIndex
INDEXED_DB0
MEMORY1
LOCAL_STORAGE2
WEB_SQL3
OBSERVABLE_STORE4
43 | 44 | *example:* 45 | 46 | ```ts 47 | // Database.ts 48 | improt { Database, DataStoreType } from 'reactivedb' 49 | 50 | export default new Database(DataStoreType.INDEXED_DB, true, 'Example', 1) 51 | ``` 52 | 53 | ### Database.prototype.defineSchema 54 | 55 | ```ts 56 | Database.defineSchema(tableName: string, schemaDef: SchemaDef): Database 57 | ``` 58 | 定义库中表的数据结构 / 形态 59 | 60 | - ```Method: Database.defineSchema(tableName: string, schemaDef: SchemaDef)``` 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 |
ParameterTypeRequiredDescription
tableNameStringrequired数据表的名字
schemaDefSchemaDefrequiredSchema Defination的描述信息
82 | 83 | - ```Interface: SchemaDef``` 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 |
ParameterTypeRequiredDescription
indexStringrequired对象索引
99 | 100 | - ```Interface: SchemaMetadata``` 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 |
ParameterTypeRequiredDescription
typeRDBTyperequired存储类型,只能为 RDBType 枚举值
primaryKeyBooleanoptional该字段是否设为主键
indexBooleanoptional该字段是否设为索引
uniqueBooleanoptional该字段是否唯一
virtualVirtualDefoptional该字段是否关联为其他表
140 | 141 | - ```Enum: RDBType``` 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 |
ValueIndex
ARRAY_BUFFER0
BOOLEAN1
DATE_TIME2
INTEGER3
NUMBER4
OBJECT5
STRING6
LITERAL_ARRAY7
181 | 182 | - ```Interface: VirtualDef``` 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 |
ParameterTypeRequiredDescription
nameStringrequired关联表的名字
whereFunctionrequired关联数据的约束条件
204 | 205 | [example](https://github.com/teambition/ReactiveDB/blob/master/example/rdb/defineSchema.ts) 206 | 207 | ### Database.prototype.dump 208 | 209 | ```ts 210 | database.dump(): Observable 211 | ``` 212 | 213 | dump 整个数据库,用于下次 load。 214 | 215 | ### Database.prototype.load 216 | 217 | ```ts 218 | database.load(data: Object): Observable 219 | ``` 220 | 221 | 加载 dump 出来的数据,必须在 `connect` 方法调用前调用。 222 | 223 | ### Database.prototype.connect 224 | 225 | ```ts 226 | database.connect(): void 227 | ``` 228 | 连接数据库,在 `defineSchema` 与 `defineHook` 完成之后调用,如果上述两个接口在 `connect` 之后依然被调用,则会抛出一个异常。 229 | 只有在 `connect` 之后才能调用下面的 API。 230 | 231 | ### Database.prototype.get 232 | ```ts 233 | database.get(tableName: string, clause: QueryDescription = {}): QueryToken 234 | ``` 235 | 对指定的表进行查询操作. 236 | 237 | - ```Method: database.get(tableName: string, clause: QueryDescription)``` 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 |
ParameterTypeRequiredDescription
tableNameStringrequired指定要执行查询操作的数据表的名字
clauseQueryDescriptionrequired指定用于查询操作的描述信息
259 | 260 | - ```Interface: QueryDescription``` 261 | 262 | 继承自ClauseDescription接口 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 |
ParameterTypeRequiredDescription
fieldsFieldsValue[]optional指定将要执行查询操作的时筛选的字段
whereFunctionoptional指定用于查询操作时的匹配条件
284 | 285 | - ```type: FieldsValue``` 286 | ``` 287 | type FieldsValue = string | { [index: string]: string[] } 288 | ``` 289 | 290 | - return [QueryToken](./QueryToken.md) 291 | 292 | *example:* 293 | 294 | ```ts 295 | database.get('Task', { 296 | where: { 297 | dueDate: { 298 | $and: { 299 | $gt: moment().add(1, 'day').startOf('day').valueOf(), 300 | $lt: moment().add(6, 'day').endOf('day').valueOf() 301 | } 302 | }, 303 | involveMembers: { 304 | $has: 'xxxxuserId' 305 | } 306 | } 307 | }) 308 | ``` 309 | 310 | ### Database.prototype.insert 311 | ```ts 312 | database.insert(tableName: string, data: T | T[]): Observable | Observable 313 | ``` 314 | 对指定的数据表进行插入操作. 若table上存在insert hook, 则先执行hook再进行插入操作。 315 | 316 | - ```Method: database.insert(tableName: string, data: T | T[])``` 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 |
ParameterTypeRequiredDescription
tableNameStringrequired指定将要执行插入操作的数据表的名字
dataT | T[]required存储的数据实体
338 | 339 | ## Database.prototype.update 340 | ```ts 341 | database.update(tableName: string, clause: ClauseDescription, patch): void 342 | ``` 343 | 对表中的指定的数据进行更新操作. 344 | 345 | - ```Method: database.update(tableName: string, clause: ClauseDescription, patch: any)``` 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 |
ParameterTypeRequiredDescription
tableNameStringrequired指定将要执行更新操作的数据表的名字
clauseClauseDescriptionrequired指定将要执行更新操作的实体匹配条件
patchObjectrequired更新的实体
373 | 374 | - ```Interface: ClauseDescription``` 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 |
ParameterTypeRequiredDescription
whereFunctionoptional指定用于查询操作时的匹配条件
390 | 391 | ## Database.prototype.delete 392 | ```ts 393 | database.delete(tableName: string, clause: ClauseDescription): void 394 | ``` 395 | 对表中符合条件的数据进行删除操作. 若表中存在delete hook, 则先执行hook再进行删除. 396 | 397 | - ```Method: database.delete(tableName: string, clause: ClauseDescription)``` 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 |
ParameterTypeRequiredDescription
tableNameStringrequired指定将要执行删除操作的数据表的名字
clauseClauseDescriptionrequired指定用于执行删除操作的匹配条件
419 | 420 | ## Database.prototype.dispose() 421 | ```ts 422 | database.dispose() 423 | ``` 424 | 重置Database, 清空所有数据. 425 | 426 | - ```Method: database.dispose()``` 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 |
ParameterTypeRequiredDescription
No parameters
439 | 440 | ## Database.prototype.upsert 441 | ```ts 442 | upsert(tableName: string, raw: T | T[]): Observable 443 | 444 | interface ExecutorResult { 445 | result: boolean 446 | insert: number 447 | delete: number 448 | update: number 449 | select: number 450 | } 451 | ``` 452 | 453 | 454 | 455 | 456 | 457 | 458 | 459 | 460 | 461 | 462 | 463 | 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 |
ParameterTypeRequiredDescription
tableNameStringrequired指定将要执行upsert操作的数据表的名字
rawT | T[]required执行upsert操作的数据
473 | 474 | - return `ExecutorResult` 475 | 476 | 477 | 478 | 479 | 480 | 481 | 482 | 483 | 484 | 485 | 486 | 487 | 488 | 489 | 490 | 491 | 492 | 493 | 494 | 495 | 496 | 497 | 498 | 499 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 |
fieldsTypeDescription
resultBoolean执行是否成功
insertnumber执行insert的数据条数
deletenumber执行delete的数据条数
updatenumber执行update的数据条数
selectnumber执行select的数据条数
508 | -------------------------------------------------------------------------------- /test/specs/storage/modules/PredicateProvider.spec.ts: -------------------------------------------------------------------------------- 1 | import * as lf from 'lovefield' 2 | import { describe, it, beforeEach } from 'tman' 3 | import { expect } from 'chai' 4 | import { tap } from 'rxjs/operators' 5 | import { PredicateProvider, lfFactory, DataStoreType } from '../../../index' 6 | 7 | export default describe('PredicateProvider test', () => { 8 | const dataLength = 1000 9 | 10 | const execQuery = (_db: any, _table: any, pred?: any) => 11 | _db 12 | .select() 13 | .from(_table) 14 | .where(pred) 15 | .exec() 16 | 17 | let db: lf.Database 18 | let table: lf.schema.Table 19 | let version = 1 20 | 21 | beforeEach(function*() { 22 | const schemaBuilder = lf.schema.create('PredicateProviderDatabase', version++) 23 | const db$ = lfFactory(schemaBuilder, { 24 | storeType: DataStoreType.MEMORY, 25 | enableInspector: false, 26 | }) 27 | const tableBuilder = schemaBuilder.createTable('TestPredicateProvider') 28 | tableBuilder 29 | .addColumn('_id', lf.Type.STRING) 30 | .addColumn('name', lf.Type.STRING) 31 | .addColumn('time1', lf.Type.NUMBER) 32 | .addColumn('time2', lf.Type.NUMBER) 33 | .addColumn('times', lf.Type.STRING) 34 | .addColumn('nullable', lf.Type.BOOLEAN) 35 | .addPrimaryKey(['_id']) 36 | .addNullable(['nullable']) 37 | 38 | db$.connect() 39 | db = yield db$.pipe( 40 | tap((r) => { 41 | table = r.getSchema().table('TestPredicateProvider') 42 | }), 43 | ) 44 | const rows: lf.Row[] = [] 45 | for (let i = 0; i < dataLength; i++) { 46 | rows.push( 47 | table.createRow({ 48 | _id: `_id:${i}`, 49 | name: `name:${i}`, 50 | time1: i, 51 | time2: dataLength - i, 52 | times: [i - 1, i, i + 1].map((r) => `times: ${r}`).join('|'), 53 | nullable: i >= 300 ? null : false, 54 | }), 55 | ) 56 | } 57 | yield db 58 | .insert() 59 | .into(table) 60 | .values(rows) 61 | .exec() 62 | }) 63 | 64 | describe('PredicateProvider#getPredicate', () => { 65 | it('invalid key should be ignored', function*() { 66 | const fn = () => 67 | new PredicateProvider(table, { 68 | nonExist: 'whatever', 69 | }).getPredicate() 70 | 71 | const expectResult = yield execQuery(db, table) 72 | const result = yield execQuery(db, table, fn()) 73 | 74 | expect(result).deep.equal(expectResult) 75 | }) 76 | 77 | it('empty meta should ok', function*() { 78 | const predicate = new PredicateProvider(table, {}).getPredicate() 79 | expect(predicate).to.be.null 80 | const result = yield execQuery(db, table, predicate) 81 | expect(result).to.have.lengthOf(1000) 82 | }) 83 | 84 | it('literal value should ok', function*() { 85 | const predicate = new PredicateProvider(table, { 86 | time1: 20, 87 | }).getPredicate() 88 | 89 | const result = yield execQuery(db, table, predicate) 90 | 91 | expect(result).to.have.lengthOf(1) 92 | expect(result[0]['time1']).to.equal(20) 93 | }) 94 | 95 | it('$ne should ok', function*() { 96 | const predicate = new PredicateProvider(table, { 97 | time1: { 98 | $ne: 20, 99 | }, 100 | }).getPredicate() 101 | 102 | const result = yield execQuery(db, table, predicate) 103 | 104 | expect(result).to.have.lengthOf(dataLength - 1) 105 | result.forEach((r: any) => expect(r['time1'] === 20).to.be.false) 106 | }) 107 | 108 | it('$lt should ok', function*() { 109 | const predicate = new PredicateProvider(table, { 110 | time1: { 111 | $lt: 20, 112 | }, 113 | }).getPredicate() 114 | 115 | const result = yield execQuery(db, table, predicate) 116 | 117 | expect(result).to.have.lengthOf(20) 118 | result.forEach((r: any) => expect(r['time1'] < 20).to.be.true) 119 | }) 120 | 121 | it('$lte should ok', function*() { 122 | const predicate = new PredicateProvider(table, { 123 | time1: { 124 | $lte: 19, 125 | }, 126 | }).getPredicate() 127 | 128 | const result = yield execQuery(db, table, predicate) 129 | 130 | expect(result).to.have.lengthOf(20) 131 | result.forEach((r: any) => expect(r['time1'] <= 19).to.be.true) 132 | }) 133 | 134 | it('$gt should ok', function*() { 135 | const predicate = new PredicateProvider(table, { 136 | time2: { 137 | $gt: 20, 138 | }, 139 | }).getPredicate() 140 | 141 | const result = yield execQuery(db, table, predicate) 142 | 143 | expect(result).to.have.lengthOf(dataLength - 20) 144 | result.forEach((r: any) => expect(r['time2'] > 20).to.be.true) 145 | }) 146 | 147 | it('$gte should ok', function*() { 148 | const predicate = new PredicateProvider(table, { 149 | time2: { 150 | $gte: 21, 151 | }, 152 | }).getPredicate() 153 | 154 | const result = yield execQuery(db, table, predicate) 155 | 156 | expect(result).to.have.lengthOf(dataLength - 20) 157 | result.forEach((r: any) => expect(r['time2'] >= 21).to.be.true) 158 | }) 159 | 160 | it('$match should ok', function*() { 161 | const regExp = /\:(\d{0,1}1$)/ 162 | const predicate = new PredicateProvider(table, { 163 | name: { 164 | $match: regExp, 165 | }, 166 | }).getPredicate() 167 | 168 | const result = yield execQuery(db, table, predicate) 169 | 170 | expect(result).to.have.lengthOf(10) 171 | result.forEach((r: any) => expect(regExp.test(r['name'])).to.be.true) 172 | }) 173 | 174 | it('$notMatch should ok', function*() { 175 | const regExp = /\:(\d{0,1}1$)/ 176 | const predicate = new PredicateProvider(table, { 177 | name: { 178 | $notMatch: regExp, 179 | }, 180 | }).getPredicate() 181 | 182 | const result = yield execQuery(db, table, predicate) 183 | 184 | // 上一个测试中结果长度是 10 185 | expect(result).to.have.lengthOf(dataLength - 10) 186 | result.forEach((r: any) => expect(regExp.test(r['name'])).to.be.false) 187 | }) 188 | 189 | it('$between should ok', function*() { 190 | const predicate = new PredicateProvider(table, { 191 | time1: { 192 | $between: [1, 20], 193 | }, 194 | }).getPredicate() 195 | 196 | const result = yield execQuery(db, table, predicate) 197 | 198 | expect(result).to.have.lengthOf(20) 199 | result.forEach((r: any) => expect(r['time1'] > 0 && r['time1'] <= 20).to.be.true) 200 | }) 201 | 202 | it('$has should ok', function*() { 203 | const predicate = new PredicateProvider(table, { 204 | times: { 205 | $has: 'times: 10', 206 | }, 207 | }).getPredicate() 208 | 209 | const result = yield execQuery(db, table, predicate) 210 | 211 | expect(result).to.have.lengthOf(3) 212 | result.forEach((r: any) => { 213 | expect(r.times.match(/times: 10\b/)).to.not.be.null 214 | }) 215 | }) 216 | 217 | it('$in should ok', function*() { 218 | const seed = [10, 20, 30, 10000] 219 | const predicate = new PredicateProvider(table, { 220 | time1: { 221 | $in: seed, 222 | }, 223 | }).getPredicate() 224 | 225 | const result = yield execQuery(db, table, predicate) 226 | 227 | expect(result).to.have.lengthOf(3) 228 | result.forEach((r: any) => expect(seed.indexOf(r['time1']) !== -1).to.be.true) 229 | }) 230 | 231 | it('$isNull should ok', function*() { 232 | const predicate = new PredicateProvider(table, { 233 | nullable: { 234 | $isNull: true, 235 | }, 236 | }).getPredicate() 237 | 238 | const result = yield execQuery(db, table, predicate) 239 | 240 | expect(result).to.have.lengthOf(700) 241 | result.forEach((r: any) => expect(r['nullable']).to.be.null) 242 | }) 243 | 244 | it('$isNotNull should ok', function*() { 245 | const predicate = new PredicateProvider(table, { 246 | nullable: { 247 | $isNotNull: true, 248 | }, 249 | }).getPredicate() 250 | 251 | const result = yield execQuery(db, table, predicate) 252 | 253 | expect(result).to.have.lengthOf(300) 254 | result.forEach((r: any) => expect(r['nullable']).to.not.be.null) 255 | }) 256 | 257 | it('$not should ok', function*() { 258 | const predicate = new PredicateProvider(table, { 259 | $not: { 260 | time1: 0, 261 | }, 262 | }).getPredicate() 263 | 264 | const result = yield execQuery(db, table, predicate) 265 | 266 | expect(result).to.have.lengthOf(dataLength - 1) 267 | }) 268 | 269 | it('$and should ok', function*() { 270 | const predicate = new PredicateProvider(table, { 271 | time1: { 272 | $and: { 273 | $lt: 200, 274 | $gte: 50, 275 | }, 276 | }, 277 | }).getPredicate() 278 | 279 | const result = yield execQuery(db, table, predicate) 280 | 281 | expect(result).to.have.lengthOf(150) 282 | result.forEach((r: any) => expect(r['time1'] >= 50 && r['time1'] < 200).to.be.true) 283 | }) 284 | 285 | it('$or should ok', function*() { 286 | const predicate = new PredicateProvider(table, { 287 | time1: { 288 | $or: { 289 | $gte: dataLength - 50, 290 | $lt: 50, 291 | }, 292 | }, 293 | }).getPredicate() 294 | 295 | const result = yield execQuery(db, table, predicate) 296 | 297 | expect(result).to.have.lengthOf(100) 298 | result.forEach((r: any) => expect(r['time1'] >= dataLength - 50 || r['time1'] < 50).to.be.true) 299 | }) 300 | 301 | it('non-compound predicates should be combined with $and', function*() { 302 | const predicate = new PredicateProvider(table, { 303 | $or: { 304 | time1: { $gte: 0, $lt: 50 }, 305 | time2: { $gt: 0, $lte: 50 }, 306 | }, 307 | }).getPredicate() 308 | 309 | const result = yield execQuery(db, table, predicate) 310 | 311 | expect(result).to.have.lengthOf(100) 312 | result.forEach((row: any) => { 313 | expect((row.time1 >= 0 && row.time1 < 50) || (row.time2 <= 50 && row.time2 > 0)).to.be.true 314 | }) 315 | }) 316 | 317 | it('compoundPredicate should skip null/undefined property', function*() { 318 | const predicate = new PredicateProvider(table, { 319 | time1: { 320 | $or: { 321 | $gte: dataLength - 50, 322 | $lt: null, 323 | }, 324 | }, 325 | }).getPredicate() 326 | 327 | const result = yield execQuery(db, table, predicate) 328 | 329 | expect(result).to.have.lengthOf(50) 330 | result.forEach((r: any) => expect(r['time1'] >= dataLength - 50).to.be.true) 331 | }) 332 | 333 | it('complex PredicateDescription should ok', function*() { 334 | const reg = /\:(\d{0,1}1$)/ 335 | const predicate = new PredicateProvider(table, { 336 | time1: { 337 | $or: { 338 | $gte: dataLength - 50, 339 | $lt: 50, 340 | }, 341 | }, 342 | time2: { 343 | $and: { 344 | $gte: dataLength / 2, 345 | $lt: dataLength, 346 | }, 347 | }, 348 | name: { 349 | $match: reg, 350 | }, 351 | }).getPredicate() 352 | 353 | const result = yield execQuery(db, table, predicate) 354 | 355 | expect(result).to.have.lengthOf(5) 356 | 357 | result.forEach((r: any) => { 358 | const pred1 = r['time1'] >= dataLength - 50 || r['time1'] < 50 359 | const pred2 = r['time2'] >= dataLength / 2 && r['time2'] < dataLength 360 | const pred3 = reg.test(r['name']) 361 | 362 | expect(pred1 && pred2 && pred3).to.be.true 363 | }) 364 | }) 365 | }) 366 | 367 | describe('PredicateProvider#toString', () => { 368 | it('convert empty PredicateProvider to empty string', () => { 369 | expect(new PredicateProvider(table, {}).toString()).to.equal('') 370 | }) 371 | 372 | it('convert to string representation of the predicate', () => { 373 | expect(new PredicateProvider(table, { notExist: 20 }).toString()).to.equal('') 374 | 375 | expect(new PredicateProvider(table, { time1: 20 }).toString()).to.equal('{"time1":20}') 376 | 377 | expect( 378 | new PredicateProvider(table, { 379 | $or: { 380 | time1: { $gte: 0, $lt: 50 }, 381 | time2: { $gt: 0, $lte: 50 }, 382 | }, 383 | }).toString(), 384 | ).to.equal('{"$or":{"time1":{"$gte":0,"$lt":50},"time2":{"$gt":0,"$lte":50}}}') 385 | }) 386 | }) 387 | }) 388 | -------------------------------------------------------------------------------- /test/specs/utils/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { forEach, clone, getType, assert, hash, tryCatch } from '../../index' 2 | import { describe, it } from 'tman' 3 | import { expect } from 'chai' 4 | 5 | export default describe('Utils Testcase: ', () => { 6 | describe('Func: forEach', () => { 7 | describe('Array Iteration', () => { 8 | it('should be able to execute successfully', () => { 9 | const fixture = [1, 2, 3, 4, 5, 6] 10 | const dest: number[] = [] 11 | 12 | forEach(fixture, (el) => { 13 | dest.push(el) 14 | }) 15 | 16 | expect(dest).is.deep.equal(fixture) 17 | }) 18 | 19 | it('should be able to get Index during iteration', () => { 20 | const fixture = [0, 1, 2, 3, 4, 5] 21 | const dest: number[] = [] 22 | 23 | forEach(fixture.map((e) => e * 2), (_, index) => { 24 | dest.push(index) 25 | }) 26 | 27 | expect(dest).is.deep.equal(fixture) 28 | }) 29 | 30 | it('should be able to break the iteration', () => { 31 | const arr = [0, 1, 2, 3, 4] 32 | const dest: number[] = [] 33 | 34 | forEach(arr, (ele) => { 35 | if (ele === 2) { 36 | return false 37 | } 38 | return dest.push(ele) 39 | }) 40 | 41 | expect(dest).to.have.lengthOf(2) 42 | }) 43 | 44 | it('should be iterated inversely', () => { 45 | const arr = [0, 1, 2, 3, 4, 5] 46 | const result: number[] = [] 47 | 48 | forEach( 49 | arr, 50 | (val) => { 51 | result.push(val) 52 | }, 53 | true, 54 | ) 55 | 56 | expect(result).to.eql(arr.reverse()) 57 | }) 58 | 59 | it('should be able to break the inverse iteration', () => { 60 | const arr = [0, 1, 2, 3, 4] 61 | const dest: number[] = [] 62 | 63 | forEach( 64 | arr, 65 | (ele) => { 66 | if (ele === 1) { 67 | return false 68 | } 69 | return dest.push(ele) 70 | }, 71 | true, 72 | ) 73 | 74 | expect(dest).to.have.lengthOf(3) 75 | }) 76 | }) 77 | 78 | describe('Object Iteration', () => { 79 | it('should be able to execute successfully', () => { 80 | const obj = { a: 1, b: 2, c: 3, d: 4, e: 5 } 81 | const dest = [1, 2, 3, 4, 5] 82 | const arr: number[] = [] 83 | 84 | forEach(obj, (val) => { 85 | arr.push(val) 86 | }) 87 | 88 | expect(arr.sort((x, y) => x - y)).to.deep.equal(dest) 89 | }) 90 | 91 | it('should be able to get Key during iteration', () => { 92 | const obj = { a: 1, b: 2, c: 3, d: 4, e: 5 } 93 | const dest = Object.keys(obj).sort() 94 | const arr: string[] = [] 95 | 96 | forEach(obj, (_, key) => { 97 | arr.push(key) 98 | }) 99 | 100 | expect(arr.sort()).to.deep.equal(dest) 101 | }) 102 | 103 | it('should be able to break the iteration', () => { 104 | const obj = { a: 1, b: 2, c: 3, d: 4, e: 5 } 105 | const arr: any[] = [] 106 | const dest = [1, 2, 3] 107 | 108 | forEach(obj, (val) => { 109 | if (val === 4) { 110 | return false 111 | } 112 | return arr.push(val) 113 | }) 114 | 115 | expect(arr).to.deep.equal(dest) 116 | }) 117 | }) 118 | 119 | describe('Set Iteration', () => { 120 | it('should be able to execute successfully', () => { 121 | const origin = [1, 2, 3, 4, 5] 122 | const set = new Set(origin) 123 | const total = origin.reduce((pre, curr) => pre + curr) 124 | 125 | let count = 0 126 | forEach(set, (val, key) => { 127 | count += val 128 | expect(val).is.equal(key) 129 | }) 130 | 131 | expect(count).is.equal(total) 132 | }) 133 | 134 | it('should be able to get Key during iteration', () => { 135 | const origin = [1, 2, 3, 4, 5] 136 | const set = new Set(origin) 137 | const arr: any[] = [] 138 | 139 | forEach(set, (_, key) => { 140 | arr.push(key) 141 | }) 142 | 143 | expect(arr.sort()).is.deep.equal(origin.sort()) 144 | }) 145 | 146 | it('should be able to break the iteration', () => { 147 | const arr = [1, 2, 3, 4, 5] 148 | const set = new Set(arr) 149 | const ret: number[] = [] 150 | 151 | forEach(set, (val) => { 152 | if (val === 2) { 153 | return false 154 | } 155 | return ret.push(val) 156 | }) 157 | 158 | expect(ret).have.lengthOf(1) 159 | }) 160 | }) 161 | 162 | describe('Map Iteration', () => { 163 | it('should be able to execute successfully', () => { 164 | const kv: any = [['a', 1], ['b', 2], ['c', 3]] 165 | const map = new Map(kv) 166 | 167 | const arr: number[] = [] 168 | 169 | forEach(map, (val) => { 170 | arr.push(val) 171 | }) 172 | 173 | expect(arr.sort((x, y) => x - y)).is.deep.equal([1, 2, 3]) 174 | }) 175 | 176 | it('should be able to get Key during iteration', () => { 177 | const kv: [string, number][] = [['a', 1], ['b', 2], ['c', 3]] 178 | const map = new Map(kv) 179 | 180 | const dest = kv.map(([key]) => key) 181 | const arr: string[] = [] 182 | 183 | forEach(map, (_, key) => { 184 | arr.push(key) 185 | }) 186 | 187 | expect(arr.sort()).is.deep.equal(dest) 188 | }) 189 | 190 | it('should be able to break the iteration', () => { 191 | const kv: [string, number][] = [['a', 1], ['b', 2], ['c', 3]] 192 | const map = new Map(kv) 193 | const dest: number[] = [] 194 | 195 | forEach(map, (val) => { 196 | if (val === 2) { 197 | return false 198 | } 199 | return dest.push(val) 200 | }) 201 | 202 | expect(dest).have.lengthOf(1) 203 | }) 204 | }) 205 | }) 206 | 207 | describe('Func: getType', () => { 208 | const checkList = [ 209 | { src: null, type: 'Null' }, 210 | { src: new Date(), type: 'Date' }, 211 | { src: [] as any[], type: 'Array' }, 212 | { src: /\w/, type: 'RegExp' }, 213 | { src: 'str', type: 'String' }, 214 | { src: 1, type: 'Number' }, 215 | { src: {}, type: 'Object' }, 216 | { src: (): void => void 0, type: 'Function' }, 217 | { 218 | src: function(): void { 219 | return void 0 220 | }, 221 | type: 'Function', 222 | }, 223 | { src: undefined, type: 'Undefined' }, 224 | ] 225 | 226 | checkList.forEach((item) => { 227 | it(`should return type: ${item.type} correctly`, () => { 228 | expect(getType(item.src)).is.equal(item.type) 229 | }) 230 | }) 231 | }) 232 | 233 | describe('Func: clone', () => { 234 | it('should be able to handle Array', () => { 235 | const fixture = [1, 2, 3, [4, 5, 6]] 236 | const cloned = clone(fixture) 237 | 238 | cloned.push(99) 239 | cloned.splice(1, 1) 240 | 241 | expect(cloned).to.deep.equal([1, 3, [4, 5, 6], 99]) 242 | expect(fixture).to.deep.equal([1, 2, 3, [4, 5, 6]]) 243 | }) 244 | 245 | it('should be able to handle Object', () => { 246 | const fixture = { a: 1, b: 2, c: { d: 4, e: { f: 5 } } } 247 | const cloned = clone(fixture) as any 248 | 249 | cloned.a = 'foo' 250 | delete cloned.c 251 | 252 | expect(cloned).to.deep.equal({ a: 'foo', b: 2 }) 253 | expect(fixture).to.deep.equal({ a: 1, b: 2, c: { d: 4, e: { f: 5 } } }) 254 | }) 255 | 256 | it('should be able to handle Boolean', () => { 257 | const fixture = true 258 | expect(clone(fixture)).to.deep.equal(fixture) 259 | }) 260 | 261 | it('should be able to handle Function', () => { 262 | const fixture1 = (): void => void 0 263 | const fixture2 = function(): void { 264 | return void 0 265 | } 266 | 267 | expect(clone(fixture1)).to.equal(fixture1) 268 | expect(clone(fixture2)).to.equal(fixture2) 269 | }) 270 | 271 | it('should be able to handle Regexp', () => { 272 | const fixture = /\w*/ 273 | const cloned = clone(fixture) 274 | 275 | fixture.lastIndex = 10 276 | 277 | expect(cloned).to.deep.equal(/\w*/) 278 | expect(cloned.lastIndex).to.deep.equal(0) 279 | }) 280 | 281 | it('should be able to handle Date', () => { 282 | const superLonelyDate = new Date(2011, 10, 11, 11, 11, 11) 283 | const cloned = clone(superLonelyDate) 284 | 285 | cloned.setMonth(0) 286 | cloned.setDate(1) 287 | cloned.setHours(0) 288 | cloned.setSeconds(0) 289 | cloned.setMinutes(0) 290 | 291 | expect(cloned).to.deep.equal(new Date(2011, 0, 1, 0, 0, 0)) 292 | expect(superLonelyDate).to.deep.equal(new Date(2011, 10, 11, 11, 11, 11)) 293 | }) 294 | 295 | it('should be able to handle Undefined', () => { 296 | const fixture: any = undefined 297 | expect(clone(fixture)).to.equal(fixture) 298 | }) 299 | 300 | it('should be able to handle Null', () => { 301 | const fixture: any = null 302 | expect(clone(fixture)).to.equal(fixture) 303 | }) 304 | 305 | it('should be able to handle Complex Object', () => { 306 | const standardDate = new Date() 307 | 308 | const fixture = { 309 | a: [1, 2, 3], 310 | b: 4, 311 | c: { 312 | a: 5, 313 | b: 6, 314 | c: { 315 | a: 7, 316 | b: [8, 9, 10], 317 | }, 318 | }, 319 | d: standardDate, 320 | f: 'f', 321 | e: false, 322 | g: true, 323 | h: /\w*/, 324 | } 325 | 326 | const sealed = { 327 | a: [1, 2, 3], 328 | b: 4, 329 | c: { 330 | a: 5, 331 | b: 6, 332 | c: { 333 | a: 7, 334 | b: [8, 9, 10], 335 | }, 336 | }, 337 | d: standardDate, 338 | f: 'f', 339 | e: false, 340 | g: true, 341 | h: /\w*/, 342 | } 343 | 344 | const modifed = { 345 | a: [10, 20, 30], 346 | b: 40, 347 | c: { 348 | a: 50, 349 | b: 60, 350 | c: { 351 | a: 70, 352 | b: [80, 90, 100], 353 | }, 354 | }, 355 | d: standardDate, 356 | f: 'f', 357 | e: false, 358 | g: true, 359 | h: /\w*/, 360 | } 361 | 362 | const cloned = clone(fixture) 363 | const mul = (e: number) => e * 10 364 | cloned.a = cloned.a.map(mul) 365 | cloned.b = mul(cloned.b) 366 | cloned.c = { 367 | a: mul(cloned.c.a), 368 | b: mul(cloned.c.b), 369 | c: { 370 | a: mul(cloned.c.c.a), 371 | b: cloned.c.c.b.map(mul), 372 | }, 373 | } 374 | 375 | expect(cloned).to.deep.equal(modifed) 376 | expect(fixture).to.deep.equal(sealed) 377 | }) 378 | }) 379 | 380 | describe('Func: assert', () => { 381 | it('should throw when assert failed [1]', () => { 382 | const check = () => assert(false, (msg: string) => new Error(msg), 'failed') 383 | expect(check).to.throw('failed') 384 | }) 385 | 386 | it('should throw when assert failed [2]', () => { 387 | const check = () => assert(false, 'failed') 388 | expect(check).to.throw('failed') 389 | }) 390 | 391 | it('should not throw when assert successed', () => { 392 | const check = () => assert(true, 'error code path') 393 | expect(check).to.not.throw() 394 | }) 395 | 396 | it('should not execute error function when assert condition is met', () => { 397 | let x = 0 398 | assert(true, () => { 399 | x++ 400 | return new Error('failed') 401 | }) 402 | expect(x).to.equal(0) 403 | }) 404 | }) 405 | 406 | describe('Func: hash', () => { 407 | it('should be able to convert string to hash', () => { 408 | expect(hash('')).to.equal(0) 409 | expect(hash(' ')).to.equal(32) 410 | expect(hash(' ')).to.equal(1024) 411 | }) 412 | }) 413 | 414 | describe('Func: tryCatch', () => { 415 | const mayThrow = (flag: boolean): string => { 416 | const value = String(flag) 417 | if (flag) { 418 | throw new Error(value) 419 | } else { 420 | return value 421 | } 422 | } 423 | 424 | const tryCatchMayThrow = tryCatch(mayThrow) 425 | 426 | it("should return correct Value if the unwrapped function doesn't throw", () => { 427 | const args: [boolean] = [false] 428 | const unwrappedResult = mayThrow(...args) 429 | 430 | expect(tryCatchMayThrow()(...args)).to.deep.equal({ 431 | kind: 'value', 432 | unwrapped: unwrappedResult, 433 | }) 434 | }) 435 | 436 | it('should return correct Exception if the unwrapped function throws', () => { 437 | const args: [boolean] = [true] 438 | 439 | const result = tryCatchMayThrow()(...args) 440 | 441 | expect(result.kind).to.equal('exception') 442 | expect(result.unwrapped).to.be.instanceOf(Error) 443 | expect((result.unwrapped as Error).message).to.equal('true') 444 | }) 445 | 446 | it(`should return correct Value if the unwrapped function doesn't throw 447 | \tand 'doThrow' option is true`, () => { 448 | const args: [boolean] = [false] 449 | const unwrappedResult = mayThrow(...args) 450 | 451 | expect(tryCatchMayThrow({ doThrow: true })(...args)).to.deep.equal({ 452 | kind: 'value', 453 | unwrapped: unwrappedResult, 454 | }) 455 | }) 456 | 457 | it(`should throw correct Error if the unwrapped function throws 458 | \tand 'doThrow' option is true`, () => { 459 | const args: [boolean] = [true] 460 | 461 | expect(() => tryCatchMayThrow({ doThrow: true })(...args)).to.throw('true\nMoreInfo: {}') 462 | }) 463 | 464 | it(`should allow caller to pass in more related error info through options, 465 | \tbeing utilized on exception`, () => { 466 | const args: [boolean] = [true] 467 | 468 | expect(() => tryCatchMayThrow({ doThrow: true, msg: 'hello' })(...args)).to.throw( 469 | 'true\nMoreInfo: {"msg":"hello"}', 470 | ) 471 | 472 | const result = tryCatchMayThrow({ msg: 'world' })(...args) 473 | expect(result.kind).to.equal('exception') 474 | expect((result.unwrapped as Error).message).to.equal('true\nMoreInfo: {"msg":"world"}') 475 | }) 476 | }) 477 | }) 478 | --------------------------------------------------------------------------------