diff --git a/.changeset/two-oranges-march.md b/.changeset/two-oranges-march.md new file mode 100644 index 00000000..4515788f --- /dev/null +++ b/.changeset/two-oranges-march.md @@ -0,0 +1,10 @@ +--- +'rushdb-core': patch +'rushdb-docs': patch +'@rushdb/javascript-sdk': patch +'@rushdb/mcp-server': patch +'@rushdb/skills': patch +'rushdb-dashboard': patch +--- + +Import and querying stability improvements diff --git a/.github/scripts/notify-content-release.mjs b/.github/scripts/notify-content-release.mjs new file mode 100644 index 00000000..8fd5154c --- /dev/null +++ b/.github/scripts/notify-content-release.mjs @@ -0,0 +1,35 @@ +import { createHmac } from 'node:crypto' + +const required = (name) => { + const value = process.env[name] + if (!value) throw new Error(`Missing required environment variable: ${name}`) + return value +} + +const version = required('RELEASE_VERSION') +const body = JSON.stringify({ + event: 'release.completed', + repository: process.env.GITHUB_REPOSITORY ?? 'rush-db/rushdb', + version, + tag: `v${version}`, + sha: required('RELEASE_SHA'), + workflowUrl: required('RELEASE_RUN_URL'), + packages: [ + { name: '@rushdb/javascript-sdk', version }, + { name: '@rushdb/mcp-server', version } + ], + occurredAt: new Date().toISOString() +}) +const timestamp = String(Math.floor(Date.now() / 1000)) +const signature = `sha256=${createHmac('sha256', required('CONTENT_WEBHOOK_SECRET')).update(`${timestamp}.${body}`).digest('hex')}` +const response = await fetch(required('CONTENT_WEBHOOK_URL'), { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-content-timestamp': timestamp, + 'x-content-signature': signature + }, + body +}) +if (!response.ok) throw new Error(`Content webhook failed (${response.status}): ${await response.text()}`) +console.log(await response.text()) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cea2de5b..268b9acf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -65,7 +65,8 @@ jobs: STATUS=$(pnpm changeset status --no-color --verbose || true) VERSION=$(echo "$STATUS" | grep -E '@rushdb/(javascript-sdk|mcp-server)' | awk '{print $NF}' | sed 's/[^[:print:]]//g' | head -n1) if [ -z "$VERSION" ]; then - echo "No upcoming version detected for javascript-sdk or mcp-server; leaving empty" + VERSION=$(node -p "require('./packages/javascript-sdk/package.json').version") + echo "No upcoming changeset version; using checked-in SDK version ${VERSION}" fi echo "version=${VERSION}" >> $GITHUB_OUTPUT @@ -298,3 +299,21 @@ jobs: service: ${{ env.ECS_SERVICE_MCP }} cluster: ${{ env.ECS_CLUSTER }} wait-for-service-stability: false + + notify-content: + name: Notify content control plane + needs: [release, docker, deploy] + if: needs.release.outputs.is_version_bump == 'true' && needs.deploy.result == 'success' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Send signed release event + env: + CONTENT_WEBHOOK_URL: ${{ secrets.CONTENT_WEBHOOK_URL }} + CONTENT_WEBHOOK_SECRET: ${{ secrets.CONTENT_WEBHOOK_SECRET }} + RELEASE_VERSION: ${{ needs.release.outputs.version }} + RELEASE_SHA: ${{ github.sha }} + RELEASE_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: node .github/scripts/notify-content-release.mjs diff --git a/e2e/import-write-path.e2e-spec.ts b/e2e/import-write-path.e2e-spec.ts index 6f7c7ae7..b516298d 100644 --- a/e2e/import-write-path.e2e-spec.ts +++ b/e2e/import-write-path.e2e-spec.ts @@ -104,7 +104,9 @@ describe('record import write path (e2e)', () => { key: `r-${i}`, name: `Section ${i}` })) + const firstStartedAt = Date.now() await db.records.createMany({ label, data: first, options }) + const firstElapsed = Date.now() - firstStartedAt const afterFirst = await db.records.find({ labels: [label], limit: 1 }) expect(afterFirst.total).toBe(100) @@ -114,7 +116,14 @@ describe('record import write path (e2e)', () => { key: `r-${i + 50}`, grade: 'III' })) + const secondStartedAt = Date.now() await db.records.createMany({ label, data: second, options }) + const secondElapsed = Date.now() - secondStartedAt + // The reported bug was ~0.45s/merged row driven by an unindexed, project-wide (not + // label-scoped) match — a 100-row batch took 40+ seconds. Scoping the match to this + // label's node set must keep a 100-row merge on an idle local stack far below that. + expect(firstElapsed).toBeLessThan(20_000) + expect(secondElapsed).toBeLessThan(20_000) const afterSecond = await db.records.find({ labels: [label], limit: 1 }) expect(afterSecond.total).toBe(150) diff --git a/e2e/sdk/records.find.where-nested-pagination.e2e.test.ts b/e2e/sdk/records.find.where-nested-pagination.e2e.test.ts new file mode 100644 index 00000000..418afc5c --- /dev/null +++ b/e2e/sdk/records.find.where-nested-pagination.e2e.test.ts @@ -0,0 +1,127 @@ +/** + * E2E regression test for a bug where a related-label `where` traversal filter + * combined with `skip`/`limit` returned incomplete `data` while `total` stayed + * correct. + * + * Root cause (fixed in EntityQueryService.findRecords): SKIP/LIMIT/ORDER BY were + * glued onto the root MATCH clause, which ran *before* the OPTIONAL MATCH + * traversal to the related label and before the WITH...WHERE barrier that + * applies the traversal predicate. So pagination sliced the raw, unfiltered + * root scan first, and only afterwards was the traversal filter applied to + * whatever records happened to survive that slice — silently dropping matches + * that lived outside the pagination window. + * + * This test pins down an explicit, deterministic ordering (via a `seq` + * property) so the reproduction doesn't depend on incidental id ordering: + * parent "A" owns children seq 0-149, parent "B" owns children seq 150-299. + * Querying children scoped to "B", ordered by seq ascending, means the first + * `limit` window of the *unfiltered* scan is entirely "A"'s children — exactly + * the failure mode from the bug report. + * + * Prerequisites + * ───────────── + * RUSHDB_API_KEY and RUSHDB_API_URL must be set in packages/javascript-sdk/.env + * + * If RUSHDB_API_KEY is absent the whole suite is skipped gracefully. + */ + +import RushDB from '../../packages/javascript-sdk/src/index.node' + +jest.setTimeout(60_000) + +const apiKey = process.env.RUSHDB_API_KEY +const apiUrl = process.env.RUSHDB_API_URL || 'http://localhost:3000' + +if (!apiKey) { + describe('records.find() – traversal filter + pagination (e2e)', () => { + it('skips because RUSHDB_API_KEY is not set', () => expect(true).toBe(true)) + }) +} else { + const db = new RushDB(apiKey, { url: apiUrl }) + + const tenantId = `where-nested-pg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` + + const L_PARENT = 'PGParent' + const L_CHILD = 'PGChild' + + const CHILDREN_PER_PARENT = 150 + + beforeAll(async () => { + await db.records.delete({ where: { tenantId } }).catch(() => {}) + + await db.records.importJson({ + label: L_PARENT, + data: [ + { + name: 'A', + tenantId, + [L_CHILD]: Array.from({ length: CHILDREN_PER_PARENT }, (_, i) => ({ seq: i, tenantId })) + }, + { + name: 'B', + tenantId, + [L_CHILD]: Array.from({ length: CHILDREN_PER_PARENT }, (_, i) => ({ + seq: i + CHILDREN_PER_PARENT, + tenantId + })) + } + ] + }) + }) + + afterAll(async () => { + await db.records.delete({ where: { tenantId } }).catch(() => {}) + }) + + describe('records.find() – traversal filter + pagination (e2e)', () => { + it('reports the correct total for a related-label filter beyond the first page', async () => { + const res = await db.records.find({ + labels: [L_CHILD], + where: { tenantId, [L_PARENT]: { name: 'B' } }, + orderBy: { seq: 'asc' }, + limit: 100 + }) + expect(res.total).toBe(CHILDREN_PER_PARENT) + }) + + it('returns a full first page of matches for a parent whose children sit outside the raw scan window', async () => { + const res = await db.records.find({ + labels: [L_CHILD], + where: { tenantId, [L_PARENT]: { name: 'B' } }, + orderBy: { seq: 'asc' }, + limit: 100 + }) + + expect(res.data).toHaveLength(100) + // Every returned record must actually belong to parent B (seq >= 150), + // never a record from A that leaked in from the unfiltered window. + expect((res.data as any[]).every((r) => r.data.seq >= CHILDREN_PER_PARENT)).toBe(true) + }) + + it('returns the remaining matches on the second page', async () => { + const res = await db.records.find({ + labels: [L_CHILD], + where: { tenantId, [L_PARENT]: { name: 'B' } }, + orderBy: { seq: 'asc' }, + skip: 100, + limit: 100 + }) + + expect(res.data).toHaveLength(50) + expect((res.data as any[]).every((r) => r.data.seq >= CHILDREN_PER_PARENT)).toBe(true) + }) + + it('still paginates correctly for a parent whose children DO sit inside the first window (regression sanity check)', async () => { + const res = await db.records.find({ + labels: [L_CHILD], + where: { tenantId, [L_PARENT]: { name: 'A' } }, + orderBy: { seq: 'asc' }, + limit: 100 + }) + + expect(res.total).toBe(CHILDREN_PER_PARENT) + expect(res.data).toHaveLength(100) + expect((res.data as any[]).every((r) => r.data.seq < CHILDREN_PER_PARENT)).toBe(true) + }) + }) +} diff --git a/platform/core/src/core/entity/entity-query.service.ts b/platform/core/src/core/entity/entity-query.service.ts index c9b19d9a..1dd4967c 100755 --- a/platform/core/src/core/entity/entity-query.service.ts +++ b/platform/core/src/core/entity/entity-query.service.ts @@ -187,8 +187,9 @@ export class EntityQueryService { importUpsertRecords({ withResults = false, rewrite = false, - mergeBy - }: { withResults?: boolean; rewrite?: boolean; mergeBy?: string[] } = {}) { + mergeBy, + label: recordLabel + }: { withResults?: boolean; rewrite?: boolean; mergeBy?: string[]; label?: string } = {}) { const queryBuilder = new QueryBuilder() // When mergeBy is provided, inline the keys as static property accesses @@ -200,6 +201,15 @@ export class EntityQueryService { const staticMergeKeys = (mergeBy ?? []).filter((k) => typeof k === 'string' && k.trim().length > 0) const useStaticMergeKeys = staticMergeKeys.length > 0 + // Callers group an upsert batch by business label before calling this method, so every + // row in $records shares `label` — inline it as a static pattern label (record:Record:`Label`) + // instead of matching every :Record in the project and filtering by r.label afterward. This + // scopes the match to one business label's node set rather than the whole project. Rows with + // no label (rare: an unlabeled root import) are called without `label` and fall back to the + // original untyped match + runtime label check. + const labelPart = recordLabel ? `:\`${recordLabel.replace(/`/g, '``')}\`` : '' + const labelGuard = labelPart ? '' : '(r.label IS NULL OR ANY(l IN labels(record) WHERE l = r.label)) AND ' + queryBuilder .append(`WITH $records as recordsToUpsert, datetime() as time, coalesce($mergeBy, []) as mergeBy`) .append(`UNWIND recordsToUpsert as r`) @@ -217,17 +227,13 @@ export class EntityQueryService { .join(' AND ') queryBuilder - .append(`OPTIONAL MATCH (record:${RUSHDB_LABEL_RECORD} { ${projectIdInline()} })`) - .append( - `WHERE (r.label IS NULL OR ANY(l IN labels(record) WHERE l = r.label)) AND ${mergePredicates}` - ) + .append(`OPTIONAL MATCH (record:${RUSHDB_LABEL_RECORD}${labelPart} { ${projectIdInline()} })`) + .append(`WHERE ${labelGuard}${mergePredicates}`) } else { queryBuilder .append(`WITH *, CASE WHEN size(mergeBy)=0 THEN keys(valuesMap) ELSE mergeBy END as keysToMatch`) - .append(`OPTIONAL MATCH (record:${RUSHDB_LABEL_RECORD} { ${projectIdInline()} })`) - .append( - `WHERE (r.label IS NULL OR ANY(l IN labels(record) WHERE l = r.label)) AND ALL(k IN keysToMatch WHERE record[k] = valuesMap[k])` - ) + .append(`OPTIONAL MATCH (record:${RUSHDB_LABEL_RECORD}${labelPart} { ${projectIdInline()} })`) + .append(`WHERE ${labelGuard}ALL(k IN keysToMatch WHERE record[k] = valuesMap[k])`) } queryBuilder @@ -290,17 +296,7 @@ export class EntityQueryService { : { ...buildAggregation(searchQuery?.aggregate, aliasesMap, searchQuery?.groupBy ?? []), matchPart: '' } // convert a clause array to string - const normalizedQueryClauses = queryClauses - .map((clause, index) => { - if (!orderByAggregatedField && index === 0) { - return `${clause} ${sortParams} ${pagination}`.trim() - } else { - return clause - } - }) - // @FYI: Keep it in this order - .filter(toBoolean) - .join(`\n`) + const normalizedQueryClauses = queryClauses.filter(toBoolean).join(`\n`) const queryBuilder = new QueryBuilder() @@ -314,6 +310,15 @@ export class EntityQueryService { queryBuilder.append(`WITH ${parsedWhere.nodeAliases.join(', ')} ${wherePart}`.trim()) } + // @FYI: Pagination/sort must be applied AFTER the traversal WITH...WHERE barrier + // above — otherwise SKIP/LIMIT truncates the root record scan before related-label + // matches are even evaluated, silently dropping rows that only appear once the + // traversal filter is applied. For the aggregated-order-by case, ORDER BY must + // additionally follow aggregateProjections since it sorts by the computed field. + if (!orderByAggregatedField) { + queryBuilder.append(`${sortParams} ${pagination}`.trim()) + } + if (matchPart) { queryBuilder.append(matchPart) } @@ -321,7 +326,7 @@ export class EntityQueryService { queryBuilder.append(aggregateProjections) if (orderByAggregatedField) { - queryBuilder.append(`${sortParams} ${pagination}`) + queryBuilder.append(`${sortParams} ${pagination}`.trim()) } queryBuilder.append(`RETURN ${returnPart}`) diff --git a/platform/core/src/core/entity/import-export/import.service.ts b/platform/core/src/core/entity/import-export/import.service.ts index bf508c2f..cfea037c 100644 --- a/platform/core/src/core/entity/import-export/import.service.ts +++ b/platform/core/src/core/entity/import-export/import.service.ts @@ -360,20 +360,14 @@ export class ImportService { }) // Extract id map and results (if requested) - const idmap = data.records?.[0]?.get('idmap') ?? [] - if (Array.isArray(idmap)) { - for (const item of idmap) { - if (item && item.draftId && item.persistedId) { - draftToPersistedId.set(item.draftId, item.persistedId) - } + for (const item of data.idmap) { + if (item && item.draftId && item.persistedId) { + draftToPersistedId.set(item.draftId, item.persistedId) } } if (shouldAccumulateResult) { - const chunkData = data.records?.[0]?.get('data') - if (Array.isArray(chunkData)) { - result = result.concat(chunkData) - } + result = result.concat(data.data) } } @@ -457,30 +451,71 @@ export class ImportService { recordsChunk: WithId[] transaction: Transaction options: TImportOptions - }) { + }): Promise<{ idmap: Array<{ draftId: string; persistedId: string }>; data: unknown[] }> { // Upsert path if mergeStrategy or mergeBy provided (mergeStrategy defaults to 'append' behavior when omitted). const upsertRequested = typeof options.mergeStrategy !== 'undefined' || Array.isArray(options.mergeBy) if (upsertRequested) { const rewrite = options.mergeStrategy === 'rewrite' - return transaction.run( - this.entityQueryService.importUpsertRecords({ - withResults: options.returnResult, - rewrite, - mergeBy: options.mergeBy - }), - { - records: recordsChunk, - projectId, - mergeBy: options.mergeBy, - rewrite + + // Group rows by business label so each upsert query can match against just that + // label's node set (a static pattern label) instead of scanning every record in the + // project. Rows without a label (rare: an unlabeled root import) go in their own + // group and fall back to the untyped match inside importUpsertRecords. + const groups = new Map[]>() + for (const record of recordsChunk) { + const key = record.label ?? '' + const group = groups.get(key) + if (group) { + group.push(record) + } else { + groups.set(key, [record]) } - ) + } + + const idmap: Array<{ draftId: string; persistedId: string }> = [] + const data: unknown[] = [] + + for (const [recordLabel, records] of groups) { + const result = await transaction.run( + this.entityQueryService.importUpsertRecords({ + withResults: options.returnResult, + rewrite, + mergeBy: options.mergeBy, + label: recordLabel || undefined + }), + { + records, + projectId, + mergeBy: options.mergeBy, + rewrite + } + ) + + const rowIdmap = result.records?.[0]?.get('idmap') + if (Array.isArray(rowIdmap)) { + idmap.push(...rowIdmap) + } + + if (options.returnResult) { + const rowData = result.records?.[0]?.get('data') + if (Array.isArray(rowData)) { + data.push(...rowData) + } + } + } + + return { idmap, data } } - return transaction.run(this.entityQueryService.importRecords(options.returnResult), { + const result = await transaction.run(this.entityQueryService.importRecords(options.returnResult), { records: recordsChunk, projectId }) + + return { + idmap: result.records?.[0]?.get('idmap') ?? [], + data: options.returnResult ? (result.records?.[0]?.get('data') ?? []) : [] + } } async processRelationshipsChunk({ diff --git a/platform/core/src/core/search/parser/tests/buildCompleteQuery.spec.ts b/platform/core/src/core/search/parser/tests/buildCompleteQuery.spec.ts index 2804053f..6e27807e 100644 --- a/platform/core/src/core/search/parser/tests/buildCompleteQuery.spec.ts +++ b/platform/core/src/core/search/parser/tests/buildCompleteQuery.spec.ts @@ -26,9 +26,10 @@ const q0 = { } const r0 = `MATCH (record:__RUSHDB__LABEL__RECORD__:\`COMPANY\` { __RUSHDB__KEY__PROJECT__ID__: $projectId }) -WHERE ((any(value IN record.\`stage\` WHERE value = "seed")) OR (any(value IN record.\`stage\` WHERE value = "roundA"))) ORDER BY record.\`__RUSHDB__KEY__ID__\` DESC SKIP 0 LIMIT 100 +WHERE ((any(value IN record.\`stage\` WHERE value = "seed")) OR (any(value IN record.\`stage\` WHERE value = "roundA"))) OPTIONAL MATCH (record)--(record1:__RUSHDB__LABEL__RECORD__:\`EMPLOYEE\`) WHERE (any(value IN record1.\`salary\` WHERE value >= 500000)) WITH record, record1 WHERE record IS NOT NULL AND record1 IS NOT NULL +ORDER BY record.\`__RUSHDB__KEY__ID__\` DESC SKIP 0 LIMIT 100 WITH record, apoc.coll.sortMaps(collect(DISTINCT record1 {.*, __RUSHDB__KEY__LABEL__: [label IN labels(record1) WHERE label <> "__RUSHDB__LABEL__RECORD__"][0]}), "salary")[0..10] AS \`employees\` RETURN DISTINCT record {.*, __RUSHDB__KEY__LABEL__: [label IN labels(record) WHERE label <> "__RUSHDB__LABEL__RECORD__"][0], \`employees\`} as records` @@ -77,11 +78,12 @@ const q1 = { } const r1 = `MATCH (record:__RUSHDB__LABEL__RECORD__:\`COMPANY\` { __RUSHDB__KEY__PROJECT__ID__: $projectId }) -WHERE (any(value IN record.\`rating\` WHERE value >= 1)) ORDER BY record.\`__RUSHDB__KEY__ID__\` DESC SKIP 0 LIMIT 100 +WHERE (any(value IN record.\`rating\` WHERE value >= 1)) OPTIONAL MATCH (record)--(record1:__RUSHDB__LABEL__RECORD__:\`departments\`) OPTIONAL MATCH (record1)--(record2:__RUSHDB__LABEL__RECORD__:\`projects\`) OPTIONAL MATCH (record2)--(record3:__RUSHDB__LABEL__RECORD__:\`employees\`) WHERE (any(value IN record3.\`salary\` WHERE value >= 499500)) WITH record, record1, record2, record3 WHERE record IS NOT NULL AND (record1 IS NOT NULL AND (record2 IS NOT NULL AND record3 IS NOT NULL)) +ORDER BY record.\`__RUSHDB__KEY__ID__\` DESC SKIP 0 LIMIT 100 OPTIONAL MATCH (record)--(sel0:__RUSHDB__LABEL__RECORD__:\`departments\`) OPTIONAL MATCH (sel0)--(sel1:__RUSHDB__LABEL__RECORD__:\`projects\`) OPTIONAL MATCH (sel1)--(sel2:__RUSHDB__LABEL__RECORD__:\`employees\`) @@ -123,11 +125,12 @@ const q2 = { } const r2 = `MATCH (record:__RUSHDB__LABEL__RECORD__:\`COMPANY\` { __RUSHDB__KEY__PROJECT__ID__: $projectId }) -WHERE (any(value IN record.\`rating\` WHERE value >= 1)) ORDER BY record.\`__RUSHDB__KEY__ID__\` DESC SKIP 0 LIMIT 100 +WHERE (any(value IN record.\`rating\` WHERE value >= 1)) OPTIONAL MATCH (record)--(record1:__RUSHDB__LABEL__RECORD__:\`departments\`) OPTIONAL MATCH (record1)--(record2:__RUSHDB__LABEL__RECORD__:\`projects\`) OPTIONAL MATCH (record2)--(record3:__RUSHDB__LABEL__RECORD__:\`employees\`) WHERE (any(value IN record3.\`salary\` WHERE value >= 499500)) WITH record, record1, record2, record3 WHERE record IS NOT NULL AND (record1 IS NOT NULL AND (record2 IS NOT NULL AND record3 IS NOT NULL)) +ORDER BY record.\`__RUSHDB__KEY__ID__\` DESC SKIP 0 LIMIT 100 WITH record, count(DISTINCT record3) AS \`employeesCount\`, sum(record3.\`salary\`) AS \`totalWage\`, toInteger(avg(record3.\`salary\`)) AS \`avgSalary\`, min(record3.\`salary\`) AS \`minSalary\`, max(record3.\`salary\`) AS \`maxSalary\` RETURN DISTINCT record {.*, __RUSHDB__KEY__LABEL__: [label IN labels(record) WHERE label <> "__RUSHDB__LABEL__RECORD__"][0], \`companyName\`: record.\`name\`, \`employeesCount\`, \`totalWage\`, \`avgSalary\`, \`minSalary\`, \`maxSalary\`} as records` @@ -222,7 +225,7 @@ const q3 = { } const r3 = `MATCH (record:__RUSHDB__LABEL__RECORD__:\`COMPANY\` { __RUSHDB__KEY__PROJECT__ID__: $projectId }) -WHERE (any(value IN record.\`tag\` WHERE value = "top-sellers")) ORDER BY record.\`__RUSHDB__KEY__ID__\` DESC SKIP 0 LIMIT 100 +WHERE (any(value IN record.\`tag\` WHERE value = "top-sellers")) OPTIONAL MATCH (record)<-[:AUTHORED]-(record1:__RUSHDB__LABEL__RECORD__:\`AUTHOR\`) WHERE (((any(value IN record1.\`name\` WHERE value STARTS WITH "Jack") AND any(value IN record1.\`name\` WHERE value ENDS WITH "Rooney")) OR any(value IN record1.\`name\` WHERE apoc.convert.fromJsonMap(record1.\`__RUSHDB__KEY__PROPERTIES__META__\`).\`name\` = "datetime" AND datetime(value) = datetime({year: 1984})))) OPTIONAL MATCH (record)--(record2:__RUSHDB__LABEL__RECORD__:\`POST\`) WHERE (any(value IN record2.\`created\` WHERE apoc.convert.fromJsonMap(record2.\`__RUSHDB__KEY__PROPERTIES__META__\`).\`created\` = "datetime" AND datetime(value) = datetime({year: 2011, month: 11, day: 11}))) AND (((any(value IN record2.\`rating\` WHERE value > 4.5) AND any(value IN record2.\`rating\` WHERE value < 6)) OR any(value IN record2.\`rating\` WHERE value <> 3) OR (NOT(any(value IN record2.\`rating\` WHERE value >= 4))))) AND (any(value IN record2.\`title\` WHERE value <> "Forest")) OPTIONAL MATCH (record2)-[:COMMENT_TO_POST]->(record3:__RUSHDB__LABEL__RECORD__:\`COMMENT\`) WHERE (any(value IN record3.\`authoredBy\` WHERE value =~ "(?i).*Sam.*")) @@ -233,6 +236,7 @@ OPTIONAL MATCH (record)--(record7:__RUSHDB__LABEL__RECORD__:\`departments\`) OPTIONAL MATCH (record7)--(record8:__RUSHDB__LABEL__RECORD__:\`projects\`) OPTIONAL MATCH (record8)--(record9:__RUSHDB__LABEL__RECORD__:\`employees\`) WHERE (any(value IN record9.\`salary\` WHERE value >= 499500)) WITH record, record1, record2, record3, record4, record5, record6, record7, record8, record9 WHERE record IS NOT NULL AND record1 IS NOT NULL AND (record2 IS NOT NULL AND record3 IS NOT NULL) AND (record4 IS NOT NULL AND (record5 IS NOT NULL AND record6 IS NOT NULL)) +ORDER BY record.\`__RUSHDB__KEY__ID__\` DESC SKIP 0 LIMIT 100 WITH record, count(DISTINCT record9) AS \`employeesCount\`, sum(record9.\`salary\`) AS \`totalWage\`, toInteger(avg(record9.\`salary\`)) AS \`avgSalary\`, min(record9.\`salary\`) AS \`minSalary\`, max(record9.\`salary\`) AS \`maxSalary\` RETURN DISTINCT record {.*, __RUSHDB__KEY__LABEL__: [label IN labels(record) WHERE label <> "__RUSHDB__LABEL__RECORD__"][0], \`companyName\`: record.\`name\`, \`employeesCount\`, \`totalWage\`, \`avgSalary\`, \`minSalary\`, \`maxSalary\`} as records` @@ -244,7 +248,8 @@ const q4 = { } const r4 = `MATCH (record:__RUSHDB__LABEL__RECORD__:\`COMPANY\` { __RUSHDB__KEY__PROJECT__ID__: $projectId }) -WHERE ((any(value IN record.\`stage\` WHERE value = "seed") OR any(value IN record.\`stage\` WHERE value = "roundA"))) ORDER BY record.\`__RUSHDB__KEY__ID__\` DESC SKIP 0 LIMIT 100 +WHERE ((any(value IN record.\`stage\` WHERE value = "seed") OR any(value IN record.\`stage\` WHERE value = "roundA"))) +ORDER BY record.\`__RUSHDB__KEY__ID__\` DESC SKIP 0 LIMIT 100 RETURN DISTINCT record {.*, __RUSHDB__KEY__LABEL__: [label IN labels(record) WHERE label <> "__RUSHDB__LABEL__RECORD__"][0]} AS records` const q5 = { @@ -255,7 +260,8 @@ const q5 = { } const r5 = `MATCH (record:__RUSHDB__LABEL__RECORD__:\`COMPANY\` { __RUSHDB__KEY__PROJECT__ID__: $projectId }) -WHERE ((any(value IN record.\`stage\` WHERE value = "seed")) OR (any(value IN record.\`stage\` WHERE value = "roundA"))) ORDER BY record.\`__RUSHDB__KEY__ID__\` DESC SKIP 0 LIMIT 100 +WHERE ((any(value IN record.\`stage\` WHERE value = "seed")) OR (any(value IN record.\`stage\` WHERE value = "roundA"))) +ORDER BY record.\`__RUSHDB__KEY__ID__\` DESC SKIP 0 LIMIT 100 RETURN DISTINCT record {.*, __RUSHDB__KEY__LABEL__: [label IN labels(record) WHERE label <> "__RUSHDB__LABEL__RECORD__"][0]} AS records` const q6 = { @@ -270,9 +276,10 @@ const q6 = { } const r6 = `MATCH (record:__RUSHDB__LABEL__RECORD__:\`COMPANY\` { __RUSHDB__KEY__PROJECT__ID__: $projectId }) -WHERE (((any(value IN record.\`__RUSHDB__KEY__ID__\` WHERE value = "1234567890") OR any(value IN record.\`__RUSHDB__KEY__ID__\` WHERE value = "0987654321"))) AND (any(value IN record.\`name\` WHERE value = "alex"))) ORDER BY record.\`__RUSHDB__KEY__ID__\` DESC SKIP 0 LIMIT 100 +WHERE (((any(value IN record.\`__RUSHDB__KEY__ID__\` WHERE value = "1234567890") OR any(value IN record.\`__RUSHDB__KEY__ID__\` WHERE value = "0987654321"))) AND (any(value IN record.\`name\` WHERE value = "alex"))) OPTIONAL MATCH (record)--(record1:__RUSHDB__LABEL__RECORD__:\`USER\`) WHERE (any(value IN record1.\`__RUSHDB__KEY__ID__\` WHERE value IN ["1234567890", "0987654321"])) WITH record, record1 WHERE record IS NOT NULL AND record1 IS NOT NULL +ORDER BY record.\`__RUSHDB__KEY__ID__\` DESC SKIP 0 LIMIT 100 RETURN DISTINCT record {.*, __RUSHDB__KEY__LABEL__: [label IN labels(record) WHERE label <> "__RUSHDB__LABEL__RECORD__"][0]} AS records` const q7 = { @@ -296,9 +303,10 @@ const q7 = { } const r7 = `MATCH (record:__RUSHDB__LABEL__RECORD__:\`COMPANY\` { __RUSHDB__KEY__PROJECT__ID__: $projectId }) -WHERE (any(value IN record.\`rating\` WHERE value >= 1)) ORDER BY record.\`__RUSHDB__KEY__ID__\` DESC SKIP 0 LIMIT 100 +WHERE (any(value IN record.\`rating\` WHERE value >= 1)) OPTIONAL MATCH (record)--(record1:__RUSHDB__LABEL__RECORD__:\`departments\`) WITH record, record1 WHERE record IS NOT NULL AND record1 IS NOT NULL +ORDER BY record.\`__RUSHDB__KEY__ID__\` DESC SKIP 0 LIMIT 100 WITH record, apoc.coll.sortMaps(collect(DISTINCT record1 {tags: record1.\`tags\`, __RUSHDB__KEY__LABEL__: [label IN labels(record1) WHERE label <> "__RUSHDB__LABEL__RECORD__"][0]}), "__RUSHDB__KEY__ID__")[0..100] AS \`tags\` RETURN DISTINCT record {.*, __RUSHDB__KEY__LABEL__: [label IN labels(record) WHERE label <> "__RUSHDB__LABEL__RECORD__"][0], \`tags\`} as records` @@ -318,9 +326,9 @@ const q8 = { } const r8 = `MATCH (record:__RUSHDB__LABEL__RECORD__:\`COMPANY\` { __RUSHDB__KEY__PROJECT__ID__: $projectId }) -ORDER BY record.\`__RUSHDB__KEY__ID__\` DESC SKIP 0 LIMIT 100 OPTIONAL MATCH (record)--(record1:__RUSHDB__LABEL__RECORD__:\`departments\`) WITH record, record1 WHERE record IS NOT NULL AND record1 IS NOT NULL +ORDER BY record.\`__RUSHDB__KEY__ID__\` DESC SKIP 0 LIMIT 100 RETURN DISTINCT record {.*, __RUSHDB__KEY__LABEL__: [label IN labels(record) WHERE label <> "__RUSHDB__LABEL__RECORD__"][0], \`departmentId\`: record1.\`__RUSHDB__KEY__ID__\`} as records` const q13 = { @@ -386,7 +394,8 @@ const q15 = { } const r15 = `MATCH (record:__RUSHDB__LABEL__RECORD__:\`COMPANY\` { __RUSHDB__KEY__PROJECT__ID__: $projectId }) -WHERE ((any(value IN record.\`active\` WHERE value = true)) AND ((any(value IN record.\`email\` WHERE value ENDS WITH "@gmail.com")) OR (any(value IN record.\`email\` WHERE value ENDS WITH "@outlook.com")))) ORDER BY record.\`__RUSHDB__KEY__ID__\` DESC SKIP 0 LIMIT 1000 +WHERE ((any(value IN record.\`active\` WHERE value = true)) AND ((any(value IN record.\`email\` WHERE value ENDS WITH "@gmail.com")) OR (any(value IN record.\`email\` WHERE value ENDS WITH "@outlook.com")))) +ORDER BY record.\`__RUSHDB__KEY__ID__\` DESC SKIP 0 LIMIT 1000 RETURN DISTINCT record {.*, __RUSHDB__KEY__LABEL__: [label IN labels(record) WHERE label <> "__RUSHDB__LABEL__RECORD__"][0]} AS records` const q16 = { @@ -400,7 +409,8 @@ const q16 = { } const r16 = `MATCH (record:__RUSHDB__LABEL__RECORD__ { __RUSHDB__KEY__PROJECT__ID__: $projectId }) -WHERE (apoc.convert.fromJsonMap(record.\`__RUSHDB__KEY__PROPERTIES__META__\`).\`title\` = "string") ORDER BY record.\`__RUSHDB__KEY__ID__\` DESC SKIP 0 LIMIT 1000 +WHERE (apoc.convert.fromJsonMap(record.\`__RUSHDB__KEY__PROPERTIES__META__\`).\`title\` = "string") +ORDER BY record.\`__RUSHDB__KEY__ID__\` DESC SKIP 0 LIMIT 1000 RETURN DISTINCT record {.*, __RUSHDB__KEY__LABEL__: [label IN labels(record) WHERE label <> "__RUSHDB__LABEL__RECORD__"][0]} AS records` const q17 = { @@ -414,7 +424,8 @@ const q17 = { } const r17 = `MATCH (record:__RUSHDB__LABEL__RECORD__ { __RUSHDB__KEY__PROJECT__ID__: $projectId }) -WHERE (record.\`title\` IS NOT NULL) ORDER BY record.\`__RUSHDB__KEY__ID__\` DESC SKIP 0 LIMIT 1000 +WHERE (record.\`title\` IS NOT NULL) +ORDER BY record.\`__RUSHDB__KEY__ID__\` DESC SKIP 0 LIMIT 1000 RETURN DISTINCT record {.*, __RUSHDB__KEY__LABEL__: [label IN labels(record) WHERE label <> "__RUSHDB__LABEL__RECORD__"][0]} AS records` const q18 = { @@ -433,7 +444,8 @@ const q18 = { } const r18 = `MATCH (record:__RUSHDB__LABEL__RECORD__ { __RUSHDB__KEY__PROJECT__ID__: $projectId }) -WHERE ((record.\`title\` IS NOT NULL) AND (apoc.convert.fromJsonMap(record.\`__RUSHDB__KEY__PROPERTIES__META__\`).\`title\` = "string")) ORDER BY record.\`__RUSHDB__KEY__ID__\` DESC SKIP 0 LIMIT 1000 +WHERE ((record.\`title\` IS NOT NULL) AND (apoc.convert.fromJsonMap(record.\`__RUSHDB__KEY__PROPERTIES__META__\`).\`title\` = "string")) +ORDER BY record.\`__RUSHDB__KEY__ID__\` DESC SKIP 0 LIMIT 1000 RETURN DISTINCT record {.*, __RUSHDB__KEY__LABEL__: [label IN labels(record) WHERE label <> "__RUSHDB__LABEL__RECORD__"][0]} AS records` const q19 = { @@ -1058,17 +1070,17 @@ const r36 = r19 const r37 = r20 const r38 = `MATCH (record:__RUSHDB__LABEL__RECORD__:\`ORDER\` { __RUSHDB__KEY__PROJECT__ID__: $projectId }) -ORDER BY record.\`__RUSHDB__KEY__ID__\` DESC SKIP 0 LIMIT 100 OPTIONAL MATCH (record)--(record1:__RUSHDB__LABEL__RECORD__:\`CUSTOMER\`) WITH record, record1 WHERE record IS NOT NULL AND record1 IS NOT NULL +ORDER BY record.\`__RUSHDB__KEY__ID__\` DESC SKIP 0 LIMIT 100 WITH record, sum(record.\`amount\`) AS \`revenue\`, count(DISTINCT record) AS \`orders\` WITH record, \`revenue\`, \`orders\`, (\`revenue\` / \`orders\`) AS \`avgOrderValue\` RETURN DISTINCT record {.*, __RUSHDB__KEY__LABEL__: [label IN labels(record) WHERE label <> "__RUSHDB__LABEL__RECORD__"][0], \`revenue\`, \`orders\`, \`avgOrderValue\`} as records` const r39 = `MATCH (record:__RUSHDB__LABEL__RECORD__:\`COMPANY\` { __RUSHDB__KEY__PROJECT__ID__: $projectId }) -ORDER BY record.\`__RUSHDB__KEY__ID__\` DESC SKIP 0 LIMIT 100 OPTIONAL MATCH (record)--(record1:__RUSHDB__LABEL__RECORD__:\`EMPLOYEE\`) WHERE (any(value IN record1.\`salary\` WHERE value >= 50000)) WITH record, record1 WHERE record IS NOT NULL AND record1 IS NOT NULL +ORDER BY record.\`__RUSHDB__KEY__ID__\` DESC SKIP 0 LIMIT 100 WITH record, apoc.coll.sortMaps(collect(DISTINCT record1 {name: record1.\`name\`, salary: record1.\`salary\`, __RUSHDB__KEY__LABEL__: [label IN labels(record1) WHERE label <> "__RUSHDB__LABEL__RECORD__"][0]}), "salary")[0..5] AS \`employees\` RETURN DISTINCT record {.*, __RUSHDB__KEY__LABEL__: [label IN labels(record) WHERE label <> "__RUSHDB__LABEL__RECORD__"][0], \`companyName\`: record.\`name\`, \`employees\`} as records` @@ -1083,9 +1095,9 @@ ORDER BY \`month\` ASC SKIP 0 LIMIT 100 RETURN {\`revenue\`:\`revenue\`, \`month\`:\`month\`} as records` const r42 = `MATCH (record:__RUSHDB__LABEL__RECORD__:\`PRODUCT\` { __RUSHDB__KEY__PROJECT__ID__: $projectId }) -ORDER BY record.\`__RUSHDB__KEY__ID__\` DESC SKIP 0 LIMIT 100 OPTIONAL MATCH (record)--(record1:__RUSHDB__LABEL__RECORD__:\`INVENTORY\`) WITH record, record1 WHERE record IS NOT NULL AND record1 IS NOT NULL +ORDER BY record.\`__RUSHDB__KEY__ID__\` DESC SKIP 0 LIMIT 100 WITH record, (record1.\`quantity\` * record1.\`unitPrice\`) AS \`totalValue\` RETURN DISTINCT record {.*, __RUSHDB__KEY__LABEL__: [label IN labels(record) WHERE label <> "__RUSHDB__LABEL__RECORD__"][0], \`product\`: record.\`name\`, \`totalValue\`} as records` @@ -1308,7 +1320,8 @@ const q46 = { } const r46 = `MATCH (record:__RUSHDB__LABEL__RECORD__:\`COMPANY\` { __RUSHDB__KEY__PROJECT__ID__: $projectId }) -WHERE (any(value IN record.\`foundedAt\` WHERE apoc.convert.fromJsonMap(record.\`__RUSHDB__KEY__PROPERTIES__META__\`).\`foundedAt\` = "datetime" AND datetime(value) <= datetime({year: 1980}))) ORDER BY record.\`__RUSHDB__KEY__ID__\` DESC SKIP 0 LIMIT 100 +WHERE (any(value IN record.\`foundedAt\` WHERE apoc.convert.fromJsonMap(record.\`__RUSHDB__KEY__PROPERTIES__META__\`).\`foundedAt\` = "datetime" AND datetime(value) <= datetime({year: 1980}))) +ORDER BY record.\`__RUSHDB__KEY__ID__\` DESC SKIP 0 LIMIT 100 OPTIONAL MATCH (record)--(sel0:__RUSHDB__LABEL__RECORD__:\`DEPARTMENT\`) OPTIONAL MATCH (sel0)--(sel1:__RUSHDB__LABEL__RECORD__:\`PROJECT\`) OPTIONAL MATCH (sel1)--(sel2:__RUSHDB__LABEL__RECORD__:\`EMPLOYEE\`) @@ -1415,10 +1428,10 @@ WITH record, apoc.coll.sortMaps(collect(DISTINCT sel0 {employee_name: sel0.\`nam RETURN DISTINCT record {.*, __RUSHDB__KEY__LABEL__: [label IN labels(record) WHERE label <> "__RUSHDB__LABEL__RECORD__"][0], \`project_name\`: record.\`name\`, \`top_paid_employees\`} as records` const r52 = `MATCH (record:__RUSHDB__LABEL__RECORD__:\`DEPARTMENT\` { __RUSHDB__KEY__PROJECT__ID__: $projectId }) -ORDER BY record.\`__RUSHDB__KEY__ID__\` DESC SKIP 0 LIMIT 100 OPTIONAL MATCH (record)--(record1:__RUSHDB__LABEL__RECORD__:\`PROJECT\`) OPTIONAL MATCH (record1)--(record2:__RUSHDB__LABEL__RECORD__:\`EMPLOYEE\`) WITH record, record1, record2 WHERE record IS NOT NULL AND (record1 IS NOT NULL AND record2 IS NOT NULL) +ORDER BY record.\`__RUSHDB__KEY__ID__\` DESC SKIP 0 LIMIT 100 WITH record, apoc.coll.sortMaps(collect(DISTINCT record2 {employee_name: record2.\`name\`, salary: record2.\`salary\`, __RUSHDB__KEY__LABEL__: [label IN labels(record2) WHERE label <> "__RUSHDB__LABEL__RECORD__"][0]}), "salary")[0..3] AS \`top_paid_employees\` RETURN DISTINCT record {.*, __RUSHDB__KEY__LABEL__: [label IN labels(record) WHERE label <> "__RUSHDB__LABEL__RECORD__"][0], \`department_name\`: record.\`name\`, \`top_paid_employees\`} as records` @@ -1512,9 +1525,10 @@ describe('variable-length traversal and cycles (complete queries)', () => { expect(result) .toEqual(`MATCH (record:__RUSHDB__LABEL__RECORD__:\`COMPANY\` { __RUSHDB__KEY__PROJECT__ID__: $projectId }) -WHERE ((any(value IN record.\`stage\` WHERE value = "seed")) OR (any(value IN record.\`stage\` WHERE value = "roundA"))) ORDER BY record.\`__RUSHDB__KEY__ID__\` DESC SKIP 0 LIMIT 100 +WHERE ((any(value IN record.\`stage\` WHERE value = "seed")) OR (any(value IN record.\`stage\` WHERE value = "roundA"))) OPTIONAL MATCH (record)-[:MANAGES*1..3]->(record1:__RUSHDB__LABEL__RECORD__:\`EMPLOYEE\`) WHERE (any(value IN record1.\`salary\` WHERE value >= 500000)) WITH record, record1 WHERE record IS NOT NULL AND record1 IS NOT NULL +ORDER BY record.\`__RUSHDB__KEY__ID__\` DESC SKIP 0 LIMIT 100 WITH record, apoc.coll.sortMaps(collect(DISTINCT record1 {.*, __RUSHDB__KEY__LABEL__: [label IN labels(record1) WHERE label <> "__RUSHDB__LABEL__RECORD__"][0]}), "salary")[0..10] AS \`employees\` RETURN DISTINCT record {.*, __RUSHDB__KEY__LABEL__: [label IN labels(record) WHERE label <> "__RUSHDB__LABEL__RECORD__"][0], \`employees\`} as records`) }) @@ -1531,8 +1545,8 @@ RETURN DISTINCT record {.*, __RUSHDB__KEY__LABEL__: [label IN labels(record) WHE expect(result) .toEqual(`MATCH (record:__RUSHDB__LABEL__RECORD__:\`ACCOUNT\` { __RUSHDB__KEY__PROJECT__ID__: $projectId }) -ORDER BY record.\`__RUSHDB__KEY__ID__\` DESC SKIP 0 LIMIT 100 WITH record WHERE record IS NOT NULL AND EXISTS { MATCH (record)-[:TRANSFERRED_TO*2..6]->(record) } +ORDER BY record.\`__RUSHDB__KEY__ID__\` DESC SKIP 0 LIMIT 100 RETURN DISTINCT record {.*, __RUSHDB__KEY__LABEL__: [label IN labels(record) WHERE label <> "__RUSHDB__LABEL__RECORD__"][0]} AS records`) })