Lookup live query (#883)

Signed-off-by: Denis Bykhov <80476319+BykhovDenis@users.noreply.github.com>
This commit is contained in:
Denis Bykhov 2022-01-31 15:06:30 +06:00 committed by GitHub
parent 60a3793ae7
commit a7c3999515
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 1512 additions and 463 deletions

View File

@ -11983,7 +11983,7 @@ packages:
dev: false
file:projects/front.tgz:
resolution: {integrity: sha512-RXsa4jlZB6UdPjSIAHmf07BEcWlH6N26QnAVFQ3QL5VdqLi73ohsPQV9seKz36c5jGsA//Z0BS9QYVCETuHdgA==, tarball: file:projects/front.tgz}
resolution: {integrity: sha512-Nto3Qer5qe5YaIELZhEaJugu6x/1SbThjaKd0Yyc5BCo6UfjeyvQyKz0iHmTtIVfbBfzQSqj+MMBGL0k6zW3dg==, tarball: file:projects/front.tgz}
name: '@rush-temp/front'
version: 0.0.0
dependencies:
@ -11998,6 +11998,7 @@ packages:
'@typescript-eslint/eslint-plugin': 5.7.0_c25e8c1f4f4f7aaed27aa6f9ce042237
'@typescript-eslint/parser': 5.7.0_eslint@7.32.0+typescript@4.5.4
cors: 2.8.5
cross-env: 7.0.3
esbuild: 0.12.29
eslint: 7.32.0
eslint-config-standard-with-typescript: 21.0.1_ce2fa0c4dfa1c256100cababd749a13a
@ -13383,7 +13384,7 @@ packages:
dev: false
file:projects/server.tgz:
resolution: {integrity: sha512-5U67EYHkOoUqolvKMKSN1++52yfIzKPOIG2pmnbra2Ffcg9O/7UCat9eBIUx8U+n1998DnVm+c9H3yHadlOdsw==, tarball: file:projects/server.tgz}
resolution: {integrity: sha512-b5qcL1erYSk29hFzWoJhG7GbtLdoTbEUHbScHKs7lLsQvftiiZmQRmuVcP8TVjiR3yTuMRJDt4ghH2d2UF9KIw==, tarball: file:projects/server.tgz}
name: '@rush-temp/server'
version: 0.0.0
dependencies:
@ -13394,6 +13395,7 @@ packages:
'@types/ws': 7.4.7
'@typescript-eslint/eslint-plugin': 5.7.0_c25e8c1f4f4f7aaed27aa6f9ce042237
'@typescript-eslint/parser': 5.7.0_eslint@7.32.0+typescript@4.5.4
cross-env: 7.0.3
elastic-apm-node: 3.26.0
esbuild: 0.12.29
eslint: 7.32.0

View File

@ -1,4 +1,4 @@
import contact, { Employee, EmployeeAccount, Person } from '@anticrm/contact'
import contact, { Channel, Employee, EmployeeAccount, Person } from '@anticrm/contact'
import core, {
AttachedData,
Data,
@ -225,7 +225,6 @@ async function genCandidate (
const candidate: Data<Person> = {
name: fName + ',' + lName,
city: faker.address.city(),
channels: [{ provider: contact.channelProvider.Email, value: faker.internet.email(fName, lName) }],
avatar: imgId
}
@ -238,10 +237,19 @@ async function genCandidate (
const candidateId = (options.random ? `candidate-${generateId()}-${i}` : `candidate-genid-${i}`) as Ref<Candidate>
candidates.push(candidateId)
const channelId = (options.random ? `channel-${generateId()}-${i}` : `channel-genid-${i}`) as Ref<Channel>
// Update or create candidate
await ctx.with('find-update', {}, async () => {
await findOrUpdate(ctx, client, recruit.space.CandidatesPublic, contact.class.Person, candidateId, candidate)
await findOrUpdateAttached(ctx, client, recruit.space.CandidatesPublic, contact.class.Channel, channelId, {
provider: contact.channelProvider.Email,
value: faker.internet.email(fName, lName)
}, {
attachedTo: candidateId,
attachedClass: contact.class.Person,
collection: 'channels'
})
await client.updateMixin(candidateId, contact.class.Person, recruit.space.CandidatesPublic, recruit.mixin.Candidate, candidateMixin)
})

View File

@ -16,7 +16,7 @@
import attachment, { Attachment } from '@anticrm/attachment'
import chunter, { Comment } from '@anticrm/chunter'
import contact, { ChannelProvider, EmployeeAccount, Person } from '@anticrm/contact'
import contact, { Channel, ChannelProvider, EmployeeAccount, Person } from '@anticrm/contact'
import core, { AttachedData, AttachedDoc, Class, Data, Doc, DocumentUpdate, Ref, SortingOrder, Space, TxOperations, TxResult, MixinData } from '@anticrm/core'
import recruit from '@anticrm/model-recruit'
import { Applicant, Candidate, Vacancy } from '@anticrm/recruit'
@ -261,8 +261,7 @@ async function createCandidate (_name: string, pos: number, len: number, c: any,
const data: Data<Person> = {
name: names.slice(1).join(' ') + ',' + names[0],
city: get(c, _.city) ?? '',
channels: []
city: get(c, _.city) ?? ''
}
const candidateData: MixinData<Person, Candidate> = {
@ -277,8 +276,10 @@ async function createCandidate (_name: string, pos: number, len: number, c: any,
].filter(p => p !== undefined && p.trim().length > 0).filter(onlyUniq).join('/')
}
pushChannel(c, data, _.email, contact.channelProvider.Email)
pushChannel(c, data, _.phone, contact.channelProvider.Phone)
const channels: AttachedData<Channel>[] = []
pushChannel(c, channels, _.email, contact.channelProvider.Email)
pushChannel(c, channels, _.phone, contact.channelProvider.Phone)
const commentData: string[] = []
@ -291,17 +292,23 @@ async function createCandidate (_name: string, pos: number, len: number, c: any,
addComment(commentData, c, _.comment)
if (telegram !== undefined) {
data.channels.push({ provider: contact.channelProvider.Telegram, value: telegram })
channels.push({ provider: contact.channelProvider.Telegram, value: telegram })
}
if (linkedin !== undefined) {
data.channels.push({ provider: contact.channelProvider.LinkedIn, value: linkedin })
channels.push({ provider: contact.channelProvider.LinkedIn, value: linkedin })
}
if (github !== undefined) {
data.channels.push({ provider: contact.channelProvider.GitHub, value: github })
channels.push({ provider: contact.channelProvider.GitHub, value: github })
}
await findOrUpdate(client, recruit.space.CandidatesPublic, contact.class.Person, candId, data)
await client.updateMixin(candId, contact.class.Person, recruit.space.CandidatesPublic, recruit.mixin.Candidate, candidateData)
for (let i = 0; i < channels.length; i++) {
const element = channels[i]
const channelId = (candId + '.channel.' + i.toString()) as Ref<Channel>
await findOrUpdateAttached(client, recruit.space.CandidatesPublic, contact.class.Channel, channelId,
element, { attachedTo: candId, attachedClass: recruit.mixin.Candidate, collection: 'channels' })
}
const commentId = (candId + '.description.comment') as Ref<Comment>
if (commentData.length > 0) {
@ -349,10 +356,10 @@ function parseSocials (c: any): { sourceFields: string[], telegram: string | und
return { sourceFields, telegram, linkedin, github }
}
function pushChannel (c: any, data: Data<Candidate>, key: string, provider: Ref<ChannelProvider>): void {
function pushChannel (c: any, channels: AttachedData<Channel>[], key: string, provider: Ref<ChannelProvider>): void {
const value = get(c, key)
if (value !== undefined) {
data.channels.push({ provider, value })
channels.push({ provider, value })
}
}
export async function findOrUpdate<T extends Doc> (client: TxOperations, space: Ref<Space>, _class: Ref<Class<T>>, objectId: Ref<T>, data: Data<T>): Promise<void> {

View File

@ -22,6 +22,7 @@ import { attachmentOperation } from '@anticrm/model-attachment'
import { leadOperation } from '@anticrm/model-lead'
import { recruitOperation } from '@anticrm/model-recruit'
import { viewOperation } from '@anticrm/model-view'
import { contactOperation } from '@anticrm/model-contact'
export const migrateOperations: MigrateOperation[] = [
coreOperation,
@ -29,5 +30,6 @@ export const migrateOperations: MigrateOperation[] = [
attachmentOperation,
leadOperation,
recruitOperation,
viewOperation
viewOperation,
contactOperation
]

View File

@ -13,28 +13,23 @@
// limitations under the License.
//
import type { Domain, Type, Ref } from '@anticrm/core'
import { DOMAIN_MODEL, IndexKind } from '@anticrm/core'
import { Builder, Model, Prop, TypeString, UX, Index, Collection, ArrOf } from '@anticrm/model'
import type { IntlString, Asset } from '@anticrm/platform'
import chunter from '@anticrm/model-chunter'
import core, { TAccount, TDoc, TSpace, TType } from '@anticrm/model-core'
import type {
Contact,
Person,
Persons,
Organization,
Organizations,
Employee,
Channel,
ChannelProvider,
EmployeeAccount
ChannelProvider, Contact, Employee, EmployeeAccount, Organization,
Organizations, Person,
Persons
} from '@anticrm/contact'
import workbench from '@anticrm/model-workbench'
import view from '@anticrm/model-view'
import type { Domain, Ref } from '@anticrm/core'
import { DOMAIN_MODEL, IndexKind } from '@anticrm/core'
import { Builder, Collection, Index, Model, Prop, TypeRef, TypeString, UX } from '@anticrm/model'
import attachment from '@anticrm/model-attachment'
import { ids as contact } from './plugin'
import chunter from '@anticrm/model-chunter'
import core, { TAccount, TAttachedDoc, TDoc, TSpace } from '@anticrm/model-core'
import presentation from '@anticrm/model-presentation'
import view from '@anticrm/model-view'
import workbench from '@anticrm/model-workbench'
import type { Asset, IntlString } from '@anticrm/platform'
import { ids as contact } from './plugin'
export const DOMAIN_CONTACT = 'contact' as Domain
@ -45,16 +40,6 @@ export class TChannelProvider extends TDoc implements ChannelProvider {
placeholder!: IntlString
}
@Model(contact.class.TypeChannel, core.class.Type)
export class TTypeChannels extends TType {}
/**
* @public
*/
export function TypeChannel (): Type<Channel> {
return { _class: contact.class.TypeChannel, label: 'Channel' as IntlString }
}
@Model(contact.class.Contact, core.class.Doc, DOMAIN_CONTACT)
@UX('Contact' as IntlString, contact.icon.Person, undefined, 'name')
export class TContact extends TDoc implements Contact {
@ -64,8 +49,8 @@ export class TContact extends TDoc implements Contact {
avatar?: string
@Prop(ArrOf(TypeChannel()), 'Contact Info' as IntlString)
channels!: Channel[]
@Prop(Collection(contact.class.Channel), 'Contact Info' as IntlString)
channels?: number
@Prop(Collection(attachment.class.Attachment), 'Attachments' as IntlString)
attachments?: number
@ -77,6 +62,16 @@ export class TContact extends TDoc implements Contact {
city!: string
}
@Model(contact.class.Channel, core.class.AttachedDoc, DOMAIN_CONTACT)
@UX('Channel' as IntlString, contact.icon.Person)
export class TChannel extends TAttachedDoc implements Channel {
@Prop(TypeRef(contact.class.ChannelProvider), 'Channel provider' as IntlString)
provider!: Ref<ChannelProvider>
@Prop(TypeString(), 'Value' as IntlString)
value!: string
}
@Model(contact.class.Person, contact.class.Contact)
@UX('Person' as IntlString, contact.icon.Person, undefined, 'name')
export class TPerson extends TContact implements Person {}
@ -106,14 +101,14 @@ export class TPersons extends TSpace implements Persons {}
export function createModel (builder: Builder): void {
builder.createModel(
TChannelProvider,
TTypeChannels,
TContact,
TPerson,
TPersons,
TOrganization,
TOrganizations,
TEmployee,
TEmployeeAccount
TEmployeeAccount,
TChannel
)
builder.mixin(contact.class.Person, core.class.Class, view.mixin.ObjectFactory, {
@ -140,14 +135,16 @@ export function createModel (builder: Builder): void {
attachTo: contact.class.Contact,
descriptor: view.viewlet.Table,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
options: {},
options: {
lookup: { _id: { channels: contact.class.Channel } }
},
config: [
'',
'city',
{ presenter: attachment.component.AttachmentsPresenter, label: 'Files', sortingKey: 'attachments' },
'modifiedOn',
{ presenter: view.component.RolePresenter, label: 'Role' },
'channels'
'$lookup.channels'
]
})
@ -163,7 +160,7 @@ export function createModel (builder: Builder): void {
editor: contact.component.EditOrganization
})
builder.mixin(contact.class.TypeChannel, core.class.Class, view.mixin.AttributePresenter, {
builder.mixin(contact.class.Channel, core.class.Class, view.mixin.AttributePresenter, {
presenter: contact.component.ChannelsPresenter
})
@ -255,4 +252,5 @@ export function createModel (builder: Builder): void {
}, contact.completion.OrganizationCategory)
}
export { contactOperation } from './migration'
export { contact as default }

View File

@ -0,0 +1,214 @@
//
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021 Hardcore Engineering Inc.
//
// 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 { Channel, ChannelProvider, Contact } from '@anticrm/contact'
import { Class, DOMAIN_TX, generateId, Ref, SortingOrder, TxCreateDoc, TxCUD, TxRemoveDoc, TxUpdateDoc } from '@anticrm/core'
import { MigrateOperation, MigrationClient, MigrationUpgradeClient } from '@anticrm/model'
import core from '@anticrm/model-core'
import contact, { DOMAIN_CONTACT } from './index'
function createChannel (tx: TxCUD<Contact>, channel: any): Channel {
const doc: Channel = {
_class: contact.class.Channel,
_id: generateId(),
attachedToClass: tx.objectClass,
attachedTo: tx.objectId,
space: tx.objectSpace,
modifiedBy: tx.modifiedBy,
modifiedOn: tx.modifiedOn,
collection: 'channels',
value: channel.value,
provider: channel.provider
}
return doc
}
async function createTx (client: MigrationClient, tx: TxCUD<Contact>, doc: Channel): Promise<void> {
await client.create<TxCreateDoc<Channel>>(DOMAIN_TX, {
_class: core.class.TxCreateDoc,
_id: generateId(),
objectId: doc._id,
objectSpace: doc.space,
objectClass: doc._class,
space: tx.space,
modifiedBy: tx.modifiedBy,
modifiedOn: tx.modifiedOn,
attributes: {
collection: doc.collection,
attachedToClass: doc.attachedToClass,
attachedTo: doc.attachedTo,
value: doc.value,
provider: doc.provider
}
})
}
async function removeTx (client: MigrationClient, tx: TxCUD<Contact>, doc: Channel): Promise<void> {
await client.create<TxRemoveDoc<Channel>>(DOMAIN_TX, {
_class: core.class.TxRemoveDoc,
_id: generateId(),
objectId: doc._id,
objectSpace: doc.space,
objectClass: doc._class,
space: tx.space,
modifiedBy: tx.modifiedBy,
modifiedOn: tx.modifiedOn
})
}
async function processCreateTxes (client: MigrationClient, createTxes: TxCreateDoc<Contact>[]): Promise<Map<Ref<Contact>, Map<Ref<ChannelProvider>, Channel>>> {
const result: Map<Ref<Contact>, Map<Ref<ChannelProvider>, Channel>> = new Map<Ref<Contact>, Map<Ref<ChannelProvider>, Channel>>()
for (const tx of createTxes) {
if (tx.attributes.channels == null) continue
const { channels, ...attributes } = tx.attributes
const current = result.get(tx.objectId)
for (const channel of (channels as any) ?? []) {
const doc = createChannel(tx, channel)
if (current !== undefined) {
current.set(channel.provider, doc)
} else {
const map = new Map<Ref<ChannelProvider>, Channel>()
map.set(channel.provider, doc)
result.set(tx.objectId, map)
}
await createTx(client, tx, doc)
}
await client.update<TxCreateDoc<Contact>>(DOMAIN_TX, { _id: tx._id }, {
attributes: attributes
})
}
return result
}
export async function processRemoveTxes (client: MigrationClient, txes: TxRemoveDoc<Contact>[], result: Map<Ref<Contact>, Map<Ref<ChannelProvider>, Channel>>): Promise<Map<Ref<Contact>, Map<Ref<ChannelProvider>, Channel>>> {
for (const tx of txes) {
const current = result.get(tx.objectId)
if (current != null) {
for (const provider of current.keys()) {
const doc = current.get(provider)
if (doc !== undefined) {
await removeTx(client, tx, doc)
current.delete(provider)
}
}
}
}
return result
}
export async function migrateContactChannels (client: MigrationClient, classes: Ref<Class<Contact>>[]): Promise<void> {
const objectIds: Ref<Contact>[] = []
const contacts = await client.find<Contact>(DOMAIN_CONTACT, { _class: { $in: classes } })
for (const doc of contacts) {
const obj = doc as any
if (obj.channels != null && Array.isArray(obj.channels)) {
objectIds.push(doc._id)
}
}
const createTxes = await client.find<TxCreateDoc<Contact>>(DOMAIN_TX, {
_class: core.class.TxCreateDoc,
objectId: { $in: objectIds }
})
const objectChannels = await processCreateTxes(client, createTxes)
const updateTxes = await client.find<TxUpdateDoc<Contact>>(DOMAIN_TX, {
_class: core.class.TxUpdateDoc,
objectId: { $in: objectIds }
}, { sort: { modifiedOn: SortingOrder.Ascending } })
for (const tx of updateTxes) {
if (tx.operations.channels === undefined) continue
const { channels, ...operations } = tx.operations
const current = objectChannels.get(tx.objectId)
if (current !== undefined) {
const providers = new Set<Ref<ChannelProvider>>(current.keys())
for (const channel of (channels as any) ?? []) {
const doc = current.get(channel.provider)
if (doc !== undefined) {
providers.delete(doc.provider)
doc.value = channel.value
await client.create<TxUpdateDoc<Channel>>(DOMAIN_TX, {
_class: core.class.TxUpdateDoc,
_id: generateId(),
objectId: doc._id,
objectSpace: doc.space,
objectClass: doc._class,
space: tx.space,
modifiedBy: tx.modifiedBy,
modifiedOn: tx.modifiedOn,
operations: {
value: doc.value
}
})
} else {
const doc = createChannel(tx, channel)
current.set(channel.provider, doc)
await createTx(client, tx, doc)
}
}
for (const provider of providers.keys()) {
const doc = current.get(provider)
if (doc !== undefined) {
await removeTx(client, tx, doc)
current.delete(provider)
}
}
} else {
for (const channel of (channels as any) ?? []) {
const doc = createChannel(tx, channel)
const map = new Map<Ref<ChannelProvider>, Channel>()
map.set(channel.provider, doc)
objectChannels.set(tx.objectId, map)
}
}
if (Object.keys(operations).length > 0) {
await client.update<TxUpdateDoc<Contact>>(DOMAIN_TX, { _id: tx._id }, {
operations: operations
})
} else {
await client.delete<TxUpdateDoc<Contact>>(DOMAIN_TX, tx._id)
}
}
const removeTxes = await client.find<TxRemoveDoc<Contact>>(DOMAIN_TX, {
_class: core.class.TxRemoveDoc,
objectId: { $in: objectIds }
})
const result = await processRemoveTxes(client, removeTxes, objectChannels)
for (const contact of result.values()) {
for (const channel of contact.values()) {
await client.create(DOMAIN_CONTACT, channel)
}
}
for (const id of objectIds) {
const channels = result.get(id)?.size ?? 0
await client.update(DOMAIN_CONTACT, { _id: id }, {
channels: channels
})
}
}
export const contactOperation: MigrateOperation = {
async migrate (client: MigrationClient): Promise<void> {
const classes = [contact.class.Contact, contact.class.Person, contact.class.Employee, contact.class.Organization]
await migrateContactChannels(client, classes)
},
async upgrade (client: MigrationUpgradeClient): Promise<void> {
}
}

View File

@ -14,9 +14,8 @@
//
import { IntlString, mergeIds, Resource } from '@anticrm/platform'
import type { Ref, Class, Type } from '@anticrm/core'
import type { Ref } from '@anticrm/core'
import contact, { contactId } from '@anticrm/contact'
import type { Channel } from '@anticrm/contact'
import type { AnyComponent } from '@anticrm/ui'
import {} from '@anticrm/core'
import { Application } from '@anticrm/workbench'
@ -51,9 +50,6 @@ export const ids = mergeIds(contactId, contact, {
SearchPerson: '' as IntlString,
SearchOrganization: '' as IntlString
},
class: {
TypeChannel: '' as Ref<Class<Type<Channel>>>
},
completion: {
PersonQuery: '' as Resource<ObjectSearchFactory>,
EmployeeQuery: '' as Resource<ObjectSearchFactory>,

View File

@ -33,8 +33,7 @@ export async function createDeps (client: Client): Promise<void> {
contact.space.Employee,
{
name: 'Chen,Rosamund',
city: 'Mountain View',
channels: []
city: 'Mountain View'
},
account.employee
)
@ -44,15 +43,13 @@ export async function createDeps (client: Client): Promise<void> {
recruit.space.CandidatesPublic,
{
name: 'P.,Andrey',
city: 'Monte Carlo',
channels: [
{
provider: contact.channelProvider.Email,
value: 'andrey@hc.engineering'
}
]
city: 'Monte Carlo'
}
)
await tx.addCollection(contact.class.Channel, recruit.space.CandidatesPublic, u1, contact.class.Person, 'channels', {
provider: contact.channelProvider.Email,
value: 'andrey@hc.engineering'
})
await tx.createMixin(u1, contact.class.Person, recruit.space.CandidatesPublic, recruit.mixin.Candidate, {
title: 'Chief Architect'
@ -63,15 +60,13 @@ export async function createDeps (client: Client): Promise<void> {
recruit.space.CandidatesPublic,
{
name: 'M.,Marina',
city: 'Los Angeles',
channels: [
{
provider: contact.channelProvider.Email,
value: 'marina@hc.engineering'
}
]
city: 'Los Angeles'
}
)
await tx.addCollection(contact.class.Channel, recruit.space.CandidatesPublic, u2, contact.class.Person, 'channels', {
provider: contact.channelProvider.Email,
value: 'marina@hc.engineering'
})
await tx.createMixin(u2, contact.class.Person, recruit.space.CandidatesPublic, recruit.mixin.Candidate, {
title: 'Chief Designer'
})
@ -81,15 +76,13 @@ export async function createDeps (client: Client): Promise<void> {
recruit.space.CandidatesPublic,
{
name: 'P.,Alex',
city: 'Krasnodar, Russia',
channels: [
{
provider: contact.channelProvider.Email,
value: 'alex@hc.engineering'
}
]
city: 'Krasnodar, Russia'
}
)
await tx.addCollection(contact.class.Channel, recruit.space.CandidatesPublic, u3, contact.class.Person, 'channels', {
provider: contact.channelProvider.Email,
value: 'alex@hc.engineering'
})
await tx.createMixin(u3, contact.class.Person, recruit.space.CandidatesPublic, recruit.mixin.Candidate, {
title: 'Frontend Engineer'
})

View File

@ -14,17 +14,16 @@
// limitations under the License.
//
// To help typescript locate view plugin properly
import type { Category, Product, Variant } from '@anticrm/inventory'
import { Doc, Domain, FindOptions, Ref } from '@anticrm/core'
import type { Category, Product, Variant } from '@anticrm/inventory'
import { Builder, Collection, Model, Prop, TypeRef, TypeString, UX } from '@anticrm/model'
import core, { TAttachedDoc } from '@anticrm/model-core'
import type { IntlString } from '@anticrm/platform'
import type {} from '@anticrm/view'
import inventory from './plugin'
import workbench from '@anticrm/model-workbench'
import view from '@anticrm/view'
import attachment from '@anticrm/model-attachment'
import core, { TAttachedDoc } from '@anticrm/model-core'
import workbench from '@anticrm/model-workbench'
import type { IntlString } from '@anticrm/platform'
import type { } from '@anticrm/view'
import view from '@anticrm/view'
import inventory from './plugin'
export const DOMAIN_INVENTORY = 'inventory' as Domain
@Model(inventory.class.Category, core.class.AttachedDoc, DOMAIN_INVENTORY)
@ -96,9 +95,7 @@ export function createModel (builder: Builder): void {
descriptor: view.viewlet.Table,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
options: {
lookup: {
attachedTo: inventory.class.Category
}
lookup: { attachedTo: inventory.class.Category }
} as FindOptions<Doc>,
config: ['', '$lookup.attachedTo', 'modifiedOn']
})

View File

@ -16,7 +16,7 @@
// To help typescript locate view plugin properly
import type { Employee } from '@anticrm/contact'
import type { Doc, FindOptions, Ref } from '@anticrm/core'
import type { Doc, FindOptions, Lookup, Ref } from '@anticrm/core'
import type { Customer, Funnel, Lead } from '@anticrm/lead'
import { Builder, Collection, Mixin, Model, Prop, TypeRef, TypeString, UX } from '@anticrm/model'
import attachment from '@anticrm/model-attachment'
@ -115,15 +115,33 @@ export function createModel (builder: Builder): void {
lead.space.DefaultFunnel
)
builder.createDoc(view.class.Viewlet, core.space.Model, {
attachTo: lead.mixin.Customer,
descriptor: view.viewlet.Table,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
options: {
lookup: { _id: { channels: contact.class.Channel } } as any
} as FindOptions<Doc>, // TODO: fix
config: [
'',
{ key: 'leads', presenter: lead.component.LeadsPresenter, label: lead.string.Leads },
'modifiedOn',
'$lookup.channels'
]
})
const leadLookup: Lookup<Lead> =
{
attachedTo: [contact.class.Contact, { _id: { channels: contact.class.Channel } }],
state: task.class.State
}
builder.createDoc(view.class.Viewlet, core.space.Model, {
attachTo: lead.class.Lead,
descriptor: view.viewlet.Table,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
options: {
lookup: {
attachedTo: contact.class.Contact,
state: task.class.State
}
lookup: leadLookup
} as FindOptions<Doc>, // TODO: fix
config: [
'',
@ -132,7 +150,7 @@ export function createModel (builder: Builder): void {
{ presenter: attachment.component.AttachmentsPresenter, label: 'Files', sortingKey: 'attachments' },
{ presenter: chunter.component.CommentsPresenter, label: 'Comments', sortingKey: 'comments' },
'modifiedOn',
'$lookup.attachedTo.channels'
'$lookup.attachedTo.$lookup.channels'
]
})
@ -141,10 +159,7 @@ export function createModel (builder: Builder): void {
descriptor: task.viewlet.Kanban,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
options: {
lookup: {
customer: contact.class.Contact,
state: task.class.State
}
lookup: leadLookup
} as FindOptions<Doc>, // TODO: fix
config: ['$lookup.customer', '$lookup.state']
})

View File

@ -14,7 +14,7 @@
//
import type { Employee } from '@anticrm/contact'
import { Doc, FindOptions, Ref, Timestamp } from '@anticrm/core'
import { Doc, FindOptions, Lookup, Ref, Timestamp } from '@anticrm/core'
import { Builder, Collection, Mixin, Model, Prop, TypeBoolean, TypeDate, TypeRef, TypeString, UX } from '@anticrm/model'
import attachment from '@anticrm/model-attachment'
import chunter from '@anticrm/model-chunter'
@ -153,9 +153,7 @@ export function createModel (builder: Builder): void {
descriptor: view.viewlet.Table,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
options: {
// lookup: {
// resume: chunter.class.Attachment
// }
lookup: { _id: { channels: contact.class.Channel } }
} as FindOptions<Doc>, // TODO: fix
config: [
'',
@ -165,21 +163,24 @@ export function createModel (builder: Builder): void {
{ presenter: attachment.component.AttachmentsPresenter, label: 'Files', sortingKey: 'attachments' },
{ presenter: chunter.component.CommentsPresenter, label: 'Comments', sortingKey: 'comments' },
'modifiedOn',
'channels'
'$lookup.channels'
]
})
const applicantTableLookup: Lookup<Applicant> =
{
attachedTo: [recruit.mixin.Candidate, { _id: { channels: contact.class.Channel } }],
state: task.class.State,
assignee: contact.class.Employee,
doneState: task.class.DoneState
}
builder.createDoc(view.class.Viewlet, core.space.Model, {
attachTo: recruit.class.Applicant,
descriptor: view.viewlet.Table,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
options: {
lookup: {
attachedTo: recruit.mixin.Candidate,
state: task.class.State,
assignee: contact.class.Employee,
doneState: task.class.DoneState
}
lookup: applicantTableLookup
} as FindOptions<Doc>, // TODO: fix
config: [
'',
@ -190,21 +191,24 @@ export function createModel (builder: Builder): void {
{ presenter: attachment.component.AttachmentsPresenter, label: 'Files', sortingKey: 'attachments' },
{ presenter: chunter.component.CommentsPresenter, label: 'Comments', sortingKey: 'comments' },
'modifiedOn',
'$lookup.attachedTo.channels'
'$lookup.attachedTo.$lookup.channels'
]
})
const applicantKanbanLookup: Lookup<Applicant> =
{
attachedTo: [recruit.mixin.Candidate, { _id: { channels: contact.class.Channel } }],
state: task.class.State
}
builder.createDoc(view.class.Viewlet, core.space.Model, {
attachTo: recruit.class.Applicant,
descriptor: task.viewlet.Kanban,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
options: {
lookup: {
attachedTo: recruit.mixin.Candidate,
state: task.class.State
}
lookup: applicantKanbanLookup
} as FindOptions<Doc>, // TODO: fix
config: ['$lookup.attachedTo', '$lookup.state', '$lookup.attachedTo.city', '$lookup.attachedTo.channels']
config: ['$lookup.attachedTo', '$lookup.state', '$lookup.attachedTo.city', '$lookup.attachedTo.$lookup.channels']
})
builder.createDoc(view.class.Viewlet, core.space.Model, {
@ -212,12 +216,7 @@ export function createModel (builder: Builder): void {
descriptor: task.viewlet.StatusTable,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
options: {
lookup: {
attachedTo: recruit.mixin.Candidate,
state: task.class.State,
assignee: contact.class.Employee,
doneState: task.class.DoneState
}
lookup: applicantTableLookup
} as FindOptions<Doc>, // TODO: fix
config: [
'',
@ -228,7 +227,7 @@ export function createModel (builder: Builder): void {
{ presenter: attachment.component.AttachmentsPresenter, label: 'Files', sortingKey: 'attachments' },
{ presenter: chunter.component.CommentsPresenter, label: 'Comments', sortingKey: 'comments' },
'modifiedOn',
'$lookup.attachedTo.channels'
'$lookup.attachedTo.$lookup.channels'
]
})

View File

@ -13,7 +13,6 @@
// limitations under the License.
//
// To help typescript locate view plugin properly
import type { Employee } from '@anticrm/contact'
import contact from '@anticrm/contact'
import { Arr, Class, Doc, Domain, DOMAIN_MODEL, FindOptions, Ref, Space, Timestamp } from '@anticrm/core'
@ -281,9 +280,7 @@ export function createModel (builder: Builder): void {
descriptor: view.viewlet.Table,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
options: {
lookup: {
assignee: contact.class.Employee
}
lookup: { assignee: contact.class.Employee }
} as FindOptions<Doc>,
config: [
'',
@ -313,9 +310,8 @@ export function createModel (builder: Builder): void {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
options: {
lookup: {
assignee: contact.class.Employee,
state: task.class.State
// attachedTo: core.class.Doc
state: task.class.State,
assignee: contact.class.Employee
}
} as FindOptions<Doc>, // TODO: fix
config: [

View File

@ -252,4 +252,33 @@ describe('memdb', () => {
const result2 = await client.findAll(test.class.TestComment, {})
expect(result2).toHaveLength(1)
})
it('lookups', async () => {
const { model } = await createModel()
const client = new TxOperations(model, core.account.System)
const spaces = await client.findAll(core.class.Space, {})
expect(spaces).toHaveLength(2)
const first = await client.addCollection(test.class.TestComment, core.space.Model, spaces[0]._id, spaces[0]._class, 'comments', {
message: 'msg'
})
const second = await client.addCollection(test.class.TestComment, core.space.Model, first, test.class.TestComment, 'comments', {
message: 'msg2'
})
await client.addCollection(test.class.TestComment, core.space.Model, spaces[0]._id, spaces[0]._class, 'comments', {
message: 'msg3'
})
const simple = await client.findAll(test.class.TestComment, { _id: first }, { lookup: { attachedTo: spaces[0]._class } })
expect(simple[0].$lookup?.attachedTo).toEqual(spaces[0])
const nested = await client.findAll(test.class.TestComment, { _id: second }, { lookup: { attachedTo: [test.class.TestComment, { attachedTo: spaces[0]._class } as any] } })
expect((nested[0].$lookup?.attachedTo as any).$lookup?.attachedTo).toEqual(spaces[0])
const reverse = await client.findAll(spaces[0]._class, { _id: spaces[0]._id }, { lookup: { _id: { comments: test.class.TestComment } } })
expect((reverse[0].$lookup as any).comments).toHaveLength(2)
})
})

View File

@ -21,6 +21,7 @@ export * from './hierarchy'
export * from './memdb'
export * from './client'
export * from './operator'
export * from './objvalue'
export * from './query'
export * from './server'
export * from './measurements'

View File

@ -15,11 +15,12 @@
import { PlatformError, Severity, Status } from '@anticrm/platform'
import clone from 'just-clone'
import { Lookup, ReverseLookups } from '.'
import type { Class, Doc, Ref } from './classes'
import core from './component'
import { Hierarchy } from './hierarchy'
import { matchQuery, resultSort } from './query'
import type { DocumentQuery, FindOptions, FindResult, LookupData, Refs, Storage, TxResult, WithLookup } from './storage'
import type { DocumentQuery, FindOptions, FindResult, LookupData, Storage, TxResult, WithLookup } from './storage'
import type { Tx, TxCreateDoc, TxMixin, TxPutBag, TxRemoveDoc, TxUpdateDoc } from './tx'
import { TxProcessor } from './tx'
@ -76,16 +77,43 @@ export abstract class MemDb extends TxProcessor {
return doc as T
}
private lookup<T extends Doc>(docs: T[], lookup: Refs<T>): WithLookup<T>[] {
private async getLookupValue<T extends Doc> (doc: T, lookup: Lookup<T>, result: LookupData<T>): Promise<void> {
for (const key in lookup) {
if (key === '_id') {
await this.getReverseLookupValue(doc, lookup, result)
continue
}
const value = (lookup as any)[key]
if (Array.isArray(value)) {
const [_class, nested] = value
const objects = await this.findAll(_class, { _id: (doc as any)[key] })
;(result as any)[key] = objects[0]
const nestedResult = {}
const parent = (result as any)[key]
await this.getLookupValue(parent, nested, nestedResult)
Object.assign(parent, {
$lookup: nestedResult
})
} else {
const objects = await this.findAll(value, { _id: (doc as any)[key] })
;(result as any)[key] = objects[0]
}
}
}
private async getReverseLookupValue<T extends Doc> (doc: T, lookup: ReverseLookups, result: LookupData<T>): Promise<void> {
for (const key in lookup._id) {
const value = lookup._id[key]
const objects = await this.findAll(value, { attachedTo: doc._id })
;(result as any)[key] = objects
}
}
private async lookup<T extends Doc>(docs: T[], lookup: Lookup<T>): Promise<WithLookup<T>[]> {
const withLookup: WithLookup<T>[] = []
for (const doc of docs) {
const result: LookupData<T> = {}
for (const key in lookup) {
const id = (doc as any)[key] as Ref<Doc>
if (id != null) {
(result as any)[key] = this.getObject(id)
}
}
await this.getLookupValue(doc, lookup, result)
withLookup.push(Object.assign({}, doc, { $lookup: result }))
}
return withLookup
@ -114,7 +142,7 @@ export abstract class MemDb extends TxProcessor {
result = result.filter(r => (r as any)[_class] !== undefined)
}
if (options?.lookup !== undefined) result = this.lookup(result as T[], options.lookup)
if (options?.lookup !== undefined) result = await this.lookup(result as T[], options.lookup)
if (options?.sort !== undefined) resultSort(result, options?.sort)

View File

@ -14,7 +14,7 @@
//
import type { KeysByType } from 'simplytyped'
import type { Class, Doc, Ref } from './classes'
import type { AttachedDoc, Class, Doc, Ref } from './classes'
import type { Tx } from './tx'
/**
@ -49,13 +49,42 @@ export type DocumentQuery<T extends Doc> = {
* @public
*/
export type ToClassRef<T extends object> = {
[P in keyof T]?: T[P] extends Ref<infer X> ? Ref<Class<X>> : never
[P in keyof T]?: T[P] extends Ref<infer X> | null ? Ref<Class<X>> | [Ref<Class<X>>, Lookup<X>] : never
}
/**
* @public
*/
export type Refs<T extends Doc> = ToClassRef<Pick<T, KeysByType<T, Ref<Doc>>>>
export type RefKeys<T extends Doc> = Pick<T, KeysByType<T, NullableRef>>
/**
* @public
*/
export type NullableRef = Ref<Doc> | null
/**
* @public
*/
export type Refs<T extends Doc> = ToClassRef<RefKeys<T>>
/**
* @public
*/
export interface ReverseLookups {
_id?: ReverseLookup
}
/**
* @public
*/
export interface ReverseLookup {
[key: string]: Ref<Class<AttachedDoc>>
}
/**
* @public
*/
export type Lookup<T extends Doc> = Refs<T> | ReverseLookups | (Refs<T> & ReverseLookups)
/**
* @public
@ -64,7 +93,7 @@ export type Refs<T extends Doc> = ToClassRef<Pick<T, KeysByType<T, Ref<Doc>>>>
export type FindOptions<T extends Doc> = {
limit?: number
sort?: SortingQuery<T>
lookup?: Refs<T>
lookup?: Lookup<T>
}
/**
@ -85,8 +114,8 @@ export enum SortingOrder {
/**
* @public
*/
export type RefsAsDocs<T extends Doc> = {
[P in keyof T]: T[P] extends Ref<infer X> ? X : never
export type RefsAsDocs<T> = {
[P in keyof T]: T[P] extends Ref<infer X> ? (T extends X ? X : X | WithLookup<X>) : never
}
/**
@ -97,7 +126,9 @@ export type RemoveNever<T extends object> = Omit<T, KeysByType<T, never>>
/**
* @public
*/
export type LookupData<T extends Doc> = Partial<RemoveNever<RefsAsDocs<T>>>
export type LookupData<T extends Doc> = Partial<RemoveNever<RefsAsDocs<T>>> | RemoveNever<{
[key: string]: Doc[]
}>
/**
* @public

View File

@ -13,7 +13,7 @@
// limitations under the License.
//
import type { Account, Arr, Class, Data, Doc, Mixin, Obj, Ref, TxCreateDoc, TxCUD } from '@anticrm/core'
import type { Account, Arr, Class, Data, Doc, Domain, 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'
@ -62,6 +62,8 @@ export const test = plugin('test' as Plugin, {
}
})
const DOMAIN_TEST: Domain = 'test' as Domain
/**
* @public
* Generate minimal model for testing purposes.
@ -86,7 +88,7 @@ export function genMinModel (): TxCUD<Doc>[] {
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 }))
txes.push(createClass(test.class.TestComment, { label: 'TestComment' as IntlString, extends: core.class.AttachedDoc, kind: ClassifierKind.CLASS, domain: DOMAIN_TEST }))
const u1 = 'User1' as Ref<Account>
const u2 = 'User2' as Ref<Account>

View File

@ -13,8 +13,8 @@
// limitations under the License.
//
import core, { createClient, Doc, SortingOrder, Space, Tx, TxCreateDoc, TxOperations } from '@anticrm/core'
import { genMinModel } from './minmodel'
import core, { createClient, Doc, generateId, Ref, SortingOrder, Space, Tx, TxCreateDoc, TxOperations, WithLookup } from '@anticrm/core'
import { AttachedComment, test, genMinModel } from './minmodel'
import { LiveQuery } from '..'
import { connect } from './connection'
@ -71,7 +71,6 @@ describe('query', () => {
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)
@ -213,20 +212,20 @@ describe('query', () => {
const { liveQuery, factory } = await getClient()
const limit = 1
let attempt = -1
let doneCount = 0
let attempt = 0
let descAttempt = 0
const pp1 = new Promise((resolve) => {
liveQuery.query<Space>(
core.class.Space,
{ private: true },
(result) => {
if (attempt === 0 && result.length > 0) {
if (result.length > 0) {
expect(result.length).toEqual(limit)
expect(result[0].name).toMatch('0')
attempt++
}
if (attempt === 0) doneCount++
if (doneCount === 2) resolve(null)
if (attempt === 1) resolve(null)
},
{ limit: limit, sort: { name: SortingOrder.Ascending } }
)
@ -237,19 +236,18 @@ describe('query', () => {
core.class.Space,
{ private: true },
(result) => {
if (attempt > 0 && result.length > 0) {
if (result.length > 0) {
expect(result.length).toEqual(limit)
expect(result[0].name).toMatch(attempt.toString())
expect(result[0].name).toMatch(descAttempt.toString())
descAttempt++
}
if (attempt === 9) doneCount++
if (doneCount === 2) resolve(null)
if (descAttempt === 10) 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(),
@ -362,6 +360,364 @@ describe('query', () => {
await pp
})
it('lookup query add doc', async () => {
const { liveQuery, factory } = await getClient()
const futureSpace: Space = {
_id: generateId(),
_class: core.class.Space,
private: false,
members: [],
space: core.space.Model,
name: 'new space',
description: '',
archived: false,
modifiedBy: core.account.System,
modifiedOn: 0
}
const comment = await factory.addCollection(test.class.TestComment, futureSpace._id, futureSpace._id, core.class.Space, 'comments', {
message: 'test'
})
let attempt = 0
const pp = new Promise((resolve) => {
liveQuery.query<AttachedComment>(
test.class.TestComment,
{ _id: comment },
(result) => {
const comment = result[0]
if (comment !== undefined) {
if (attempt > 0) {
expect((comment as WithLookup<AttachedComment>).$lookup?.space).toEqual(futureSpace)
resolve(null)
} else {
expect((comment as WithLookup<AttachedComment>).$lookup?.space).toBeUndefined()
attempt++
}
}
},
{ lookup: { space: core.class.Space } }
)
})
await factory.createDoc(core.class.Space, futureSpace.space, {
...futureSpace
}, futureSpace._id)
await pp
})
it('lookup nested query add doc', async () => {
const { liveQuery, factory } = await getClient()
const futureSpace: Space = {
_id: generateId(),
_class: core.class.Space,
private: false,
members: [],
space: core.space.Model,
name: 'new space',
description: '',
archived: false,
modifiedBy: core.account.System,
modifiedOn: 0
}
const comment = await factory.addCollection(test.class.TestComment, futureSpace._id, futureSpace._id, core.class.Space, 'comments', {
message: 'test'
})
const childComment = await factory.addCollection(test.class.TestComment, futureSpace._id, comment, test.class.TestComment, 'comments', {
message: 'child'
})
let attempt = 0
const pp = new Promise((resolve) => {
liveQuery.query<AttachedComment>(
test.class.TestComment,
{ _id: childComment },
(result) => {
const comment = result[0]
if (comment !== undefined) {
if (attempt > 0) {
expect(((comment as WithLookup<AttachedComment>).$lookup?.attachedTo as WithLookup<AttachedComment>)?.$lookup?.space).toEqual(futureSpace)
resolve(null)
} else {
expect(((comment as WithLookup<AttachedComment>).$lookup?.attachedTo as WithLookup<AttachedComment>)?.$lookup?.space).toBeUndefined()
attempt++
}
}
},
{ lookup: { attachedTo: [test.class.TestComment, { space: core.class.Space }] } }
)
})
await factory.createDoc(core.class.Space, futureSpace.space, {
...futureSpace
}, futureSpace._id)
await pp
})
it('lookup reverse query add doc', async () => {
const { liveQuery, factory } = await getClient()
const spaces = await liveQuery.findAll(core.class.Space, {})
const parentComment = await factory.addCollection(test.class.TestComment, spaces[0]._id, spaces[0]._id, spaces[0]._class, 'comments', {
message: 'test'
})
let attempt = 0
const childLength = 3
const pp = new Promise((resolve) => {
liveQuery.query<AttachedComment>(
test.class.TestComment,
{ _id: parentComment },
(result) => {
const comment = result[0]
if (comment !== undefined) {
expect(((comment as WithLookup<AttachedComment>).$lookup as any)?.comments).toHaveLength(attempt++)
}
if (attempt === childLength) {
resolve(null)
}
},
{ lookup: { _id: { comments: test.class.TestComment } } }
)
})
for (let index = 0; index < childLength; index++) {
await factory.addCollection(test.class.TestComment, spaces[0]._id, parentComment, test.class.TestComment, 'comments', {
message: index.toString()
})
}
await pp
})
it('lookup query remove doc', async () => {
const { liveQuery, factory } = await getClient()
const futureSpace = await factory.createDoc(core.class.Space, core.space.Model, {
name: 'new space',
description: '',
archived: false,
private: false,
members: []
})
const comment = await factory.addCollection(test.class.TestComment, futureSpace, futureSpace, core.class.Space, 'comments', {
message: 'test'
})
let attempt = 0
const pp = new Promise((resolve) => {
liveQuery.query<AttachedComment>(
test.class.TestComment,
{ _id: comment },
(result) => {
const comment = result[0]
if (comment !== undefined) {
if (attempt > 0) {
expect((comment as WithLookup<AttachedComment>).$lookup?.space).toBeUndefined()
resolve(null)
} else {
expect(((comment as WithLookup<AttachedComment>).$lookup?.space as Doc)?._id).toEqual(futureSpace)
attempt++
}
}
},
{ lookup: { space: core.class.Space } }
)
})
await factory.removeDoc(core.class.Space, core.space.Model, futureSpace)
await pp
})
it('lookup nested query remove doc', async () => {
const { liveQuery, factory } = await getClient()
const futureSpace = await factory.createDoc(core.class.Space, core.space.Model, {
name: 'new space',
description: '',
archived: false,
private: false,
members: []
})
const comment = await factory.addCollection(test.class.TestComment, futureSpace, futureSpace, core.class.Space, 'comments', {
message: 'test'
})
const childComment = await factory.addCollection(test.class.TestComment, futureSpace, comment, test.class.TestComment, 'comments', {
message: 'child'
})
let attempt = 0
const pp = new Promise((resolve) => {
liveQuery.query<AttachedComment>(
test.class.TestComment,
{ _id: childComment },
(result) => {
const comment = result[0]
if (comment !== undefined) {
if (attempt > 0) {
expect(((comment as WithLookup<AttachedComment>).$lookup?.attachedTo as WithLookup<AttachedComment>)?.$lookup?.space).toBeUndefined()
resolve(null)
} else {
expect((((comment as WithLookup<AttachedComment>).$lookup?.attachedTo as WithLookup<AttachedComment>)?.$lookup?.space as Doc)?._id).toEqual(futureSpace)
attempt++
}
}
},
{ lookup: { attachedTo: [test.class.TestComment, { space: core.class.Space }] } }
)
})
await factory.removeDoc(core.class.Space, core.space.Model, futureSpace)
await pp
})
it('lookup reverse query remove doc', async () => {
const { liveQuery, factory } = await getClient()
const spaces = await liveQuery.findAll(core.class.Space, {})
const comments = await liveQuery.findAll(test.class.TestComment, {})
expect(comments).toHaveLength(0)
const parentComment = await factory.addCollection(test.class.TestComment, spaces[0]._id, spaces[0]._id, spaces[0]._class, 'comments', {
message: 'test'
})
let attempt = 0
const childLength = 3
const childs: Ref<AttachedComment>[] = []
for (let index = 0; index < childLength; index++) {
childs.push(await factory.addCollection(test.class.TestComment, spaces[0]._id, parentComment, test.class.TestComment, 'comments', {
message: index.toString()
}))
}
const pp = new Promise((resolve) => {
liveQuery.query<AttachedComment>(
test.class.TestComment,
{ _id: parentComment },
(result) => {
const comment = result[0]
if (comment !== undefined) {
expect(((comment as WithLookup<AttachedComment>).$lookup as any)?.comments).toHaveLength(childLength - attempt)
attempt++
}
if (attempt === childLength) {
resolve(null)
}
},
{ lookup: { _id: { comments: test.class.TestComment } } }
)
})
for (const child of childs) {
await factory.removeCollection(test.class.TestComment, spaces[0]._id, child, parentComment, test.class.TestComment, 'comments')
}
await pp
})
it('lookup query update doc', async () => {
const { liveQuery, factory } = await getClient()
let attempt = 0
const futureSpace = await factory.createDoc(core.class.Space, core.space.Model, {
name: '0',
description: '',
archived: false,
private: false,
members: []
})
const comment = await factory.addCollection(test.class.TestComment, futureSpace, futureSpace, core.class.Space, 'comments', {
message: 'test'
})
const pp = new Promise((resolve) => {
liveQuery.query<AttachedComment>(
test.class.TestComment,
{ _id: comment },
(result) => {
const comment = result[0]
if (comment !== undefined) {
expect(((comment as WithLookup<AttachedComment>).$lookup?.space as Space).name).toEqual(attempt.toString())
}
if (attempt > 0) {
resolve(null)
} else {
attempt++
}
},
{ lookup: { space: core.class.Space } }
)
})
await factory.updateDoc(core.class.Space, core.space.Model, futureSpace, {
name: '1'
})
await pp
})
it('lookup nested query update doc', async () => {
const { liveQuery, factory } = await getClient()
let attempt = 0
const futureSpace = await factory.createDoc(core.class.Space, core.space.Model, {
name: '0',
description: '',
archived: false,
private: false,
members: []
})
const comment = await factory.addCollection(test.class.TestComment, futureSpace, futureSpace, core.class.Space, 'comments', {
message: 'test'
})
const childComment = await factory.addCollection(test.class.TestComment, futureSpace, comment, test.class.TestComment, 'comments', {
message: 'child'
})
const pp = new Promise((resolve) => {
liveQuery.query<AttachedComment>(
test.class.TestComment,
{ _id: childComment },
(result) => {
const comment = result[0]
if (comment !== undefined) {
expect((((comment as WithLookup<AttachedComment>).$lookup?.attachedTo as WithLookup<AttachedComment>)?.$lookup?.space as Space).name).toEqual(attempt.toString())
}
if (attempt > 0) {
resolve(null)
} else {
attempt++
}
},
{ lookup: { attachedTo: [test.class.TestComment, { space: core.class.Space }] } }
)
})
await factory.updateDoc(core.class.Space, core.space.Model, futureSpace, {
name: '1'
})
await pp
})
it('lookup reverse query update doc', async () => {
const { liveQuery, factory } = await getClient()
const spaces = await liveQuery.findAll(core.class.Space, {})
const parentComment = await factory.addCollection(test.class.TestComment, spaces[0]._id, spaces[0]._id, spaces[0]._class, 'comments', {
message: 'test'
})
let attempt = 0
const childComment = await factory.addCollection(test.class.TestComment, spaces[0]._id, parentComment, test.class.TestComment, 'comments', {
message: '0'
})
const pp = new Promise((resolve) => {
liveQuery.query<AttachedComment>(
test.class.TestComment,
{ _id: parentComment },
(result) => {
const comment = result[0]
if (comment !== undefined) {
expect((((comment as WithLookup<AttachedComment>).$lookup as any)?.comments[0] as AttachedComment).message).toEqual(attempt.toString())
}
if (attempt > 0) {
resolve(null)
} else {
attempt++
}
},
{ lookup: { _id: { comments: test.class.TestComment } } }
)
})
await factory.updateCollection(test.class.TestComment, spaces[0]._id, childComment, parentComment, test.class.TestComment, 'comments', {
message: '1'
})
await pp
})
// it('update with over limit', async () => {
// const { liveQuery, factory } = await getClient()

View File

@ -22,12 +22,10 @@ import core, {
FindOptions,
findProperty,
FindResult,
Hierarchy,
getObjectValue,
Hierarchy, Lookup,
LookupData,
ModelDb,
Ref,
Refs,
resultSort,
ModelDb, Ref, resultSort, ReverseLookups,
SortingQuery,
Tx,
TxBulkWrite,
@ -86,7 +84,6 @@ export class LiveQuery extends TxProcessor implements Client {
const query = q.query
for (const key in query) {
if (key === '$search') continue
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) {
@ -123,7 +120,7 @@ export class LiveQuery extends TxProcessor implements Client {
_class,
query,
result,
options,
options: options as FindOptions<Doc>,
callback: callback as (result: Doc[]) => void
}
this.queries.push(q)
@ -241,8 +238,49 @@ export class LiveQuery extends TxProcessor implements Client {
this.sort(q, tx)
await this.callback(updatedDoc, q)
} else if (this.matchQuery(q, tx)) {
await this.refresh(q)
return await this.refresh(q)
}
await this.handleDocUpdateLookup(q, tx)
}
private async handleDocUpdateLookup (q: Query, tx: TxUpdateDoc<Doc>): Promise<void> {
if (q.options?.lookup === undefined) return
const lookup = q.options.lookup
if (q.result instanceof Promise) {
q.result = await q.result
}
let needCallback = false
needCallback = this.proccesLookupUpdateDoc(q.result, lookup, tx)
if (needCallback) {
q.callback(this.clone(q.result))
}
}
private proccesLookupUpdateDoc (docs: Doc[], lookup: Lookup<Doc>, tx: TxUpdateDoc<Doc>): boolean {
let needCallback = false
const lookupWays = this.getLookupWays(lookup, tx.objectClass)
for (const lookupWay of lookupWays) {
const [objWay, key] = lookupWay
for (const resDoc of docs) {
const obj = getObjectValue(objWay, resDoc)
if (obj === undefined) continue
const value = getObjectValue('$lookup.' + key, obj)
if (Array.isArray(value)) {
const index = value.findIndex((p) => p._id === tx.objectId)
if (index !== -1) {
TxProcessor.updateDoc2Doc(value[index], tx)
needCallback = true
}
} else {
if (obj[key] === tx.objectId) {
TxProcessor.updateDoc2Doc(obj.$lookup[key], tx)
needCallback = true
}
}
}
}
return needCallback
}
/**
@ -280,13 +318,41 @@ export class LiveQuery extends TxProcessor implements Client {
return false
}
private async lookup (doc: Doc, lookup: Refs<Doc>): Promise<void> {
const result: LookupData<Doc> = {}
private async getLookupValue<T extends Doc> (doc: T, lookup: Lookup<T>, result: LookupData<T>): Promise<void> {
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.findOne(_class, { _id })
if (key === '_id') {
await this.getReverseLookupValue(doc, lookup, result)
continue
}
const value = (lookup as any)[key]
if (Array.isArray(value)) {
const [_class, nested] = value
const objects = await this.findAll(_class, { _id: (doc as any)[key] })
;(result as any)[key] = objects[0]
const nestedResult = {}
const parent = (result as any)[key]
await this.getLookupValue(parent, nested, nestedResult)
Object.assign(parent, {
$lookup: nestedResult
})
} else {
const objects = await this.findAll(value, { _id: (doc as any)[key] })
;(result as any)[key] = objects[0]
}
}
}
private async getReverseLookupValue<T extends Doc> (doc: T, lookup: ReverseLookups, result: LookupData<T>): Promise<void> {
for (const key in lookup._id) {
const value = lookup._id[key]
const objects = await this.findAll(value, { attachedTo: doc._id })
;(result as any)[key] = objects
}
}
private async lookup<T extends Doc> (doc: T, lookup: Lookup<T>): Promise<void> {
const result: LookupData<Doc> = {}
await this.getLookupValue(doc, lookup, result)
;(doc as WithLookup<Doc>).$lookup = result
}
@ -328,6 +394,49 @@ export class LiveQuery extends TxProcessor implements Client {
q.callback(this.clone(q.result))
}
}
await this.handleDocAddLookup(q, doc)
}
private async handleDocAddLookup (q: Query, doc: Doc): Promise<void> {
if (q.options?.lookup === undefined) return
const lookup = q.options.lookup
if (q.result instanceof Promise) {
q.result = await q.result
}
let needCallback = false
needCallback = this.proccesLookupAddDoc(q.result, lookup, doc)
if (needCallback) {
q.callback(this.clone(q.result))
}
}
private proccesLookupAddDoc (docs: Doc[], lookup: Lookup<Doc>, doc: Doc): boolean {
let needCallback = false
const lookupWays = this.getLookupWays(lookup, doc._class)
for (const lookupWay of lookupWays) {
const [objWay, key] = lookupWay
for (const resDoc of docs) {
const obj = getObjectValue(objWay, resDoc)
if (obj === undefined) continue
const value = getObjectValue('$lookup.' + key, obj)
if (Array.isArray(value)) {
if (this.client.getHierarchy().isDerived(doc._class, core.class.AttachedDoc)) {
if ((doc as AttachedDoc).attachedTo === obj._id) {
value.push(doc)
needCallback = true
}
}
} else {
if (obj[key] === doc._id) {
obj.$lookup[key] = doc
needCallback = true
}
}
}
}
return needCallback
}
protected async txRemoveDoc (tx: TxRemoveDoc<Doc>): Promise<TxResult> {
@ -346,7 +455,6 @@ export class LiveQuery extends TxProcessor implements Client {
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 &&
@ -354,10 +462,83 @@ export class LiveQuery extends TxProcessor implements Client {
) {
return await this.refresh(q)
}
const index = q.result.findIndex((p) => p._id === tx.objectId)
if (index > -1) {
q.result.splice(index, 1)
q.callback(this.clone(q.result))
}
await this.handleDocRemoveLookup(q, tx)
}
private async handleDocRemoveLookup (q: Query, tx: TxRemoveDoc<Doc>): Promise<void> {
if (q.options?.lookup === undefined) return
let needCallback = false
const lookupWays = this.getLookupWays(q.options.lookup, tx.objectClass)
if (lookupWays.length === 0) return
if (q.result instanceof Promise) {
q.result = await q.result
}
for (const lookupWay of lookupWays) {
const [objWay, key] = lookupWay
const docs = q.result
for (const doc of docs) {
const obj = getObjectValue(objWay, doc)
if (obj === undefined) continue
const value = getObjectValue('$lookup.' + key, obj)
if (value === undefined) continue
if (Array.isArray(value)) {
const index = value.findIndex((p) => p._id === tx.objectId)
if (index !== -1) {
value.splice(index, 1)
needCallback = true
}
} else {
if (value._id === tx.objectId) {
obj.$lookup[key] = undefined
needCallback = true
}
}
}
}
if (needCallback) {
q.callback(this.clone(q.result))
}
}
private getLookupWays (lookup: Lookup<Doc>, _class: Ref<Class<Doc>>, parent: string = ''): [string, string][] {
const result: [string, string][] = []
const hierarchy = this.client.getHierarchy()
if (lookup._id !== undefined) {
for (const key in lookup._id) {
const value = (lookup._id as any)[key]
const clazz = hierarchy.isMixin(value) ? hierarchy.getBaseClass(value) : value
if (hierarchy.isDerived(_class, clazz)) {
result.push([parent, key])
}
}
}
for (const key in lookup) {
if (key === '_id') continue
const value = (lookup as any)[key]
if (Array.isArray(value)) {
const clazz = hierarchy.isMixin(value[0]) ? hierarchy.getBaseClass(value[0]) : value[0]
if (hierarchy.isDerived(_class, clazz)) {
result.push([parent, key])
}
const lookupKey = '$lookup.' + key
const newParent = parent.length > 0 ? parent + '.' + lookupKey : lookupKey
const nested = this.getLookupWays(value[1], _class, newParent)
if (nested.length > 0) {
result.push(...nested)
}
} else {
const clazz = hierarchy.isMixin(value) ? hierarchy.getBaseClass(value) : value
if (hierarchy.isDerived(_class, clazz)) {
result.push([parent, key])
}
}
}
return result
}
protected override async txBulkWrite (tx: TxBulkWrite): Promise<TxResult> {

View File

@ -0,0 +1,61 @@
<!--
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021, 2022 Hardcore Engineering Inc.
//
// 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.
-->
<script lang="ts">
import { Channel } from '@anticrm/contact'
import type { Doc,Ref } from '@anticrm/core'
import presentation,{ Channels } from '@anticrm/presentation'
import { CircleButton,IconAdd,Label,showPopup } from '@anticrm/ui'
import { createEventDispatcher } from 'svelte'
import contact from '../plugin'
export let integrations: Set<Ref<Doc>> | undefined = undefined
export let channels: Channel[] = []
const dispatch = createEventDispatcher()
</script>
{#if !channels.length}
<CircleButton
icon={IconAdd}
size={'small'}
selected
on:click={(ev) =>
showPopup(contact.component.SocialEditor, { values: channels }, ev.target, (result) => {
dispatch('change', result)
})}
/>
<span><Label label={presentation.string.AddSocialLinks} /></span>
{:else}
<Channels value={channels} size={'small'} {integrations} on:click />
<div class="ml-1">
<CircleButton
icon={contact.icon.Edit}
size={'small'}
selected
on:click={(ev) =>
showPopup(contact.component.SocialEditor, { values: channels }, ev.target, (result) => {
dispatch('change', result)
})}
/>
</div>
{/if}
<style lang="scss">
span {
margin-left: 0.5rem;
}
</style>

View File

@ -0,0 +1,74 @@
<!--
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021, 2022 Hardcore Engineering Inc.
//
// 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.
-->
<script lang="ts">
import type { AttachedData, Class, Doc, Ref } from '@anticrm/core'
import { createQuery, getClient } from '@anticrm/presentation'
import { ChannelProvider, Channel } from '@anticrm/contact'
import contact from '../plugin'
import Channels from './Channels.svelte'
export let attachedTo: Ref<Doc>
export let attachedClass: Ref<Class<Doc>>
export let integrations: Set<Ref<Doc>> | undefined = undefined
let channels: Channel[] = []
function findValue (provider: Ref<ChannelProvider>): Channel | undefined {
for (let i = 0; i < channels.length; i++) {
if (channels[i].provider === provider) return channels[i]
}
return undefined
}
const query = createQuery()
query.query(contact.class.Channel, {
attachedTo: attachedTo
}, (res) => {
channels = res
})
const client = getClient()
async function save (newValues: AttachedData<Channel>[]): Promise<void> {
const currentProviders = new Set(channels.map((p) => p.provider))
const promises = []
for (const value of newValues) {
const oldChannel = findValue(value.provider)
if (oldChannel === undefined) {
if (value.value.length === 0) continue
promises.push(client.addCollection(contact.class.Channel, contact.space.Contacts, attachedTo, attachedClass, 'channels', {
value: value.value,
provider: value.provider
}))
} else {
currentProviders.delete(value.provider)
if (value.value === oldChannel.value) continue
promises.push(client.updateCollection(oldChannel._class, oldChannel.space, oldChannel._id, oldChannel.attachedTo, oldChannel.attachedToClass, oldChannel.collection, {
value: value.value
}))
}
}
for (const value of currentProviders) {
const oldChannel = findValue(value)
if (oldChannel === undefined) continue
promises.push(client.removeCollection(oldChannel._class, oldChannel.space, oldChannel._id, oldChannel.attachedTo, oldChannel.attachedToClass, oldChannel.collection))
}
Promise.all(promises)
}
</script>
<Channels {channels} {integrations} on:change={(e) => { save(e.detail) }} />

View File

@ -16,19 +16,22 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import presentation, { getClient, Card, Channels } from '@anticrm/presentation'
import { getClient, Card } from '@anticrm/presentation'
import { EditBox, showPopup, CircleButton, IconEdit, IconAdd, Label } from '@anticrm/ui'
import SocialEditor from './SocialEditor.svelte'
import { EditBox } from '@anticrm/ui'
import { Organization } from '@anticrm/contact'
import { Channel, Organization } from '@anticrm/contact'
import contact from '../plugin'
import Company from './icons/Company.svelte'
import { generateId } from '@anticrm/core'
import Channels from './Channels.svelte'
export function canClose (): boolean {
return object.name === ''
}
const id = generateId()
const object: Organization = {
name: ''
} as Organization
@ -37,10 +40,18 @@
const client = getClient()
async function createOrganization () {
await client.createDoc(contact.class.Organization, contact.space.Contacts, object)
await client.createDoc(contact.class.Organization, contact.space.Contacts, object, id)
for (const channel of channels) {
await client.addCollection(contact.class.Channel, contact.space.Contacts, id, contact.class.Organization, 'channels', {
value: channel.value,
provider: channel.provider
})
}
dispatch('close')
}
let channels: Channel[] = []
</script>
<Card
@ -64,31 +75,7 @@
</div>
<div class="flex-row-center channels">
{#if !object.channels || object.channels.length === 0}
<CircleButton
icon={IconAdd}
size={'small'}
transparent
on:click={(ev) =>
showPopup(SocialEditor, { values: object.channels ?? [] }, ev.target, (result) => {
object.channels = result
})}
/>
<span><Label label={presentation.string.AddSocialLinks} /></span>
{:else}
<Channels value={object.channels} size={'small'} />
<div class="ml-1">
<CircleButton
icon={IconEdit}
size={'small'}
transparent
on:click={(ev) =>
showPopup(SocialEditor, { values: object.channels ?? [] }, ev.target, (result) => {
object.channels = result
})}
/>
</div>
{/if}
<Channels bind:channels={channels} on:change={(e) => { channels = e.detail }} />
</div>
</Card>
@ -102,8 +89,5 @@
}
.channels {
margin-top: 1.25rem;
span {
margin-left: 0.5rem;
}
}
</style>

View File

@ -15,21 +15,23 @@
-->
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import type { Data } from '@anticrm/core'
import { getResource } from '@anticrm/platform';
import { Data, generateId } from '@anticrm/core'
import { getResource } from '@anticrm/platform'
import presentation, { getClient, Card, Channels, EditableAvatar } from '@anticrm/presentation'
import { getClient, Card, EditableAvatar } from '@anticrm/presentation'
import attachment from '@anticrm/attachment'
import { EditBox, showPopup, CircleButton, IconEdit, IconAdd, Label } from '@anticrm/ui'
import SocialEditor from './SocialEditor.svelte'
import { EditBox } from '@anticrm/ui'
import { combineName, Person } from '@anticrm/contact'
import { Channel, combineName, Person } from '@anticrm/contact'
import contact from '../plugin'
import Channels from './Channels.svelte'
let firstName = ''
let lastName = ''
const id = generateId()
export function canClose (): boolean {
return firstName === '' && lastName === ''
}
@ -60,10 +62,18 @@
...avatarProp
}
await client.createDoc(contact.class.Person, contact.space.Contacts, person)
await client.createDoc(contact.class.Person, contact.space.Contacts, person, id)
for (const channel of channels) {
await client.addCollection(contact.class.Channel, contact.space.Contacts, id, contact.class.Person, 'channels', {
value: channel.value,
provider: channel.provider
})
}
dispatch('close')
}
let channels: Channel[] = []
</script>
<Card
@ -87,39 +97,12 @@
</div>
<div class="flex-row-center channels">
{#if !object.channels || object.channels.length === 0}
<CircleButton
icon={IconAdd}
size={'small'}
transparent
on:click={(ev) =>
showPopup(SocialEditor, { values: object.channels ?? [] }, ev.target, (result) => {
object.channels = result
})}
/>
<span><Label label={presentation.string.AddSocialLinks} /></span>
{:else}
<Channels value={object.channels} size={'small'} />
<div class="ml-1">
<CircleButton
icon={IconEdit}
size={'small'}
transparent
on:click={(ev) =>
showPopup(SocialEditor, { values: object.channels ?? [] }, ev.target, (result) => {
object.channels = result
})}
/>
</div>
{/if}
<Channels bind:channels={channels} on:change={(e) => { channels = e.detail }} />
</div>
</Card>
<style lang="scss">
.channels {
margin-top: 1.25rem;
span {
margin-left: 0.5rem;
}
}
</style>

View File

@ -16,13 +16,14 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte'
import { getCurrentAccount, Ref, Space } from '@anticrm/core'
import { CircleButton, EditBox, showPopup, IconAdd, Label, IconActivity } from '@anticrm/ui'
import presentation, { getClient, createQuery, Channels } from '@anticrm/presentation'
import { CircleButton, EditBox, IconActivity } from '@anticrm/ui'
import { getClient, createQuery } from '@anticrm/presentation'
import setting from '@anticrm/setting'
import { IntegrationType } from '@anticrm/setting'
import contact from '../plugin'
import { Organization } from '@anticrm/contact'
import Company from './icons/Company.svelte'
import ChannelsEditor from './ChannelsEditor.svelte'
export let object: Organization
@ -30,13 +31,6 @@
const dispatch = createEventDispatcher()
function saveChannels (result: any) {
if (result !== undefined) {
object.channels = result
client.updateDoc(object._class, object.space, object._id, { channels: result })
}
}
function nameChange () {
client.updateDoc(object._class, object.space, object._id, { name: object.name })
}
@ -64,31 +58,7 @@
</div>
<div class="flex-between channels">
<div class="flex-row-center">
{#if !object.channels || object.channels.length === 0}
<CircleButton
icon={IconAdd}
size={'small'}
selected
on:click={(ev) =>
showPopup(contact.component.SocialEditor, { values: object.channels ?? [] }, ev.target, (result) => {
saveChannels(result)
})}
/>
<span><Label label={presentation.string.AddSocialLinks} /></span>
{:else}
<Channels value={object.channels} size={'small'} {integrations} on:click />
<div class="ml-1">
<CircleButton
icon={contact.icon.Edit}
size={'small'}
selected
on:click={(ev) =>
showPopup(contact.component.SocialEditor, { values: object.channels ?? [] }, ev.target, (result) => {
saveChannels(result)
})}
/>
</div>
{/if}
<ChannelsEditor attachedTo={object._id} attachedClass={object._class} {integrations} />
</div>
<div class="flex-row-center">
@ -117,8 +87,5 @@
}
.channels {
margin-top: 0.75rem;
span {
margin-left: 0.5rem;
}
}
</style>

View File

@ -16,14 +16,15 @@
<script lang="ts">
import { createEventDispatcher, onMount, afterUpdate } from 'svelte'
import { getCurrentAccount, Ref, Space } from '@anticrm/core'
import { CircleButton, EditBox, showPopup, IconAdd, Label, IconActivity } from '@anticrm/ui'
import presentation, { getClient, createQuery, Channels, EditableAvatar, AttributeEditor } from '@anticrm/presentation'
import { CircleButton, EditBox, IconActivity } from '@anticrm/ui'
import { getClient, createQuery, EditableAvatar, AttributeEditor } from '@anticrm/presentation'
import { getResource } from '@anticrm/platform'
import attachment from '@anticrm/attachment'
import setting from '@anticrm/setting'
import { IntegrationType } from '@anticrm/setting'
import contact from '../plugin'
import { combineName, getFirstName, getLastName, Person } from '@anticrm/contact'
import ChannelsEditor from './ChannelsEditor.svelte'
export let object: Person
@ -34,13 +35,6 @@
const dispatch = createEventDispatcher()
function saveChannels (result: any) {
if (result !== undefined) {
object.channels = result
client.updateDoc(object._class, object.space, object._id, { channels: result })
}
}
function firstNameChange () {
client.updateDoc(object._class, object.space, object._id, {
name: combineName(firstName, getLastName(object.name))
@ -101,31 +95,7 @@
<div class="flex-between channels">
<div class="flex-row-center">
{#if !object.channels || object.channels.length === 0}
<CircleButton
icon={IconAdd}
size={'small'}
selected
on:click={(ev) =>
showPopup(contact.component.SocialEditor, { values: object.channels ?? [] }, ev.target, (result) => {
saveChannels(result)
})}
/>
<span><Label label={presentation.string.AddSocialLinks} /></span>
{:else}
<Channels value={object.channels} size={'small'} {integrations} on:click />
<div class="ml-1">
<CircleButton
icon={contact.icon.Edit}
size={'small'}
selected
on:click={(ev) =>
showPopup(contact.component.SocialEditor, { values: object.channels ?? [] }, ev.target, (result) => {
saveChannels(result)
})}
/>
</div>
{/if}
<ChannelsEditor attachedTo={object._id} attachedClass={object._class} {integrations} />
</div>
<div class="flex-row-center">
@ -147,9 +117,6 @@
}
.channels {
margin-top: 0.75rem;
span {
margin-left: 0.5rem;
}
}
.location {
margin-top: 0.25rem;

View File

@ -14,7 +14,7 @@
// limitations under the License.
-->
<script lang="ts">
import type { Ref } from '@anticrm/core'
import type { AttachedData, Ref } from '@anticrm/core'
import { createEventDispatcher } from 'svelte'
import { EditBox, Button, ScrollBox } from '@anticrm/ui'
import { getClient } from '@anticrm/presentation'
@ -23,7 +23,7 @@
import contact from '../plugin'
export let values: Channel[]
const newValues: Channel[] = []
const newValues: AttachedData<Channel>[] = []
const dispatch = createEventDispatcher()
@ -49,7 +49,7 @@
}
})
function filterUndefined (channels: Channel[]): Channel[] {
function filterUndefined (channels: AttachedData<Channel>[]): AttachedData<Channel>[] {
return channels.filter((channel) => channel.value !== undefined && channel.value.length > 0)
}
</script>

View File

@ -18,6 +18,8 @@ import { Contact, formatName } from '@anticrm/contact'
import { Class, Client, Ref } from '@anticrm/core'
import { Resources } from '@anticrm/platform'
import { Avatar, ObjectSearchResult, UserInfo } from '@anticrm/presentation'
import ChannelsEditor from './components/ChannelsEditor.svelte'
import Channels from './components/Channels.svelte'
import ChannelsPresenter from './components/ChannelsPresenter.svelte'
import ContactPresenter from './components/ContactPresenter.svelte'
import Contacts from './components/Contacts.svelte'
@ -32,7 +34,7 @@ import PersonPresenter from './components/PersonPresenter.svelte'
import SocialEditor from './components/SocialEditor.svelte'
import contact from './plugin'
export { ContactPresenter }
export { Channels, ChannelsEditor, ContactPresenter }
async function queryContact (_class: Ref<Class<Contact>>, client: Client, search: string): Promise<ObjectSearchResult[]> {
return (await client.findAll(_class, { name: { $like: `%${search}%` } }, { limit: 200 })).map(e => ({

View File

@ -15,7 +15,7 @@
import { IntlString, plugin } from '@anticrm/platform'
import type { Plugin, Asset } from '@anticrm/platform'
import type { Doc, Ref, Class, UXObject, Space, Account } from '@anticrm/core'
import type { Doc, Ref, Class, UXObject, Space, Account, AttachedDoc } from '@anticrm/core'
import type { AnyComponent } from '@anticrm/ui'
/**
@ -40,7 +40,7 @@ export interface ChannelProvider extends Doc, UXObject {
/**
* @public
*/
export interface Channel {
export interface Channel extends AttachedDoc {
provider: Ref<ChannelProvider>
value: string
}
@ -53,7 +53,7 @@ export interface Contact extends Doc {
avatar?: string
attachments?: number
comments?: number
channels: Channel[]
channels?: number
city: string
}
@ -121,6 +121,7 @@ export const contactId = 'contact' as Plugin
export default plugin(contactId, {
class: {
ChannelProvider: '' as Ref<Class<ChannelProvider>>,
Channel: '' as Ref<Class<Channel>>,
Contact: '' as Ref<Class<Contact>>,
Person: '' as Ref<Class<Person>>,
Persons: '' as Ref<Class<Persons>>,

View File

@ -64,7 +64,7 @@ class ModelClient implements Client {
async findOne <T extends Doc>(_class: Ref<Class<T>>, query: DocumentQuery<T>, options?: FindOptions<T>): Promise<WithLookup<T> | undefined> {
const result = await this.client.findOne(_class, query, options)
console.info('devmodel# findOne=>', _class, query, options, 'result => ', result, ' =>model', this.client.getModel(), getMetadata(devmodel.metadata.DevModel))
queries.push({ _class, query, options, result: result !== undefined ? [result] : [], findOne: true })
queries.push({ _class, query, options: options as FindOptions<Doc>, result: result !== undefined ? [result] : [], findOne: true })
if (queries.length > 100) {
queries.shift()
}
@ -74,7 +74,7 @@ class ModelClient implements Client {
async findAll<T extends Doc>(_class: Ref<Class<T>>, query: DocumentQuery<T>, options?: FindOptions<T>): Promise<FindResult<T>> {
const result = await this.client.findAll(_class, query, options)
console.info('devmodel# findAll=>', _class, query, options, 'result => ', result, ' =>model', this.client.getModel(), getMetadata(devmodel.metadata.DevModel))
queries.push({ _class, query, options, result, findOne: false })
queries.push({ _class, query, options: options as FindOptions<Doc>, result, findOne: false })
if (queries.length > 100) {
queries.shift()
}

View File

@ -14,16 +14,24 @@
// limitations under the License.
-->
<script lang="ts">
import contact, { Contact } from '@anticrm/contact'
import contact, { Channel, Contact } from '@anticrm/contact'
import { SharedMessage } from '@anticrm/gmail'
import NewMessage from './NewMessage.svelte'
import FullMessage from './FullMessage.svelte'
import Chats from './Chats.svelte'
import { getClient } from '@anticrm/presentation'
export let object: Contact
let newMessage: boolean = false
let currentMessage: SharedMessage | undefined = undefined
$: contactString = object.channels.find((p) => p.provider === contact.channelProvider.Email)
let channelValue: string | undefined = undefined
const client = getClient()
client.findOne(contact.class.Channel, {
attachedTo: object._id,
provider: contact.channelProvider.Email
}).then((res) => channelValue = res?.value)
function back () {
if (newMessage) {
@ -37,12 +45,12 @@
}
</script>
{#if contactString}
{#if channelValue}
{#if newMessage}
<NewMessage {object} contact={contactString.value} {currentMessage} on:close={back} />
<NewMessage {object} contact={channelValue} {currentMessage} on:close={back} />
{:else if currentMessage}
<FullMessage {currentMessage} bind:newMessage on:close={back} />
{:else}
<Chats {object} contactString={contactString.value} bind:newMessage on:select={selectHandler} />
<Chats {object} contactString={channelValue} bind:newMessage on:select={selectHandler} />
{/if}
{/if}

View File

@ -16,13 +16,18 @@
<script lang="ts">
import { Doc, DocumentQuery } from '@anticrm/core'
import { getClient } from '@anticrm/presentation'
import { Icon, Label, ScrollBox, SearchEdit } from '@anticrm/ui'
import { Table } from '@anticrm/view-resources'
import view, { Viewlet } from '@anticrm/view'
import lead from '../plugin'
let search = ''
let resultQuery: DocumentQuery<Doc> = {}
const client = getClient()
const tableDescriptor = client.findOne<Viewlet>(view.class.Viewlet, { attachTo: lead.mixin.Customer, descriptor: view.viewlet.Table })
function updateResultQuery (search: string): void {
resultQuery = (search === '') ? { } : { $search: search }
}
@ -35,7 +40,7 @@
<span class="label"><Label label={lead.string.Customers}/></span>
</div>
</div>
<SearchEdit bind:value={search} on:change={() => {
updateResultQuery(search)
}}/>
@ -44,19 +49,17 @@
<div class="container">
<div class="panel-component">
<ScrollBox vertical stretch noShift>
<Table
_class={lead.mixin.Customer}
config={[
'',
{ key: 'leads', presenter: lead.component.LeadsPresenter, label: lead.string.Leads },
'modifiedOn',
'channels'
]}
options={ {} }
query={ resultQuery }
enableChecking
/>
{#await tableDescriptor then descr}
{#if descr}
<Table
_class={lead.mixin.Customer}
config={descr.config}
options={descr.options}
query={ resultQuery }
enableChecking
/>
{/if}
{/await}
</ScrollBox>
</div>
</div>

View File

@ -15,14 +15,15 @@
<script lang="ts">
import attachment from '@anticrm/attachment'
import contact, { combineName, Person } from '@anticrm/contact'
import contact, { Channel, combineName, Person } from '@anticrm/contact'
import type { Data, MixinData, Ref } from '@anticrm/core'
import { generateId } from '@anticrm/core'
import { getResource, setPlatformStatus, unknownError } from '@anticrm/platform'
import presentation, { EditableAvatar, Card, Channels, getClient, PDFViewer } from '@anticrm/presentation'
import { EditableAvatar, Card, getClient, PDFViewer } from '@anticrm/presentation'
import type { Candidate } from '@anticrm/recruit'
import { CircleButton, EditBox, IconAdd, IconFile as FileIcon, Label, Link, showPopup, Spinner } from '@anticrm/ui'
import { EditBox, IconFile as FileIcon, Label, Link, showPopup, Spinner } from '@anticrm/ui'
import { createEventDispatcher } from 'svelte'
import { Channels } from '@anticrm/contact-resources'
import recruit from '../plugin'
import FileUpload from './icons/FileUpload.svelte'
import YesNo from './YesNo.svelte'
@ -79,6 +80,12 @@
lastModified: resume.lastModified
})
}
for (const channel of channels) {
await client.addCollection(contact.class.Channel, contact.space.Contacts, candidateId, contact.class.Person, 'channels', {
value: channel.value,
provider: channel.provider
})
}
dispatch('close')
}
@ -129,6 +136,8 @@
avatar = file
}
let channels: Channel[] = []
</script>
<!-- <DialogHeader {space} {object} {newValue} {resume} create={true} on:save={createCandidate}/> -->
@ -153,15 +162,7 @@
</div>
<div class="flex-row-center channels">
{#if !object.channels || object.channels.length === 0}
<CircleButton icon={IconAdd} size={'small'} transparent on:click={(ev) => showPopup(contact.component.SocialEditor, { values: object.channels ?? [] }, ev.target, (result) => { object.channels = result })} />
<span><Label label={presentation.string.AddSocialLinks} /></span>
{:else}
<Channels value={object.channels} size={'small'} />
<div class="ml-1">
<CircleButton icon={contact.icon.Edit} size={'small'} transparent on:click={(ev) => showPopup(contact.component.SocialEditor, { values: object.channels ?? [] }, ev.target, (result) => { object.channels = result })} />
</div>
{/if}
<Channels bind:channels={channels} on:change={(e) => { channels = e.detail }} />
</div>
<div class="flex-center resume" class:solid={dragover || resume.uuid}
@ -191,7 +192,6 @@
<style lang="scss">
.channels {
margin-top: 1.25rem;
span { margin-left: .5rem; }
}
.locations {

View File

@ -13,22 +13,22 @@
// limitations under the License.
-->
<script lang="ts">
import presentation, {
import {
AttributeEditor,
Channels,
createQuery,
EditableAvatar,
getClient
} from '@anticrm/presentation'
import setting from '@anticrm/setting'
import { CircleButton, EditBox, Icon, IconAdd, Label, showPopup } from '@anticrm/ui'
import { EditBox, Icon, Label } from '@anticrm/ui'
import contact, { Employee, EmployeeAccount, getFirstName, getLastName } from '@anticrm/contact'
import contactRes from '@anticrm/contact-resources/src/plugin'
import { getCurrentAccount, Ref } from '@anticrm/core'
import { getResource } from '@anticrm/platform'
import attachment from '@anticrm/attachment'
import { changeName } from '@anticrm/login-resources'
import { ChannelsEditor } from '@anticrm/contact-resources'
const client = getClient()
let account: EmployeeAccount | undefined
@ -129,41 +129,7 @@
<div class="flex-between channels">
<div class="flex-row-center">
{#if !employee.channels || employee.channels.length === 0}
<CircleButton
icon={IconAdd}
size={'small'}
selected
on:click={(ev) =>
showPopup(
contact.component.SocialEditor,
{ values: employee?.channels ?? [] },
ev.target,
(result) => {
saveChannels(result)
}
)}
/>
<span><Label label={presentation.string.AddSocialLinks} /></span>
{:else}
<Channels value={employee.channels} size={'small'} />
<div class="ml-1">
<CircleButton
icon={contact.icon.Edit}
size={'small'}
selected
on:click={(ev) =>
showPopup(
contact.component.SocialEditor,
{ values: employee?.channels ?? [] },
ev.target,
(result) => {
saveChannels(result)
}
)}
/>
</div>
{/if}
<ChannelsEditor attachedTo={employee._id} attachedClass={employee._class} />
</div>
</div>
</div>

View File

@ -52,7 +52,7 @@
<CircleButton icon={IconAdd} size={'small'} on:click={createApp} />
</div>
{#if tasks.length > 0}
<Table
<Table
_class={task.class.Issue}
config={['', '$lookup.space.name', '$lookup.state']}
{options}

View File

@ -20,14 +20,17 @@ import core, {
Client,
Collection,
Doc,
FindOptions,
FindResult, Hierarchy, matchQuery, Obj,
FindResult,
Hierarchy,
Lookup,
matchQuery,
Obj,
Ref,
TxOperations
} from '@anticrm/core'
import type { IntlString } from '@anticrm/platform'
import { getResource } from '@anticrm/platform'
import { getAttributePresenterClass } from '@anticrm/presentation'
import { Channels, getAttributePresenterClass } from '@anticrm/presentation'
import { ErrorPresenter, getPlatformColorForText } from '@anticrm/ui'
import type { Action, ActionTarget, BuildModelOptions, ObjectDDParticipant } from '@anticrm/view'
import view, { AttributeModel, BuildModelKey } from '@anticrm/view'
@ -102,12 +105,12 @@ async function getAttributePresenter (
}
}
async function getPresenter (
async function getPresenter<T extends Doc> (
client: Client,
_class: Ref<Class<Obj>>,
_class: Ref<Class<T>>,
key: BuildModelKey,
preserveKey: BuildModelKey,
options?: FindOptions<Doc>
lookup?: Lookup<T>
): Promise<AttributeModel> {
if (key.presenter !== undefined) {
const { presenter, label, sortingKey } = key
@ -122,22 +125,11 @@ async function getPresenter (
if (key.key.length === 0) {
return await getObjectPresenter(client, _class, preserveKey)
} else {
const split = key.key.split('.')
if (split[0] === '$lookup') {
const lookupClass = (options?.lookup as any)[split[1]] as Ref<Class<Obj>>
if (lookupClass === undefined) {
throw new Error('lookup class does not provided for ' + split[1])
if (key.key.startsWith('$lookup')) {
if (lookup === undefined) {
throw new Error('lookup class does not provided for ' + key)
}
const lookupKey = { ...key, key: split[2] ?? '' }
const model = await getPresenter(client, lookupClass, lookupKey, preserveKey)
if (lookupKey.key === '') {
const attribute = client.getHierarchy().getAttribute(_class, split[1])
model.label = attribute.label
} else {
const attribute = client.getHierarchy().getAttribute(lookupClass, lookupKey.key)
model.label = attribute.label
}
return model
return await getLookupPresenter(client, _class, key, preserveKey, lookup)
}
return await getAttributePresenter(client, _class, key.key, preserveKey)
}
@ -150,7 +142,7 @@ export async function buildModel (options: BuildModelOptions): Promise<Attribute
.map((key) => (typeof key === 'string' ? { key: key } : key))
.map(async (key) => {
try {
return await getPresenter(options.client, options._class, key, key, options.options)
return await getPresenter(options.client, options._class, key, key, options.options?.lookup)
} catch (err: any) {
if (options.ignoreMissing ?? false) {
return undefined
@ -250,3 +242,65 @@ export function getMixinStyle (id: Ref<Class<Doc>>, selected: boolean): string {
border: 1px solid ${color + (selected ? '0f' : '66')};
`
}
async function getLookupPresenter<T extends Doc> (client: Client, _class: Ref<Class<T>>, key: BuildModelKey, preserveKey: BuildModelKey, lookup: Lookup<T>): Promise<AttributeModel> {
const lookupClass = getLookupClass(key.key, lookup, _class)
const lookupProperty = getLookupProperty(key.key)
const lookupKey = { ...key, key: lookupProperty[0] }
const model = await getPresenter(client, lookupClass[0], lookupKey, preserveKey)
model.label = getLookupLabel(client, lookupClass[1], lookupClass[0], lookupKey, lookupProperty[1])
return model
}
function getLookupLabel<T extends Doc> (client: Client, _class: Ref<Class<T>>, lookupClass: Ref<Class<Doc>>, key: BuildModelKey, attrib: string): IntlString {
if (key.label !== undefined) return key.label
if (key.key === '') {
try {
const attribute = client.getHierarchy().getAttribute(_class, attrib)
return attribute.label
} catch {}
const clazz = client.getHierarchy().getClass(lookupClass)
return clazz.label
} else {
const attribute = client.getHierarchy().getAttribute(lookupClass, key.key)
return attribute.label
}
}
function getLookupClass<T extends Doc> (key: string, lookup: Lookup<T>, parent: Ref<Class<T>>): [Ref<Class<Doc>>, Ref<Class<Doc>>] {
const _class = getLookup(key, lookup, parent)
if (_class === undefined) {
throw new Error('lookup class does not provided for ' + key)
}
return _class
}
function getLookupProperty (key: string): [string, string] {
const parts = key.split('$lookup')
const lastPart = parts[parts.length - 1]
const split = lastPart.split('.').filter((p) => p.length > 0)
const prev = split.shift() ?? ''
const result = split.join('.')
return [result, prev]
}
function getLookup (key: string, lookup: Lookup<any>, parent: Ref<Class<Doc>>): [Ref<Class<Doc>>, Ref<Class<Doc>>] | undefined {
const parts = key.split('$lookup.').filter((p) => p.length > 0)
const currentKey = parts[0].split('.').filter((p) => p.length > 0)[0]
const current = (lookup as any)[currentKey]
const nestedKey = parts.slice(1).join('$lookup.')
if (nestedKey) {
if (!Array.isArray(current)) {
return
}
return getLookup(nestedKey, current[1], current[0])
}
if (Array.isArray(current)) {
return [current[0], parent]
}
if (current === undefined && lookup._id !== undefined) {
const reverse = (lookup._id as any)[currentKey]
return reverse !== undefined ? [reverse, parent] : undefined
}
return current !== undefined ? [current, parent] : undefined
}

View File

@ -406,8 +406,7 @@ async function createEmployeeAccount (account: Account, workspace: string): Prom
if (existingAccount === undefined) {
const employee = await ops.createDoc(contact.class.Employee, contact.space.Employee, {
name,
city: '',
channels: []
city: ''
})
await ops.createDoc(contact.class.EmployeeAccount, core.space.Model, {
@ -421,8 +420,7 @@ async function createEmployeeAccount (account: Account, workspace: string): Prom
// Employee was deleted, let's restore it.
const employeeId = await ops.createDoc(contact.class.Employee, contact.space.Employee, {
name,
city: '',
channels: []
city: ''
})
await ops.updateDoc(contact.class.EmployeeAccount, existingAccount.space, existingAccount._id, { employee: employeeId })
}

View File

@ -25,13 +25,12 @@ import core, {
FindOptions,
FindResult,
generateId,
Hierarchy, ModelDb, Ref,
Hierarchy, MeasureMetricsContext, ModelDb, Ref,
SortingOrder,
Space,
Tx,
TxOperations,
TxResult,
MeasureMetricsContext
TxResult
} from '@anticrm/core'
import { createServerStorage, DbAdapter, DbConfiguration, FullTextAdapter, IndexedDoc } from '@anticrm/server-core'
import { MongoClient } from 'mongodb'
@ -277,7 +276,7 @@ describe('mongo operations', () => {
rate: 20
})
await operations.addCollection(taskPlugin.class.TaskComment, '' as Ref<Space>, docId, taskPlugin.class.Task, 'tasks', {
const commentId = await operations.addCollection(taskPlugin.class.TaskComment, '' as Ref<Space>, docId, taskPlugin.class.Task, 'tasks', {
message: 'my-msg',
date: new Date()
})
@ -287,16 +286,34 @@ describe('mongo operations', () => {
date: new Date()
})
const r = await client.findAll<Task>(taskPlugin.class.TaskComment, {})
expect(r.length).toEqual(2)
const r2 = await client.findAll<TaskComment>(taskPlugin.class.TaskComment, {}, {
lookup: {
attachedTo: taskPlugin.class.Task
}
})
expect(r2.length).toEqual(2)
console.log(JSON.stringify(r2, undefined, 2))
expect((r2[0].$lookup?.attachedTo as Task)?._id).toEqual(docId)
const r3 = await client.findAll<Task>(taskPlugin.class.Task, {}, {
lookup: {
_id: { comment: taskPlugin.class.TaskComment }
}
})
expect(r3).toHaveLength(1)
expect((r3[0].$lookup as any).comment).toHaveLength(2)
const comment2Id = await operations.addCollection(taskPlugin.class.TaskComment, '' as Ref<Space>, commentId, taskPlugin.class.TaskComment, 'comments', {
message: 'my-msg3',
date: new Date()
})
const r4 = await client.findAll<TaskComment>(taskPlugin.class.TaskComment, {
_id: comment2Id
}, {
lookup: { attachedTo: [taskPlugin.class.TaskComment, { attachedTo: taskPlugin.class.Task } as any] }
})
expect((r4[0].$lookup?.attachedTo as TaskComment)?._id).toEqual(commentId)
expect(((r4[0].$lookup?.attachedTo as any)?.$lookup.attachedTo as Task)?._id).toEqual(docId)
})
})

View File

@ -16,8 +16,7 @@
import core, {
Class,
Doc,
DocumentQuery, DOMAIN_MODEL, DOMAIN_TX, FindOptions,
FindResult, Hierarchy, isOperator, Mixin, ModelDb, Ref, SortingOrder, Tx,
DocumentQuery, DOMAIN_MODEL, DOMAIN_TX, FindOptions, FindResult, Hierarchy, isOperator, Lookup, Mixin, ModelDb, Ref, ReverseLookups, SortingOrder, Tx,
TxCreateDoc,
TxMixin, TxProcessor, TxPutBag,
TxRemoveDoc,
@ -32,6 +31,13 @@ function translateDoc (doc: Doc): Document {
return doc as Document
}
interface LookupStep {
from: string
localField: string
foreignField: string
as: string
}
abstract class MongoAdapterBase extends TxProcessor {
constructor (protected readonly db: Db, protected readonly hierarchy: Hierarchy, protected readonly modelDb: ModelDb, protected readonly client: MongoClient) {
super()
@ -76,6 +82,118 @@ abstract class MongoAdapterBase extends TxProcessor {
return translated
}
private async getLookupValue<T extends Doc> (lookup: Lookup<T>, result: LookupStep[], parent?: string): Promise<void> {
for (const key in lookup) {
if (key === '_id') {
await this.getReverseLookupValue(lookup, result, parent)
continue
}
const value = (lookup as any)[key]
const fullKey = parent !== undefined ? parent + '.' + key : key
if (Array.isArray(value)) {
const [_class, nested] = value
const domain = this.hierarchy.getDomain(_class)
if (domain !== DOMAIN_MODEL) {
result.push({
from: domain,
localField: fullKey,
foreignField: '_id',
as: fullKey.split('.').join('') + '_lookup'
})
}
await this.getLookupValue(nested, result, fullKey + '_lookup')
} else {
const _class = value as Ref<Class<Doc>>
const domain = this.hierarchy.getDomain(_class)
if (domain !== DOMAIN_MODEL) {
result.push({
from: domain,
localField: fullKey,
foreignField: '_id',
as: fullKey.split('.').join('') + '_lookup'
})
}
}
}
}
private async getReverseLookupValue (lookup: ReverseLookups, result: LookupStep[], parent?: string): Promise<any | undefined> {
const fullKey = parent !== undefined ? parent + '.' + '_id' : '_id'
for (const key in lookup._id) {
const as = parent !== undefined ? parent + key : key
const value = lookup._id[key]
const domain = this.hierarchy.getDomain(value)
if (domain !== DOMAIN_MODEL) {
const step = {
from: domain,
localField: fullKey,
foreignField: 'attachedTo',
as: as.split('.').join('') + '_lookup'
}
result.push(step)
}
}
}
private async getLookups<T extends Doc> (lookup: Lookup<T> | undefined, parent?: string): Promise<LookupStep[]> {
if (lookup === undefined) return []
const result: [] = []
await this.getLookupValue(lookup, result, parent)
return result
}
private async fillLookup<T extends Doc> (_class: Ref<Class<T>>, object: any, key: string, fullKey: string, targetObject: any): Promise<void> {
if (targetObject.$lookup === undefined) {
targetObject.$lookup = {}
}
const domain = this.hierarchy.getDomain(_class)
if (domain !== DOMAIN_MODEL) {
const arr = object[fullKey]
targetObject.$lookup[key] = arr?.[0]
} else {
targetObject.$lookup[key] = this.modelDb.getObject(targetObject[key])
}
}
private async fillLookupValue<T extends Doc> (lookup: Lookup<T> | undefined, object: any, parent?: string, parentObject?: any): Promise<void> {
if (lookup === undefined) return
for (const key in lookup) {
if (key === '_id') {
await this.fillReverseLookup(lookup, object, parent, parentObject)
continue
}
const value = (lookup as any)[key]
const fullKey = parent !== undefined ? parent + key + '_lookup' : key + '_lookup'
const targetObject = parentObject ?? object
if (Array.isArray(value)) {
const [_class, nested] = value
await this.fillLookup(_class, object, key, fullKey, targetObject)
await this.fillLookupValue(nested, object, fullKey, targetObject.$lookup[key])
} else {
await this.fillLookup(value, object, key, fullKey, targetObject)
}
}
}
private async fillReverseLookup (lookup: ReverseLookups, object: any, parent?: string, parentObject?: any): Promise<void> {
const targetObject = parentObject ?? object
if (targetObject.$lookup === undefined) {
targetObject.$lookup = {}
}
for (const key in lookup._id) {
const value = lookup._id[key]
const domain = this.hierarchy.getDomain(value)
const fullKey = parent !== undefined ? parent + key + '_lookup' : key + '_lookup'
if (domain !== DOMAIN_MODEL) {
const arr = object[fullKey]
targetObject.$lookup[key] = arr
} else {
const arr = await this.modelDb.findAll(value, { attachedTo: targetObject._id })
targetObject.$lookup[key] = arr
}
}
}
private async lookup<T extends Doc>(
clazz: Ref<Class<T>>,
query: DocumentQuery<T>,
@ -83,29 +201,29 @@ abstract class MongoAdapterBase extends TxProcessor {
): Promise<FindResult<T>> {
const pipeline = []
pipeline.push({ $match: this.translateQuery(clazz, query) })
const lookups = options.lookup as any
for (const key in lookups) {
const clazz = lookups[key]
const domain = this.hierarchy.getDomain(clazz)
if (domain !== DOMAIN_MODEL) {
const step = {
from: domain,
localField: key,
foreignField: '_id',
as: key + '_lookup'
}
pipeline.push({ $lookup: step })
}
const steps = await this.getLookups(options.lookup)
for (const step of steps) {
pipeline.push({ $lookup: step })
}
if (options.sort !== undefined) {
const sort = {} as any
for (const _key in options.sort) {
let key = _key as string
if (_key.startsWith('$lookup.')) {
key = key.replace('$lookup.', '')
const keys = key.split('.')
keys[0] = keys[0] + '_lookup'
key = keys.join('.')
const arr = key.split('.').filter((p) => p)
key = ''
for (let i = 0; i < arr.length; i++) {
const element = arr[i]
if (element === '$lookup') {
key += arr[++i] + '_lookup'
} else {
if (!key.endsWith('.') && i > 0) {
key += '.'
}
key += arr[i]
if (i !== arr.length - 1) {
key += '.'
}
}
}
sort[key] = options.sort[_key] === SortingOrder.Ascending ? 1 : -1
}
@ -118,18 +236,8 @@ abstract class MongoAdapterBase extends TxProcessor {
const cursor = this.db.collection(domain).aggregate(pipeline)
const result = (await cursor.toArray()) as FindResult<T>
for (const row of result) {
const object = row as any
object.$lookup = {}
for (const key in lookups) {
const clazz = lookups[key]
const domain = this.hierarchy.getDomain(clazz)
if (domain !== DOMAIN_MODEL) {
const arr = object[key + '_lookup']
object.$lookup[key] = arr[0]
} else {
object.$lookup[key] = this.modelDb.getObject(object[key])
}
}
row.$lookup = {}
await this.fillLookupValue(options.lookup, row)
}
return result
}

View File

@ -5,7 +5,7 @@
"author": "Anticrm Platform Contributors",
"license": "EPL-2.0",
"scripts": {
"start": "MONGO_URL=mongodb://localhost:27017 ELASTIC_URL=http://localhost:9200 MINIO_ENDPOINT=localhost MINIO_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin ts-node src/__start.ts",
"start": "cross-env MONGO_URL=mongodb://localhost:27017 ELASTIC_URL=http://localhost:9200 MINIO_ENDPOINT=localhost MINIO_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin ts-node src/__start.ts",
"build": "heft build",
"lint:fix": "eslint --fix src",
"bundle": "esbuild src/__start.ts --bundle --platform=node > bundle.js",
@ -16,6 +16,7 @@
"format": "prettier --write src && eslint --fix src"
},
"devDependencies": {
"cross-env": "^7.0.3",
"@anticrm/platform-rig": "~0.6.0",
"@types/heft-jest": "^1.0.2",
"@types/node": "^16.6.2",