diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index aee29c8e4f..1be94976bb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -6,9 +6,9 @@ name: CI on: # Triggers the workflow on push or pull request events but only for the main branch push: - branches: [ main ] + branches: [ main, develop ] pull_request: - branches: [ main ] + branches: [ main, develop ] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 90e74ba03a..95c692a447 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -9548,7 +9548,7 @@ packages: dev: false file:projects/activity-resources.tgz_e1367da94684b005adf08f025c517b1a: - resolution: {integrity: sha512-WDQxnDVBhDhLcJ0+gZveR64mMJBdC+hx7LFp8RmiL7SvBN5YqO+freVJ/jwhWfeN4RHmCsKsuuAFHlE9PEw/kg==, tarball: file:projects/activity-resources.tgz} + resolution: {integrity: sha512-+QzdZodn7EX1tvSLWZXDEpivgYNYeFbALbr28PO81fXSHSbiwhodZE7J6teILOdZxW3wS/7TPiokvb5zndPCog==, tarball: file:projects/activity-resources.tgz} id: file:projects/activity-resources.tgz name: '@rush-temp/activity-resources' version: 0.0.0 @@ -10222,7 +10222,7 @@ packages: dev: false file:projects/model-telegram.tgz_typescript@4.4.3: - resolution: {integrity: sha512-X4Ao5ADNaxuup/qZv0FeOLSEqXKHVqkqEEdUvHwcMHeHr1SvibSMhlumfTdPGCq70iX2AL2R37FI0DGlx0EnUw==, tarball: file:projects/model-telegram.tgz} + resolution: {integrity: sha512-Pe+NBKGYALA2zA0qUm3yJ7G0c2ZI9WfKIjKDrhtJgMPauf8cgdykpjMyBMgKCKom1jNOzF3RGRQnWh86Q7+MtA==, tarball: file:projects/model-telegram.tgz} id: file:projects/model-telegram.tgz name: '@rush-temp/model-telegram' version: 0.0.0 @@ -10474,22 +10474,28 @@ packages: dev: false file:projects/query.tgz_typescript@4.4.3: - resolution: {integrity: sha512-v0KEf+fc7kni8uGlxU33qQQV9N8fLv85+Yaty64+giXdyla+2CmmEyVyCHtjuhiL786luoAY5Bz6UZjxfCV/vA==, tarball: file:projects/query.tgz} + resolution: {integrity: sha512-b9sIE2RoX3GNvHPqcd3DBUS/wuYYkdCqmebq0AJ8t190Z1n1nyQOQy7xjO7tXgOVd85BMpCg/qVKuG/mv5+bvA==, tarball: file:projects/query.tgz} id: file:projects/query.tgz name: '@rush-temp/query' version: 0.0.0 dependencies: + '@rushstack/heft': 0.41.1 + '@rushstack/heft-jest-plugin': 0.1.36_@rushstack+heft@0.41.1 '@types/heft-jest': 1.0.2 '@typescript-eslint/eslint-plugin': 4.33.0_eslint@7.32.0+typescript@4.4.3 eslint: 7.32.0 eslint-plugin-import: 2.25.3_eslint@7.32.0 eslint-plugin-node: 11.1.0_eslint@7.32.0 eslint-plugin-promise: 4.3.1 + just-clone: 3.2.1 simplytyped: 3.3.0_typescript@4.4.3 transitivePeerDependencies: - '@typescript-eslint/parser' + - bufferutil + - canvas - supports-color - typescript + - utf-8-validate dev: false file:projects/recruit-assets.tgz: @@ -10841,7 +10847,7 @@ packages: dev: false file:projects/telegram-resources.tgz_e1367da94684b005adf08f025c517b1a: - resolution: {integrity: sha512-sbIaaqkc2pyh2dXKHF6S3gj+VZRzBjFngvY1b+RYQ+a0v7bF74kvA+uR7moeRYFf1p8VX3Rqg0kUvC3bSY2lWA==, tarball: file:projects/telegram-resources.tgz} + resolution: {integrity: sha512-c6QScub4eTkKdIFSwTwERbTGMTEC6WPTPQGR6OeuQbdkuZpF5aUtnkx6rRcROlkcIZuq8sWBOJljlRl9ch+ZoQ==, tarball: file:projects/telegram-resources.tgz} id: file:projects/telegram-resources.tgz name: '@rush-temp/telegram-resources' version: 0.0.0 @@ -10871,7 +10877,7 @@ packages: dev: false file:projects/telegram.tgz_typescript@4.4.3: - resolution: {integrity: sha512-tUpx+Ryw6IrNxyKxPL99sEfzDhXAy2Q8YAYrBro6lCNrvH12qLHZ3va5wZHtRT0mnvMg8oM0Q+zgaitWfCDQ8w==, tarball: file:projects/telegram.tgz} + resolution: {integrity: sha512-b0V40ZxHl36k6O/ukvRnBNF3hL/tcSleVNqrrdRMjGBtu9pGQFNTyYpJfUJ9z5keujRNL6Yc9wmxZ3F6JBUolw==, tarball: file:projects/telegram.tgz} id: file:projects/telegram.tgz name: '@rush-temp/telegram' version: 0.0.0 diff --git a/packages/core/src/memdb.ts b/packages/core/src/memdb.ts index b5eb83efc8..046114a26c 100644 --- a/packages/core/src/memdb.ts +++ b/packages/core/src/memdb.ts @@ -17,7 +17,7 @@ import { PlatformError, Severity, Status } from '@anticrm/platform' import clone from 'just-clone' import type { Class, Doc, Ref } from './classes' import core from './component' -import type { Hierarchy } from './hierarchy' +import { Hierarchy } from './hierarchy' import { findProperty, resultSort } from './query' import type { DocumentQuery, FindOptions, FindResult, LookupData, Refs, Storage, TxResult, WithLookup } from './storage' import type { Tx, TxCreateDoc, TxMixin, TxPutBag, TxRemoveDoc, TxUpdateDoc } from './tx' diff --git a/packages/core/src/tx.ts b/packages/core/src/tx.ts index 73535d0927..207151c1ed 100644 --- a/packages/core/src/tx.ts +++ b/packages/core/src/tx.ts @@ -18,7 +18,7 @@ import type { Class, Data, Doc, Domain, Ref, Account, Space, Arr, Mixin, Propert import type { DocumentQuery, FindOptions, FindResult, Storage, WithLookup, TxResult } from './storage' import core from './component' import { generateId } from './utils' -import { _getOperator } from '.' +import { _getOperator } from './operator' /** * @public diff --git a/packages/query/.eslintrc.js b/packages/query/.eslintrc.js index 0dd1690d11..5da5872d4a 100644 --- a/packages/query/.eslintrc.js +++ b/packages/query/.eslintrc.js @@ -4,4 +4,4 @@ module.exports = { tsconfigRootDir: __dirname, project: './tsconfig.json' } -} \ No newline at end of file +} diff --git a/packages/query/package.json b/packages/query/package.json index 1a525ea94b..bac46cec10 100644 --- a/packages/query/package.json +++ b/packages/query/package.json @@ -6,6 +6,7 @@ "license": "EPL-2.0", "scripts": { "build": "heft build", + "test": "heft test", "build:watch": "tsc", "lint:fix": "eslint --fix src" }, @@ -17,10 +18,13 @@ "eslint-plugin-promise":"4", "eslint-plugin-node":"11", "eslint":"^7.32.0", - "simplytyped": "^3.3.0" + "simplytyped": "^3.3.0", + "@rushstack/heft-jest-plugin":"^0.1.15", + "@rushstack/heft":"^0.41.1" }, "dependencies": { "@anticrm/platform": "~0.6.5", - "@anticrm/core": "~0.6.11" + "@anticrm/core": "~0.6.11", + "just-clone": "^3.2.1" } } diff --git a/packages/query/src/__tests__/connection.txt b/packages/query/src/__tests__/connection.ts similarity index 61% rename from packages/query/src/__tests__/connection.txt rename to packages/query/src/__tests__/connection.ts index 279d621538..87cc119013 100644 --- a/packages/query/src/__tests__/connection.txt +++ b/packages/query/src/__tests__/connection.ts @@ -13,12 +13,11 @@ // limitations under the License. // -import type { Tx, Storage, Ref, Doc, Class, DocumentQuery, FindResult } from '@anticrm/core' -import core, { ModelDb, TxDb, Hierarchy, DOMAIN_TX } from '@anticrm/core' +import type { Class, Client, Doc, DocumentQuery, FindOptions, FindResult, Ref, Tx, TxResult } from '@anticrm/core' +import core, { DOMAIN_TX, Hierarchy, ModelDb, TxDb } from '@anticrm/core' +import { genMinModel } from './minmodel' -import { genMinModel } from '@anticrm/core/src/__tests__/minmodel' - -export async function connect (handler: (tx: Tx) => void): Promise<Storage> { +export async function connect (handler: (tx: Tx) => void): Promise<Client> { const txes = genMinModel() const hierarchy = new Hierarchy() @@ -31,20 +30,29 @@ export async function connect (handler: (tx: Tx) => void): Promise<Storage> { await model.tx(tx) } - async function findAll<T extends Doc> (_class: Ref<Class<T>>, query: DocumentQuery<T>): Promise<FindResult<T>> { + async function findAll<T extends Doc> ( + _class: Ref<Class<T>>, + query: DocumentQuery<T>, + options?: FindOptions<T> + ): Promise<FindResult<T>> { const domain = hierarchy.getClass(_class).domain - if (domain === DOMAIN_TX) return await transactions.findAll(_class, query) - return await model.findAll(_class, query) + if (domain === DOMAIN_TX) return await transactions.findAll(_class, query, options) + return await model.findAll(_class, query, options) } return { findAll, - tx: async (tx: Tx): Promise<void> => { + findOne: async (_class, query, options) => (await findAll(_class, query, { ...options, limit: 1 })).shift(), + getHierarchy: () => hierarchy, + getModel: () => model, + tx: async (tx: Tx): Promise<TxResult> => { if (tx.objectSpace === core.space.Model) { hierarchy.tx(tx) } await Promise.all([model.tx(tx), transactions.tx(tx)]) - handler(tx) + // Not required, since handled in client. + // handler(tx) + return {} } } } diff --git a/packages/query/src/__tests__/minmodel.ts b/packages/query/src/__tests__/minmodel.ts new file mode 100644 index 0000000000..1cfa3ab798 --- /dev/null +++ b/packages/query/src/__tests__/minmodel.ts @@ -0,0 +1,113 @@ +// +// Copyright © 2020 Anticrm Platform Contributors. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import type { Account, Arr, Class, Data, Doc, Mixin, Obj, Ref, TxCreateDoc, TxCUD } from '@anticrm/core' +import core, { AttachedDoc, ClassifierKind, DOMAIN_MODEL, DOMAIN_TX, TxFactory } from '@anticrm/core' +import type { IntlString, Plugin } from '@anticrm/platform' +import { plugin } from '@anticrm/platform' + +const txFactory = new TxFactory(core.account.System) + +function createClass (_class: Ref<Class<Obj>>, attributes: Data<Class<Obj>>): TxCreateDoc<Doc> { + return txFactory.createTxCreateDoc(core.class.Class, core.space.Model, attributes, _class) +} + +/** + * @public + */ +export function createDoc<T extends Doc> (_class: Ref<Class<T>>, attributes: Data<T>, id?: Ref<T>, + modifiedBy?: Ref<Account>): TxCreateDoc<Doc> { + const result = txFactory.createTxCreateDoc(_class, core.space.Model, attributes, id) + if (modifiedBy !== undefined) { + result.modifiedBy = modifiedBy + } + return result +} + +/** + * @public + */ +export interface TestMixin extends Doc { + arr: Arr<string> +} + +/** + * @public + */ +export interface AttachedComment extends AttachedDoc { + message: string +} + +/** + * @public + */ +export const test = plugin('test' as Plugin, { + mixin: { + TestMixin: '' as Ref<Mixin<TestMixin>> + }, + class: { + TestComment: '' as Ref<Class<AttachedComment>> + } +}) + +/** + * @public + * Generate minimal model for testing purposes. + * @returns R + */ +export function genMinModel (): TxCUD<Doc>[] { + const txes = [] + // Fill Tx'es with basic model classes. + txes.push(createClass(core.class.Obj, { label: 'Obj' as IntlString, kind: ClassifierKind.CLASS })) + txes.push(createClass(core.class.Doc, { label: 'Doc' as IntlString, extends: core.class.Obj, kind: ClassifierKind.CLASS })) + txes.push(createClass(core.class.AttachedDoc, { label: 'AttachedDoc' as IntlString, extends: core.class.Doc, kind: ClassifierKind.MIXIN })) + txes.push(createClass(core.class.Class, { label: 'Class' as IntlString, extends: core.class.Doc, kind: ClassifierKind.CLASS, domain: DOMAIN_MODEL })) + txes.push(createClass(core.class.Space, { label: 'Space' as IntlString, extends: core.class.Doc, kind: ClassifierKind.CLASS, domain: DOMAIN_MODEL })) + txes.push(createClass(core.class.Account, { label: 'Account' as IntlString, extends: core.class.Doc, kind: ClassifierKind.CLASS, domain: DOMAIN_MODEL })) + + txes.push(createClass(core.class.Tx, { label: 'Tx' as IntlString, extends: core.class.Doc, kind: ClassifierKind.CLASS, domain: DOMAIN_TX })) + txes.push(createClass(core.class.TxCUD, { label: 'TxCUD' as IntlString, extends: core.class.Tx, kind: ClassifierKind.CLASS, domain: DOMAIN_TX })) + txes.push(createClass(core.class.TxCreateDoc, { label: 'TxCreateDoc' as IntlString, extends: core.class.TxCUD, kind: ClassifierKind.CLASS })) + txes.push(createClass(core.class.TxUpdateDoc, { label: 'TxUpdateDoc' as IntlString, extends: core.class.TxCUD, kind: ClassifierKind.CLASS })) + txes.push(createClass(core.class.TxRemoveDoc, { label: 'TxRemoveDoc' as IntlString, extends: core.class.TxCUD, kind: ClassifierKind.CLASS })) + txes.push(createClass(core.class.TxCollectionCUD, { label: 'TxCollectionCUD' as IntlString, extends: core.class.TxCUD, kind: ClassifierKind.CLASS })) + + txes.push(createClass(test.mixin.TestMixin, { label: 'TestMixin' as IntlString, extends: core.class.Doc, kind: ClassifierKind.MIXIN })) + + txes.push(createClass(test.class.TestComment, { label: 'TestComment' as IntlString, extends: core.class.AttachedDoc, kind: ClassifierKind.CLASS })) + + const u1 = 'User1' as Ref<Account> + const u2 = 'User2' as Ref<Account> + txes.push( + createDoc(core.class.Account, { email: 'user1@site.com' }, u1), + createDoc(core.class.Account, { email: 'user2@site.com' }, u2), + createDoc(core.class.Space, { + name: 'Sp1', + description: '', + private: false, + members: [u1, u2] + }) + ) + + txes.push( + createDoc(core.class.Space, { + name: 'Sp2', + description: '', + private: false, + members: [u1] + }) + ) + return txes +} diff --git a/packages/query/src/__tests__/query.test.ts b/packages/query/src/__tests__/query.test.ts new file mode 100644 index 0000000000..4e9a0cf67e --- /dev/null +++ b/packages/query/src/__tests__/query.test.ts @@ -0,0 +1,379 @@ +// +// Copyright © 2021 Anticrm Platform Contributors. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import core, { createClient, Doc, SortingOrder, Space, Tx, TxCreateDoc, TxOperations } from '@anticrm/core' +import { genMinModel } from './minmodel' +import { LiveQuery } from '..' +import { connect } from './connection' + +interface Channel extends Space { + x: number +} + +async function getClient (): Promise<{liveQuery: LiveQuery, factory: TxOperations}> { + const storage = await createClient(connect) + const liveQuery = new LiveQuery(storage) + storage.notify = (tx: Tx) => { + liveQuery.tx(tx).catch(err => console.log(err)) + } + return { liveQuery, factory: new TxOperations(storage, core.account.System) } +} + +describe('query', () => { + it('findAll', async () => { + const { liveQuery } = await getClient() + const result = await liveQuery.findAll<Space>(core.class.Space, {}) + expect(result).toHaveLength(2) + }) + + it('query with param', async () => { + const { liveQuery } = await getClient() + + let expectedLength = 0 + const txes = genMinModel() + for (let i = 0; i < txes.length; i++) { + if (liveQuery.getHierarchy().isDerived((txes[i] as TxCreateDoc<Doc>).objectClass, core.class.Space)) { + expectedLength++ + } + } + + await new Promise((resolve) => { + liveQuery.query<Space>(core.class.Space, { private: false }, (result) => { + expect(result).toHaveLength(expectedLength) + resolve(null) + }) + }) + }) + + it('query should be live', async () => { + const { liveQuery, factory } = await getClient() + + let expectedLength = 0 + const txes = genMinModel() + for (let i = 0; i < txes.length; i++) { + if (liveQuery.getHierarchy().isDerived((txes[i] as TxCreateDoc<Doc>).objectClass, core.class.Space)) { + expectedLength++ + } + } + + let attempt = 0 + const pp = new Promise((resolve) => { + liveQuery.query<Space>(core.class.Space, { private: false }, (result) => { + console.log('query result attempt', result, attempt) + expect(result).toHaveLength(expectedLength + attempt) + if (attempt > 0) { + expect((result[expectedLength + attempt - 1] as any).x).toBe(attempt) + } + if (attempt++ === 3) { + // check underlying storage received all data. + liveQuery + .findAll<Space>(core.class.Space, { private: false }) + .then((result) => { + expect(result).toHaveLength(expectedLength + attempt - 1) + resolve(null) + }) + .catch((err) => expect(err).toBeUndefined()) + } + }) + }) + + await factory.createDoc(core.class.Account, core.space.Model, { + email: 'user1@site.com' + }) + await factory.createDoc<Channel>(core.class.Space, core.space.Model, { + private: true, + name: '#0', + description: '', + members: [], + x: 0 + }) + await factory.createDoc<Channel>(core.class.Space, core.space.Model, { + private: false, + name: '#1', + description: '', + members: [], + x: 1 + }) + await factory.createDoc<Channel>(core.class.Space, core.space.Model, { + private: false, + name: '#2', + description: '', + members: [], + x: 2 + }) + await factory.createDoc<Channel>(core.class.Space, core.space.Model, { + private: false, + name: '#3', + description: '', + members: [], + x: 3 + }) + await pp + }) + + it('unsubscribe query', async () => { + const { liveQuery, factory } = await getClient() + + let expectedLength = 0 + const txes = genMinModel() + for (let i = 0; i < txes.length; i++) { + if (liveQuery.getHierarchy().isDerived((txes[i] as TxCreateDoc<Doc>).objectClass, core.class.Space)) { + expectedLength++ + } + } + + const unsubscribe = liveQuery.query<Space>(core.class.Space, { private: false }, (result) => { + expect(result).toHaveLength(expectedLength) + }) + + unsubscribe() + + await factory.createDoc(core.class.Space, core.space.Model, { + private: false, + name: '#1', + description: '', + members: [] + }) + await factory.createDoc(core.class.Space, core.space.Model, { + private: false, + name: '#2', + description: '', + members: [] + }) + await factory.createDoc(core.class.Space, core.space.Model, { + private: false, + name: '#3', + description: '', + members: [] + }) + }) + + it('query against core client', async () => { + const { liveQuery, factory } = await getClient() + + const expectedLength = 2 + let attempt = 0 + const pp = new Promise((resolve) => { + liveQuery.query<Space>(core.class.Space, { private: false }, (result) => { + expect(result).toHaveLength(expectedLength + attempt) + if (attempt > 0) { + expect((result[expectedLength + attempt - 1] as any).x).toBe(attempt) + } + if (attempt++ === 1) resolve(null) + }) + }) + + await factory.createDoc<Channel>(core.class.Space, core.space.Model, { + x: 1, + private: false, + name: '#1', + description: '', + members: [] + }) + await factory.createDoc<Channel>(core.class.Space, core.space.Model, { + x: 2, + private: false, + name: '#2', + description: '', + members: [] + }) + await factory.createDoc<Channel>(core.class.Space, core.space.Model, { + x: 3, + private: false, + name: '#3', + description: '', + members: [] + }) + await pp + }) + + it('limit and sorting', async () => { + const { liveQuery, factory } = await getClient() + + const limit = 1 + let attempt = -1 + let doneCount = 0 + + const pp1 = new Promise((resolve) => { + liveQuery.query<Space>( + core.class.Space, + { private: true }, + (result) => { + if (attempt === 0 && result.length > 0) { + expect(result.length).toEqual(limit) + expect(result[0].name).toMatch('0') + } + if (attempt === 0) doneCount++ + if (doneCount === 2) resolve(null) + }, + { limit: limit, sort: { name: SortingOrder.Ascending } } + ) + }) + + const pp2 = new Promise((resolve) => { + liveQuery.query<Space>( + core.class.Space, + { private: true }, + (result) => { + if (attempt > 0 && result.length > 0) { + expect(result.length).toEqual(limit) + expect(result[0].name).toMatch(attempt.toString()) + } + if (attempt === 9) doneCount++ + if (doneCount === 2) resolve(null) + }, + { limit: limit, sort: { name: SortingOrder.Descending } } + ) + }) + + for (let i = 0; i < 10; i++) { + attempt = i + await factory.createDoc(core.class.Space, core.space.Model, { + private: true, + name: i.toString(), + description: '', + members: [] + }) + } + await Promise.all([pp1, pp2]) + }) + + it('remove', async () => { + const { liveQuery, factory } = await getClient() + + const expectedLength = 2 + let attempt = 0 + const pp = new Promise((resolve) => { + liveQuery.query<Space>(core.class.Space, { private: false }, (result) => { + expect(result).toHaveLength(expectedLength - attempt) + if (attempt++ === expectedLength) resolve(null) + }) + }) + + const spaces = await liveQuery.findAll(core.class.Space, {}) + for (const space of spaces) { + await factory.removeDoc(space._class, space.space, space._id) + } + await pp + }) + + it('remove with limit', async () => { + const { liveQuery, factory } = await getClient() + + const expectedLength = 2 + let attempt = 0 + const pp = new Promise((resolve) => { + liveQuery.query<Space>( + core.class.Space, + { private: false }, + (result) => { + expect(result).toHaveLength(attempt++ === expectedLength ? 0 : 1) + if (attempt === expectedLength) resolve(null) + }, + { limit: 1 } + ) + }) + + const spaces = await liveQuery.findAll(core.class.Space, {}) + for (const space of spaces) { + await factory.removeDoc(space._class, space.space, space._id) + } + await pp + }) + + it('update', async () => { + const { liveQuery, factory } = await getClient() + + const spaces = await liveQuery.findAll(core.class.Space, {}) + let attempt = 0 + const pp = new Promise((resolve) => { + liveQuery.query<Space>( + core.class.Space, + { private: false }, + (result) => { + if (attempt > 0) { + expect(result[attempt - 1].name === attempt.toString()) + expect(result[attempt - 1].members.length === 1) + if (attempt === spaces.length) resolve(null) + } + }, + { sort: { private: SortingOrder.Ascending } } + ) + }) + + for (const space of spaces) { + attempt++ + await factory.updateDoc(space._class, space.space, space._id, { + name: attempt.toString(), + $push: { members: core.account.System } + }) + } + await pp + }) + + it('update with no match query', async () => { + const { liveQuery, factory } = await getClient() + + const spaces = await liveQuery.findAll(core.class.Space, {}) + let attempt = 0 + const pp = new Promise((resolve) => { + liveQuery.query<Space>( + core.class.Space, + { private: false }, + (result) => { + if (attempt > 0) { + expect(result.length === spaces.length - attempt) + if (attempt === spaces.length) resolve(null) + } + }, + { sort: { private: SortingOrder.Ascending } } + ) + }) + + for (const space of spaces) { + attempt++ + await factory.updateDoc(space._class, space.space, space._id, { + private: true + }) + } + await pp + }) + + // it('update with over limit', async () => { + // const { liveQuery, factory } = await getClient() + + // const spaces = await liveQuery.findAll(core.class.Space, {}) + // let attempt = 0 + // const pp = new Promise((resolve) => { + // liveQuery.query<Space>( + // core.class.Space, + // {}, + // (result) => { + // expect(result[0].name).toEqual(`Sp${++attempt}`) + // if (attempt === spaces.length + 1) resolve(null) + // }, + // { sort: { name: SortingOrder.Ascending }, limit: 1 } + // ) + // }) + + // for (let index = 0; index < spaces.length; index++) { + // const space = spaces[index] + // await factory.updateDoc(space._class, space.space, space._id, { + // name: `Sp${index + spaces.length + 1}` + // }) + // } + // await pp + // }) +}) diff --git a/packages/query/src/__tests__/query.test.txt b/packages/query/src/__tests__/query.test.txt deleted file mode 100644 index c5fe654020..0000000000 --- a/packages/query/src/__tests__/query.test.txt +++ /dev/null @@ -1,263 +0,0 @@ -// -// Copyright © 2021 Anticrm Platform Contributors. -// -// Licensed under the Eclipse Public License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. You may -// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import type { Class, Client, Doc, DocumentQuery, FindOptions, FindResult, Obj, Ref, Space, Tx, TxCreateDoc } from '@anticrm/core' -import core, { createClient, DOMAIN_TX, Hierarchy, ModelDb, TxDb, withOperations, SortingOrder } from '@anticrm/core' -import { genMinModel as getModel } from '@anticrm/core/src/__tests__/minmodel' -import { LiveQuery } from '..' -import { connect } from './connection' - -interface Channel extends Space { - x: number -} -describe('query', () => { - it('findAll', async () => { - const client = await getClient() - const query = withOperations(core.account.System, new LiveQuery(client)) - const result = await query.findAll<Space>(core.class.Space, {}) - expect(result).toHaveLength(2) - }) - - it('query with param', async (done) => { - const storage = await getClient() - - let expectedLength = 0 - const txes = await getModel() - for (let i = 0; i < txes.length; i++) { - if (storage.getHierarchy().isDerived((txes[i] as TxCreateDoc<Doc>).objectClass, core.class.Space)) { - expectedLength++ - } - } - - const query = new LiveQuery(storage) - query.query<Space>(core.class.Space, { private: false }, (result) => { - expect(result).toHaveLength(expectedLength) - done() - }) - }) - - it('query should be live', async (done) => { - const storage = await getClient() - - let expectedLength = 0 - const txes = await getModel() - for (let i = 0; i < txes.length; i++) { - if (storage.getHierarchy().isDerived((txes[i] as TxCreateDoc<Doc>).objectClass, core.class.Space)) { - expectedLength++ - } - } - - let attempt = 0 - const query = withOperations(core.account.System, new LiveQuery(storage)) - query.query<Space>(core.class.Space, { private: false }, (result) => { - expect(result).toHaveLength(expectedLength + attempt) - if (attempt > 0) { - expect((result[expectedLength + attempt - 1] as any).x).toBe(attempt) - } - if (attempt++ === 3) { - // check underlying storage received all data. - storage - .findAll<Space>(core.class.Space, { private: false }) - .then((result) => { - expect(result).toHaveLength(expectedLength + attempt - 1) - done() - }) - .catch((err) => expect(err).toBeUndefined()) - } - }) - - await query.createDoc<Channel>(core.class.Space, core.space.Model, { - private: false, - name: '#1', - description: '', - members: [], - x: 1 - }) - await query.createDoc<Channel>(core.class.Space, core.space.Model, { - private: false, - name: '#2', - description: '', - members: [], - x: 2 - }) - await query.createDoc<Channel>(core.class.Space, core.space.Model, { - private: false, - name: '#3', - description: '', - members: [], - x: 3 - }) - }) - - it('unsubscribe query', async () => { - const storage = await getClient() - - let expectedLength = 0 - const txes = await getModel() - for (let i = 0; i < txes.length; i++) { - if (storage.getHierarchy().isDerived((txes[i] as TxCreateDoc<Doc>).objectClass, core.class.Space)) { - expectedLength++ - } - } - - const query = withOperations(core.account.System, new LiveQuery(storage)) - const unsubscribe = query.query<Space>(core.class.Space, { private: false }, (result) => { - expect(result).toHaveLength(expectedLength) - }) - - unsubscribe() - - await query.createDoc(core.class.Space, core.space.Model, { - private: false, - name: '#1', - description: '', - members: [] - }) - await query.createDoc(core.class.Space, core.space.Model, { - private: false, - name: '#2', - description: '', - members: [] - }) - await query.createDoc(core.class.Space, core.space.Model, { - private: false, - name: '#3', - description: '', - members: [] - }) - }) - - it('query against core client', async (done) => { - const client = await createClient(connect) - - const expectedLength = 2 - let attempt = 0 - const query = withOperations(core.account.System, new LiveQuery(client)) - query.query<Space>(core.class.Space, { private: false }, (result) => { - expect(result).toHaveLength(expectedLength + attempt) - if (attempt > 0) { - expect((result[expectedLength + attempt - 1] as any).x).toBe(attempt) - } - if (attempt++ === 1) done() - }) - - await query.createDoc<Channel>(core.class.Space, core.space.Model, { - x: 1, - private: false, - name: '#1', - description: '', - members: [] - }) - await query.createDoc<Channel>(core.class.Space, core.space.Model, { - x: 2, - private: false, - name: '#2', - description: '', - members: [] - }) - await query.createDoc<Channel>(core.class.Space, core.space.Model, { - x: 3, - private: false, - name: '#3', - description: '', - members: [] - }) - }) - - it('limit and sorting', async (done) => { - const storage = await getClient() - - const limit = 1 - let attempt = 0 - let doneCount = 0 - - const query = withOperations(core.account.System, new LiveQuery(storage)) - query.query<Space>(core.class.Space, { private: true }, (result) => { - if (attempt > 0 && result.length > 0) { - expect(result.length).toEqual(limit) - expect(result[0].name).toMatch('0') - } - if (attempt === 1) doneCount++ - if (doneCount === 2) done() - }, { limit: limit, sort: { name: SortingOrder.Ascending } }) - - query.query<Space>(core.class.Space, { private: true }, (result) => { - if (attempt > 0 && result.length > 0) { - expect(result.length).toEqual(limit) - expect(result[0].name).toMatch(attempt.toString()) - } - if (attempt === 10) doneCount++ - if (doneCount === 2) done() - }, { limit: limit, sort: { name: SortingOrder.Descending } }) - - for (let i = 0; i < 10; i++) { - attempt++ - await query.createDoc(core.class.Space, core.space.Model, { - private: true, - name: i.toString(), - description: '', - members: [] - }) - } - }) - it('remove', async (done) => { - const client = await createClient(connect) - - const expectedLength = 2 - let attempt = 0 - const query = withOperations(core.account.System, new LiveQuery(client)) - query.query<Space>(core.class.Space, { private: false }, (result) => { - expect(result).toHaveLength(expectedLength - attempt) - if (attempt++ === expectedLength) done() - }) - - const spaces = await query.findAll(core.class.Space, {}) - for (const space of spaces) { - await query.removeDoc(space._class, space.space, space._id) - } - }) -}) - -class ClientImpl implements Client { - constructor ( - private readonly hierarchy: Hierarchy, - private readonly model: ModelDb, - private readonly transactions: TxDb - ) {} - - async tx (tx: Tx): Promise<void> { - await Promise.all([this.model.tx(tx), this.transactions.tx(tx)]) - } - - getHierarchy(): Hierarchy { - return this.hierarchy - } - - async findAll<T extends Doc>(_class: Ref<Class<T>>, query: DocumentQuery<T>, options?: FindOptions<T>): Promise<FindResult<T>> { - const domain = this.hierarchy.getClass(_class).domain - if (domain === DOMAIN_TX) return await this.transactions.findAll(_class, query, options) - return await this.model.findAll(_class, query, options) - } -} - -async function getClient (): Promise<Client> { - const hierarchy = new Hierarchy() - const transactions = new TxDb(hierarchy) - const model = new ModelDb(hierarchy) - const txes = await getModel() - for (const tx of txes) hierarchy.tx(tx) - for (const tx of txes) await model.tx(tx) - return new ClientImpl(hierarchy, model, transactions) -} diff --git a/packages/query/src/index.ts b/packages/query/src/index.ts index 00162ba009..f1543a69ab 100644 --- a/packages/query/src/index.ts +++ b/packages/query/src/index.ts @@ -13,12 +13,38 @@ // limitations under the License. // -import { - Ref, Class, Doc, Tx, DocumentQuery, TxCreateDoc, TxRemoveDoc, Client, - FindOptions, TxUpdateDoc, _getOperator, TxProcessor, resultSort, SortingQuery, - FindResult, Hierarchy, Refs, WithLookup, LookupData, TxMixin, TxPutBag, ModelDb, TxBulkWrite, TxResult +import core, { + Ref, + Class, + Doc, + Tx, + DocumentQuery, + TxCreateDoc, + TxRemoveDoc, + Client, + FindOptions, + TxUpdateDoc, + _getOperator, + TxProcessor, + resultSort, + SortingQuery, + FindResult, + Hierarchy, + Refs, + WithLookup, + LookupData, + TxMixin, + TxPutBag, + ModelDb, + TxBulkWrite, + TxResult, + TxCollectionCUD, + AttachedDoc, + findProperty } from '@anticrm/core' +import clone from 'just-clone' + interface Query { _class: Ref<Class<Doc>> query: DocumentQuery<Doc> @@ -51,24 +77,40 @@ export class LiveQuery extends TxProcessor implements Client { if (!this.getHierarchy().isDerived(doc._class, q._class)) { return false } - for (const key in q.query) { - const value = (q.query as any)[key] - if ((doc as any)[key] !== value) { + const query = q.query + for (const key in query) { + if (key === '_id' && ((query._id as any)?.$like === undefined || query._id === undefined)) continue + const value = (query as any)[key] + const result = findProperty([doc], key, value) + if (result.length === 0) { return false } } return true } - async findAll<T extends Doc>(_class: Ref<Class<T>>, query: DocumentQuery<T>, options?: FindOptions<T>): Promise<FindResult<T>> { + async findAll<T extends Doc>( + _class: Ref<Class<T>>, + query: DocumentQuery<T>, + options?: FindOptions<T> + ): Promise<FindResult<T>> { return await this.client.findAll(_class, query, options) } - async findOne<T extends Doc>(_class: Ref<Class<T>>, query: DocumentQuery<T>, options?: FindOptions<T>): Promise<WithLookup<T> | undefined> { + async findOne<T extends Doc>( + _class: Ref<Class<T>>, + query: DocumentQuery<T>, + options?: FindOptions<T> + ): Promise<WithLookup<T> | undefined> { return (await this.findAll(_class, query, options))[0] } - query<T extends Doc>(_class: Ref<Class<T>>, query: DocumentQuery<T>, callback: (result: T[]) => void, options?: FindOptions<T>): () => void { + query<T extends Doc>( + _class: Ref<Class<T>>, + query: DocumentQuery<T>, + callback: (result: T[]) => void, + options?: FindOptions<T> + ): () => void { const result = this.client.findAll(_class, query, options) const q: Query = { _class, @@ -96,7 +138,7 @@ export class LiveQuery extends TxProcessor implements Client { if (q.result instanceof Promise) { q.result = await q.result } - const updatedDoc = q.result.find(p => p._id === tx.objectId) + const updatedDoc = q.result.find((p) => p._id === tx.objectId) if (updatedDoc !== undefined) { const doc = updatedDoc as any let bag = doc[tx.bag] @@ -114,80 +156,169 @@ export class LiveQuery extends TxProcessor implements Client { throw new Error('Method not implemented.') } - protected async txUpdateDoc (tx: TxUpdateDoc<Doc>): Promise<TxResult> { - console.log(`updating ${this.queries.length} queries`) + protected async txCollectionCUD (tx: TxCollectionCUD<Doc, AttachedDoc>): Promise<TxResult> { for (const q of this.queries) { - if (q.result instanceof Promise) { - q.result = await q.result + if (this.client.getHierarchy().isDerived(q._class, core.class.Tx)) { + // handle add since Txes are immutable + await this.handleDocAdd(q, tx) + continue } - const updatedDoc = q.result.find(p => p._id === tx.objectId) - if (updatedDoc !== undefined) { - await this.__updateDoc(q, updatedDoc, tx) - this.sort(q, tx) - await this.callback(updatedDoc, q) + + if (tx.tx._class === core.class.TxCreateDoc) { + const createTx = tx.tx as TxCreateDoc<AttachedDoc> + const d: TxCreateDoc<AttachedDoc> = { + ...createTx, + attributes: { + ...createTx.attributes, + attachedTo: tx.objectId, + attachedToClass: tx.objectClass, + collection: tx.collection + } + } + await this.handleDocAdd(q, TxProcessor.createDoc2Doc(d)) + } else if (tx.tx._class === core.class.TxUpdateDoc) { + await this.handleDocUpdate(q, tx.tx as unknown as TxUpdateDoc<Doc>) + } else if (tx.tx._class === core.class.TxRemoveDoc) { + await this.handleDocRemove(q, tx.tx as unknown as TxRemoveDoc<Doc>) } } return {} } + protected async txUpdateDoc (tx: TxUpdateDoc<Doc>): Promise<TxResult> { + for (const q of this.queries) { + if (this.client.getHierarchy().isDerived(q._class, core.class.Tx)) { + // handle add since Txes are immutable + await this.handleDocAdd(q, tx) + continue + } + await this.handleDocUpdate(q, tx) + } + return {} + } + + private async handleDocUpdate (q: Query, tx: TxUpdateDoc<Doc>): Promise<void> { + if (q.result instanceof Promise) { + q.result = await q.result + } + const pos = q.result.findIndex((p) => p._id === tx.objectId) + if (pos !== -1) { + const updatedDoc = q.result[pos] + await this.__updateDoc(q, updatedDoc, tx) + if (!this.match(q, updatedDoc)) { + q.result.splice(pos, 1) + } else { + q.result[pos] = updatedDoc + } + this.sort(q, tx) + await this.callback(updatedDoc, q) + } else if (this.matchQuery(q, tx)) { + await this.refresh(q) + } + } + + private async refresh (q: Query): Promise<void> { + const res = await this.client.findAll(q._class, q.query, q.options) + q.result = res + q.callback(clone(res)) + } + + // Check if query is partially matched. + private matchQuery (q: Query, tx: TxUpdateDoc<Doc>): boolean { + if (!this.client.getHierarchy().isDerived(q._class, tx.objectClass)) { + return false + } + + for (const key in q.query) { + const value = (q.query as any)[key] + const res = findProperty([tx.operations as unknown as Doc], key, value) + if (res.length === 1) { + return true + } + } + return false + } + private async lookup (doc: Doc, lookup: Refs<Doc>): Promise<void> { const result: LookupData<Doc> = {} for (const key in lookup) { const _class = (lookup as any)[key] as Ref<Class<Doc>> const _id = (doc as any)[key] as Ref<Doc> - (result as any)[key] = (await this.client.findAll(_class, { _id }))[0] + ;(result as any)[key] = (await this.client.findAll(_class, { _id }))[0] } - (doc as WithLookup<Doc>).$lookup = result + ;(doc as WithLookup<Doc>).$lookup = result } protected async txCreateDoc (tx: TxCreateDoc<Doc>): Promise<TxResult> { - console.log('query tx', tx) + const docTx = TxProcessor.createDoc2Doc(tx) for (const q of this.queries) { - const doc = TxProcessor.createDoc2Doc(tx) - if (this.match(q, doc)) { - if (q.result instanceof Promise) { - q.result = await q.result - } - - if (q.options?.lookup !== undefined) await this.lookup(doc, q.options.lookup) - - q.result.push(doc) - - if (q.options?.sort !== undefined) resultSort(q.result, q.options?.sort) - - if (q.options?.limit !== undefined && q.result.length > q.options.limit) { - if (q.result.pop()?._id !== doc._id) { - q.callback(q.result) - } - } else { - q.callback(q.result) - } - } + const doc = this.client.getHierarchy().isDerived(q._class, core.class.Tx) ? tx : docTx + await this.handleDocAdd(q, doc) } return {} } + private async handleDocAdd (q: Query, doc: Doc): Promise<void> { + if (this.match(q, doc)) { + if (q.result instanceof Promise) { + q.result = await q.result + } + + if (q.options?.lookup !== undefined) { + await this.lookup(doc, q.options.lookup) + } + + q.result.push(doc) + + if (q.options?.sort !== undefined) { + resultSort(q.result, q.options?.sort) + } + + if (q.options?.limit !== undefined && q.result.length > q.options.limit) { + if (q.result.pop()?._id !== doc._id) { + q.callback(clone(q.result)) + } + } else { + q.callback(clone(q.result)) + } + } + } + protected async txRemoveDoc (tx: TxRemoveDoc<Doc>): Promise<TxResult> { for (const q of this.queries) { - if (q.result instanceof Promise) { - q.result = await q.result - } - const index = q.result.findIndex(p => p._id === tx.objectId) - if (index > -1) { - q.result.splice(index, 1) - q.callback(q.result) + if (this.client.getHierarchy().isDerived(q._class, core.class.Tx)) { + // handle add since Txes are immutable + await this.handleDocAdd(q, tx) + continue } + await this.handleDocRemove(q, tx) } return {} } + private async handleDocRemove (q: Query, tx: TxRemoveDoc<Doc>): Promise<void> { + if (q.result instanceof Promise) { + q.result = await q.result + } + const index = q.result.findIndex((p) => p._id === tx.objectId) + if ( + q.options?.limit !== undefined && + q.options.limit === q.result.length && + this.client.getHierarchy().isDerived(q._class, tx.objectClass) + ) { + return await this.refresh(q) + } + if (index > -1) { + q.result.splice(index, 1) + q.callback(clone(q.result)) + } + } + protected override async txBulkWrite (tx: TxBulkWrite): Promise<TxResult> { - console.log('query: bulk') return await super.txBulkWrite(tx) } async tx (tx: Tx): Promise<TxResult> { - console.log('query tx', tx) return await super.tx(tx) } @@ -199,11 +330,11 @@ export class LiveQuery extends TxProcessor implements Client { const operator = _getOperator(key) operator(updatedDoc, ops[key]) } else { - (updatedDoc as any)[key] = ops[key] + ;(updatedDoc as any)[key] = ops[key] if (q.options !== undefined) { const lookup = (q.options.lookup as any)?.[key] if (lookup !== undefined) { - (updatedDoc.$lookup as any)[key] = await this.client.findOne(lookup, { _id: ops[key] }) + ;(updatedDoc.$lookup as any)[key] = await this.client.findOne(lookup, { _id: ops[key] }) } } } @@ -240,14 +371,11 @@ export class LiveQuery extends TxProcessor implements Client { if (q.options?.limit !== undefined && q.result.length > q.options.limit) { if (q.result[q.options?.limit]._id === updatedDoc._id) { - const res = await this.findAll(q._class, q.query, q.options) - q.result = res - q.callback(res) - return + return await this.refresh(q) } if (q.result.pop()?._id !== updatedDoc._id) q.callback(q.result) } else { - q.callback(q.result) + q.callback(clone(q.result)) } } } diff --git a/packages/query/tsconfig.json b/packages/query/tsconfig.json index aeb0517b13..137ecf0617 100644 --- a/packages/query/tsconfig.json +++ b/packages/query/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "rootDir": "./src", - "outDir": "./lib" + "outDir": "./lib", + "esModuleInterop": true } -} \ No newline at end of file +}