refactor(backend): improve map performance (#9476)

This commit is contained in:
Ulf Gebhardt 2026-04-01 19:44:24 +02:00 committed by GitHub
parent b07830769d
commit 2cd07950c6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 627 additions and 73 deletions

View File

@ -27,7 +27,7 @@ export default {
],
coverageThreshold: {
global: {
lines: 92,
lines: 93,
},
},
testMatch: ['**/src/**/?(*.)+(spec|test).ts?(x)'],

View File

@ -0,0 +1,8 @@
query GroupWithLocationFilter($hasLocation: Boolean) {
Group(hasLocation: $hasLocation) {
id
location {
id
}
}
}

View File

@ -0,0 +1,8 @@
query PostWithLocationFilter($filter: _PostFilter) {
Post(filter: $filter) {
id
eventLocation {
id
}
}
}

View File

@ -0,0 +1,8 @@
query UserWithLocationFilter($filter: _UserFilter) {
User(filter: $filter) {
id
location {
id
}
}
}

View File

@ -23,7 +23,7 @@ import type { Context } from '@src/context'
export default {
Query: {
Group: async (_object, params, context: Context, _resolveInfo) => {
const { isMember, id, slug, first, offset } = params
const { isMember, hasLocation, id, slug, first, offset } = params
const session = context.driver.session()
try {
return await session.readTransaction(async (txc) => {
@ -35,10 +35,13 @@ export default {
if (slug !== undefined) matchFilters.push('group.slug = $slug')
const matchWhere = matchFilters.length ? `WHERE ${matchFilters.join(' AND ')}` : ''
const locationMatch = hasLocation === true ? 'MATCH (group)-[:IS_IN]->(:Location)' : ''
const transactionResponse = await txc.run(
`
MATCH (group:Group)
${matchWhere}
${locationMatch}
OPTIONAL MATCH (:User {id: $userId})-[membership:MEMBER_OF]->(group)
WITH group, membership
${(isMember === true && "WHERE membership IS NOT NULL AND (group.groupType IN ['public', 'closed']) OR (group.groupType = 'hidden' AND membership.role IN ['usual', 'admin', 'owner'])") || ''}

View File

@ -0,0 +1,55 @@
import { removeUndefinedNullValuesFromObject, convertObjectToCypherMapLiteral } from './Resolver'
describe('removeUndefinedNullValuesFromObject', () => {
it('removes undefined values', () => {
const obj = { a: 1, b: undefined, c: 'hello' }
removeUndefinedNullValuesFromObject(obj)
expect(obj).toEqual({ a: 1, c: 'hello' })
})
it('removes null values', () => {
const obj = { a: 1, b: null, c: 'hello' }
removeUndefinedNullValuesFromObject(obj)
expect(obj).toEqual({ a: 1, c: 'hello' })
})
it('keeps falsy but defined values', () => {
const obj = { a: 0, b: false, c: '' }
removeUndefinedNullValuesFromObject(obj)
expect(obj).toEqual({ a: 0, b: false, c: '' })
})
it('handles empty object', () => {
const obj = {}
removeUndefinedNullValuesFromObject(obj)
expect(obj).toEqual({})
})
})
describe('convertObjectToCypherMapLiteral', () => {
it('converts single entry', () => {
expect(convertObjectToCypherMapLiteral({ id: 'g0' })).toBe('{id: "g0"}')
})
it('converts multiple entries', () => {
expect(convertObjectToCypherMapLiteral({ id: 'g0', slug: 'yoga' })).toBe(
'{id: "g0", slug: "yoga"}',
)
})
it('returns empty string for empty object', () => {
expect(convertObjectToCypherMapLiteral({})).toBe('')
})
it('adds space in front when addSpaceInfrontIfMapIsNotEmpty is true and map is not empty', () => {
expect(convertObjectToCypherMapLiteral({ id: 'g0' }, true)).toBe(' {id: "g0"}')
})
it('does not add space when addSpaceInfrontIfMapIsNotEmpty is true but map is empty', () => {
expect(convertObjectToCypherMapLiteral({}, true)).toBe('')
})
it('does not add space when addSpaceInfrontIfMapIsNotEmpty is false', () => {
expect(convertObjectToCypherMapLiteral({ id: 'g0' }, false)).toBe('{id: "g0"}')
})
})

View File

@ -0,0 +1,176 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import Factory, { cleanDatabase } from '@db/factories'
import GroupWithLocationFilter from '@graphql/queries/groups/GroupWithLocationFilter.gql'
import PostWithLocationFilter from '@graphql/queries/posts/PostWithLocationFilter.gql'
import UserWithLocationFilter from '@graphql/queries/users/UserWithLocationFilter.gql'
import { createApolloTestSetup } from '@root/test/helpers'
import type { ApolloTestSetup } from '@root/test/helpers'
import type { Context } from '@src/context'
let authenticatedUser: Context['user']
const context = () => ({ authenticatedUser })
let query: ApolloTestSetup['query']
let database: ApolloTestSetup['database']
let server: ApolloTestSetup['server']
beforeAll(async () => {
await cleanDatabase()
const apolloSetup = await createApolloTestSetup({ context })
query = apolloSetup.query
database = apolloSetup.database
server = apolloSetup.server
})
afterAll(async () => {
await cleanDatabase()
void server.stop()
void database.driver.close()
database.neode.close()
})
afterEach(async () => {
await cleanDatabase()
})
describe('hasLocation filter', () => {
describe('User', () => {
beforeEach(async () => {
const location = await Factory.build('location', {
id: 'loc-hamburg',
name: 'Hamburg',
type: 'region',
lng: 10.0,
lat: 53.55,
})
const userWithLocation = await Factory.build('user', {
id: 'u-with-loc',
name: 'User With Location',
})
await userWithLocation.relateTo(location, 'isIn')
await Factory.build('user', {
id: 'u-without-loc',
name: 'User Without Location',
})
authenticatedUser = await userWithLocation.toJson()
})
it('returns all users without filter', async () => {
const result = await query({ query: UserWithLocationFilter })
expect(result.data?.User.length).toBeGreaterThanOrEqual(2)
})
it('returns only users with location when hasLocation is true', async () => {
const result = await query({
query: UserWithLocationFilter,
variables: { filter: { hasLocation: true } },
})
const ids = result.data?.User.map((u: { id: string }) => u.id)
expect(ids).toContain('u-with-loc')
expect(ids).not.toContain('u-without-loc')
})
})
describe('Group', () => {
beforeEach(async () => {
const location = await Factory.build('location', {
id: 'loc-berlin',
name: 'Berlin',
type: 'region',
lng: 13.4,
lat: 52.52,
})
const owner = await Factory.build('user', { id: 'group-owner', name: 'Owner' })
authenticatedUser = await owner.toJson()
const groupWithLocation = await Factory.build('group', {
id: 'g-with-loc',
name: 'Group With Location',
groupType: 'public',
ownerId: 'group-owner',
})
await groupWithLocation.relateTo(location, 'isIn')
await Factory.build('group', {
id: 'g-without-loc',
name: 'Group Without Location',
groupType: 'public',
ownerId: 'group-owner',
})
})
it('returns all groups without filter', async () => {
const result = await query({ query: GroupWithLocationFilter })
expect(result.data?.Group.length).toBeGreaterThanOrEqual(2)
})
it('returns only groups with location when hasLocation is true', async () => {
const result = await query({
query: GroupWithLocationFilter,
variables: { hasLocation: true },
})
const ids = result.data?.Group.map((g: { id: string }) => g.id)
expect(ids).toContain('g-with-loc')
expect(ids).not.toContain('g-without-loc')
})
})
describe('Post', () => {
beforeEach(async () => {
const author = await Factory.build('user', { id: 'post-author', name: 'Author' })
authenticatedUser = await author.toJson()
await Factory.build('location', {
id: 'loc-munich',
name: 'Munich',
type: 'region',
lng: 11.58,
lat: 48.14,
})
await Factory.build('post', {
id: 'p-with-loc',
title: 'Event With Location',
postType: 'Event',
authorId: 'post-author',
})
// Post model has no isIn relationship defined in Neode, use Cypher directly
const session = database.driver.session()
try {
await session.writeTransaction((txc) =>
txc.run(`
MATCH (p:Post {id: 'p-with-loc'}), (l:Location {id: 'loc-munich'})
MERGE (p)-[:IS_IN]->(l)
`),
)
} finally {
await session.close()
}
await Factory.build('post', {
id: 'p-without-loc',
title: 'Event Without Location',
postType: 'Event',
authorId: 'post-author',
})
})
it('returns all posts without hasLocation filter', async () => {
const result = await query({
query: PostWithLocationFilter,
})
expect(result.data?.Post.length).toBeGreaterThanOrEqual(2)
})
it('returns only posts with location when hasLocation is true', async () => {
const result = await query({
query: PostWithLocationFilter,
variables: { filter: { hasLocation: true } },
})
const ids = result.data?.Post.map((p: { id: string }) => p.id)
expect(ids).toContain('p-with-loc')
expect(ids).not.toContain('p-without-loc')
})
})
})

View File

@ -0,0 +1,56 @@
import type { Context } from '@src/context'
interface FilterParams {
filter?: {
hasLocation?: boolean
id_in?: string[]
[key: string]: unknown
}
[key: string]: unknown
}
type AllowedLabel = 'User' | 'Post'
const getIdsWithLocation = async (context: Context, label: AllowedLabel): Promise<string[]> => {
const session = context.driver.session()
try {
const result = await session.readTransaction(async (transaction) => {
const cypher = `
MATCH (n:${label})-[:IS_IN]->(l:Location)
RETURN collect(n.id) AS ids`
const response = await transaction.run(cypher)
return response.records.map((record) => record.get('ids') as string[])
})
const [ids] = result
return ids
} finally {
await session.close()
}
}
const mergeIdIn = (existing: string[] | undefined, incoming: string[]): string[] => {
if (!existing) return incoming
return existing.filter((id) => incoming.includes(id))
}
export const filterUsersHasLocation = async (
params: FilterParams,
context: Context,
): Promise<FilterParams> => {
if (!params.filter?.hasLocation) return params
delete params.filter.hasLocation
const userIds = await getIdsWithLocation(context, 'User')
params.filter.id_in = mergeIdIn(params.filter.id_in, userIds)
return params
}
export const filterPostsHasLocation = async (
params: FilterParams,
context: Context,
): Promise<FilterParams> => {
if (!params.filter?.hasLocation) return params
delete params.filter.hasLocation
const postIds = await getIdsWithLocation(context, 'Post')
params.filter.id_in = mergeIdIn(params.filter.id_in, postIds)
return params
}

View File

@ -0,0 +1,207 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import Factory, { cleanDatabase } from '@db/factories'
import CreatePost from '@graphql/queries/posts/CreatePost.gql'
import Post from '@graphql/queries/posts/Post.gql'
import { createApolloTestSetup } from '@root/test/helpers'
import type { ApolloTestSetup } from '@root/test/helpers'
import type { Context } from '@src/context'
let authenticatedUser: Context['user']
const context = () => ({ authenticatedUser })
let query: ApolloTestSetup['query']
let mutate: ApolloTestSetup['mutate']
let database: ApolloTestSetup['database']
let server: ApolloTestSetup['server']
beforeAll(async () => {
await cleanDatabase()
const apolloSetup = await createApolloTestSetup({ context })
query = apolloSetup.query
mutate = apolloSetup.mutate
database = apolloSetup.database
server = apolloSetup.server
})
afterAll(async () => {
await cleanDatabase()
void server.stop()
void database.driver.close()
database.neode.close()
})
afterEach(async () => {
await cleanDatabase()
})
describe('filterForMutedUsers', () => {
describe('when looking up a single post by id', () => {
it('does not filter muted users', async () => {
const author = await Factory.build('user', { id: 'muted-author', name: 'Muted Author' })
const viewer = await Factory.build('user', { id: 'viewer', name: 'Viewer' })
// Viewer mutes author
const session = database.driver.session()
try {
await session.writeTransaction((txc) =>
txc.run(`
MATCH (viewer:User {id: 'viewer'}), (author:User {id: 'muted-author'})
MERGE (viewer)-[:MUTED]->(author)
`),
)
} finally {
await session.close()
}
// Author creates a post
authenticatedUser = await author.toJson()
await mutate({
mutation: CreatePost,
variables: {
id: 'muted-post',
title: 'Post by muted user',
content: 'Some content here for the post',
postType: 'Article',
},
})
// Viewer queries for the specific post by id — should still see it
authenticatedUser = await viewer.toJson()
const result = await query({
query: Post,
variables: { id: 'muted-post' },
})
expect(result.data?.Post).toHaveLength(1)
expect(result.data?.Post[0].id).toBe('muted-post')
})
})
describe('when listing all posts', () => {
it('filters posts from muted users', async () => {
const author = await Factory.build('user', { id: 'muted-author', name: 'Muted Author' })
const otherAuthor = await Factory.build('user', {
id: 'other-author',
name: 'Other Author',
})
const viewer = await Factory.build('user', { id: 'viewer', name: 'Viewer' })
// Viewer mutes author
const session = database.driver.session()
try {
await session.writeTransaction((txc) =>
txc.run(`
MATCH (viewer:User {id: 'viewer'}), (author:User {id: 'muted-author'})
MERGE (viewer)-[:MUTED]->(author)
`),
)
} finally {
await session.close()
}
// Both authors create posts
authenticatedUser = await author.toJson()
await mutate({
mutation: CreatePost,
variables: {
id: 'muted-post',
title: 'Post by muted user',
content: 'Some content here for the muted post',
postType: 'Article',
},
})
authenticatedUser = await otherAuthor.toJson()
await mutate({
mutation: CreatePost,
variables: {
id: 'visible-post',
title: 'Post by other user',
content: 'Some content here for the visible post',
postType: 'Article',
},
})
// Viewer lists all posts — should not see muted author's post
authenticatedUser = await viewer.toJson()
const result = await query({ query: Post })
const ids = result.data?.Post.map((p: { id: string }) => p.id)
expect(ids).toContain('visible-post')
expect(ids).not.toContain('muted-post')
})
})
})
describe('filterPostsOfMyGroups', () => {
describe('when user has no group memberships', () => {
it('returns empty for postsInMyGroups filter', async () => {
const author = await Factory.build('user', { id: 'author', name: 'Author' })
authenticatedUser = await author.toJson()
await mutate({
mutation: CreatePost,
variables: {
id: 'some-post',
title: 'A regular post',
content: 'Some content here for the post',
postType: 'Article',
},
})
// Query with postsInMyGroups but user has no groups
const result = await query({
query: Post,
variables: { filter: { postsInMyGroups: true } },
})
expect(result.data?.Post).toHaveLength(0)
})
})
})
describe('filterInvisiblePosts', () => {
describe('with closed group posts', () => {
beforeEach(async () => {
const owner = await Factory.build('user', { id: 'group-owner', name: 'Group Owner' })
await Factory.build('user', { id: 'outsider', name: 'Outsider' })
authenticatedUser = await owner.toJson()
await Factory.build('group', {
id: 'closed-group',
name: 'Closed Group',
groupType: 'closed',
ownerId: 'group-owner',
})
await mutate({
mutation: CreatePost,
variables: {
id: 'closed-group-post',
title: 'Secret Post',
content: 'Some content here for the secret post',
postType: 'Article',
groupId: 'closed-group',
},
})
await mutate({
mutation: CreatePost,
variables: {
id: 'public-post',
title: 'Public Post',
content: 'Some content here for the public post',
postType: 'Article',
},
})
})
it('filters posts in non-public groups for non-members', async () => {
const outsider = await database.neode.find('User', 'outsider')
authenticatedUser = (await outsider.toJson()) as Context['user']
const result = await query({ query: Post })
const ids = result.data?.Post.map((p: { id: string }) => p.id)
expect(ids).toContain('public-post')
expect(ids).not.toContain('closed-group-post')
})
})
})

View File

@ -13,6 +13,7 @@ import { UserInputError } from '@graphql/errors'
import { validateEventParams } from './helpers/events'
import { filterForMutedUsers } from './helpers/filterForMutedUsers'
import { filterPostsHasLocation } from './helpers/filterHasLocation'
import { filterInvisiblePosts } from './helpers/filterInvisiblePosts'
import { filterPostsOfMyGroups } from './helpers/filterPostsOfMyGroups'
import Resolver from './helpers/Resolver'
@ -61,6 +62,7 @@ export default {
params = await filterInvisiblePosts(params, context)
params = await filterForMutedUsers(params, context)
params = filterEventDates(params)
params = await filterPostsHasLocation(params, context)
params = await maintainPinnedPosts(params)
return neo4jgraphql(object, params, context, resolveInfo)
},
@ -68,6 +70,7 @@ export default {
params = await filterPostsOfMyGroups(params, context)
params = await filterInvisiblePosts(params, context)
params = await filterForMutedUsers(params, context)
params = await filterPostsHasLocation(params, context)
params = await maintainGroupPinnedPosts(params)
return neo4jgraphql(object, params, context, resolveInfo)
},

View File

@ -12,6 +12,7 @@ import { getNeode } from '@db/neo4j'
import { UserInputError, ForbiddenError } from '@graphql/errors'
import { defaultTrophyBadge, defaultVerificationBadge } from './badges'
import { filterUsersHasLocation } from './helpers/filterHasLocation'
import normalizeEmail from './helpers/normalizeEmail'
import Resolver from './helpers/Resolver'
import { images } from './images/images'
@ -66,6 +67,7 @@ export default {
await session.close()
}
}
args = await filterUsersHasLocation(args, context)
return neo4jgraphql(object, args, context, resolveInfo)
},
},

View File

@ -79,6 +79,7 @@ input _GroupFilter {
type Query {
Group(
isMember: Boolean # if 'undefined' or 'null' then get all groups
hasLocation: Boolean # if 'true' then only groups with a location
id: ID
slug: String
first: Int

View File

@ -92,6 +92,7 @@ input _PostFilter {
postType_in: [PostType]
eventStart_gte: String
eventEnd_gte: String
hasLocation: Boolean
}
enum _PostOrdering {

View File

@ -158,6 +158,7 @@ type User {
input _UserFilter {
AND: [_UserFilter!]
OR: [_UserFilter!]
hasLocation: Boolean
name_contains: String
about_contains: String
slug_contains: String

View File

@ -0,0 +1,51 @@
import gql from 'graphql-tag'
export const mapQuery = (i18n) => {
const lang = i18n.locale().toUpperCase()
return gql`
query MapData($userFilter: _UserFilter, $groupHasLocation: Boolean, $postFilter: _PostFilter) {
User(filter: $userFilter) {
id
slug
name
about
location {
id
name(lang: "${lang}")
lng
lat
}
}
Group(hasLocation: $groupHasLocation) {
id
slug
name
about
location {
id
name(lang: "${lang}")
lng
lat
}
}
Post(filter: $postFilter) {
id
slug
title
content
postType
eventStart
eventEnd
eventVenue
eventLocationName
eventIsOnline
eventLocation {
id
name(lang: "${lang}")
lng
lat
}
}
}
`
}

View File

@ -64,9 +64,8 @@ import mapboxgl from 'mapbox-gl'
import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder'
import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css'
import { mapGetters } from 'vuex'
import { profileUserQuery, mapUserQuery } from '~/graphql/User'
import { groupQuery } from '~/graphql/groups'
import { filterMapPosts } from '~/graphql/PostQuery.js'
import { profileUserQuery } from '~/graphql/User'
import { mapQuery } from '~/graphql/MapQuery'
import mobile from '~/mixins/mobile'
import Empty from '~/components/Empty/Empty'
import MapStylesButtons from '~/components/Map/MapStylesButtons'
@ -363,7 +362,7 @@ export default {
if (this.isPreparedForMarkers) {
// add markers for "users"
this.users.forEach((user) => {
if (user.id !== this.currentUser.id && user.location) {
if (user.id !== this.currentUser.id) {
this.markers.geoJSON.push({
type: 'Feature',
properties: {
@ -403,45 +402,41 @@ export default {
}
// add markers for "groups"
this.groups.forEach((group) => {
if (group.location) {
this.markers.geoJSON.push({
type: 'Feature',
properties: {
type: 'group',
iconName: 'marker-red',
iconRotate: 0.0,
id: group.id,
slug: group.slug,
name: group.name,
description: group.about ? group.about : undefined,
},
geometry: {
type: 'Point',
coordinates: this.getCoordinates(group.location),
},
})
}
this.markers.geoJSON.push({
type: 'Feature',
properties: {
type: 'group',
iconName: 'marker-red',
iconRotate: 0.0,
id: group.id,
slug: group.slug,
name: group.name,
description: group.about ? group.about : undefined,
},
geometry: {
type: 'Point',
coordinates: this.getCoordinates(group.location),
},
})
})
// add markers for "posts", post type "Event" with location coordinates
this.posts.forEach((post) => {
if (post.postType.includes('Event') && post.eventLocation) {
this.markers.geoJSON.push({
type: 'Feature',
properties: {
type: 'event',
iconName: 'marker-purple',
iconRotate: 0.0,
id: post.id,
slug: post.slug,
name: post.title,
description: this.$filters.removeHtml(post.content),
},
geometry: {
type: 'Point',
coordinates: this.getCoordinates(post.eventLocation),
},
})
}
this.markers.geoJSON.push({
type: 'Feature',
properties: {
type: 'event',
iconName: 'marker-purple',
iconRotate: 0.0,
id: post.id,
slug: post.slug,
name: post.title,
description: this.$filters.removeHtml(post.content),
},
geometry: {
type: 'Point',
coordinates: this.getCoordinates(post.eventLocation),
},
})
})
this.markers.isGeoJSON = true
@ -514,47 +509,26 @@ export default {
},
},
apollo: {
User: {
mapData: {
query() {
return mapUserQuery(this.$i18n)
},
variables() {
return {}
},
update({ User }) {
this.users = User
this.addMarkersOnCheckPrepared()
},
fetchPolicy: 'cache-and-network',
},
Group: {
query() {
return groupQuery(this.$i18n)
},
variables() {
return {}
},
update({ Group }) {
this.groups = Group
this.addMarkersOnCheckPrepared()
},
fetchPolicy: 'cache-and-network',
},
Post: {
query() {
return filterMapPosts(this.$i18n)
return mapQuery(this.$i18n)
},
variables() {
return {
filter: {
userFilter: { hasLocation: true },
groupHasLocation: true,
postFilter: {
postType_in: ['Event'],
eventStart_gte: new Date(),
// would be good to just query for events with defined "eventLocation". couldn't get it working
hasLocation: true,
},
}
},
update({ Post }) {
update({ User, Group, Post }) {
this.users = User
this.groups = Group
this.posts = Post
this.addMarkersOnCheckPrepared()
},
fetchPolicy: 'cache-and-network',
},