feat(db-postgres): add point field support (#9078)
### What? Adds full support for the point field to Postgres and Vercel Postgres adapters through the Postgis extension. Fully the same API as with MongoDB, including support for `near`, `within` and `intersects` operators. Additionally, exposes to adapter args: * `tablesFilter`https://orm.drizzle.team/docs/drizzle-kit-push#including-tables-schemas-and-extensions. * `extensions` list of extensions to create, for example `['vector', 'pg_search']`, `postgis` is created automatically if there's any point field ### Why? It's essential to support that field type, especially if the postgres adapter should be out of beta on 3.0 stable. ### How? * Bumps `drizzle-orm` to `0.36.1` and `drizzle-kit` to `0.28.0` as we need this change https://github.com/drizzle-team/drizzle-orm/pull/3141 * Uses its functions to achieve querying functionality, for example the `near` operator works through `ST_DWithin` or `intersects` through `ST_Intersects`. * Removes MongoDB condition from all point field tests, but keeps for SQLite Resolves these discussions: https://github.com/payloadcms/payload/discussions/8996 https://github.com/payloadcms/payload/discussions/8644
This commit is contained in:
@@ -19,6 +19,16 @@ export interface Config {
|
||||
'payload-preferences': PayloadPreference;
|
||||
'payload-migrations': PayloadMigration;
|
||||
};
|
||||
collectionsJoins: {};
|
||||
collectionsSelect: {
|
||||
posts: PostsSelect<false> | PostsSelect<true>;
|
||||
simple: SimpleSelect<false> | SimpleSelect<true>;
|
||||
media: MediaSelect<false> | MediaSelect<true>;
|
||||
users: UsersSelect<false> | UsersSelect<true>;
|
||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
||||
};
|
||||
db: {
|
||||
defaultIDType: string;
|
||||
};
|
||||
@@ -26,6 +36,10 @@ export interface Config {
|
||||
menu: Menu;
|
||||
'custom-ts': CustomT;
|
||||
};
|
||||
globalsSelect: {
|
||||
menu: MenuSelect<false> | MenuSelect<true>;
|
||||
'custom-ts': CustomTsSelect<false> | CustomTsSelect<true>;
|
||||
};
|
||||
locale: null;
|
||||
user: User & {
|
||||
collection: 'users';
|
||||
@@ -232,6 +246,143 @@ export interface PayloadMigration {
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "posts_select".
|
||||
*/
|
||||
export interface PostsSelect<T extends boolean = true> {
|
||||
text?: T;
|
||||
serverTextField?: T;
|
||||
richText?: T;
|
||||
myBlocks?:
|
||||
| T
|
||||
| {
|
||||
test?:
|
||||
| T
|
||||
| {
|
||||
test?: T;
|
||||
id?: T;
|
||||
blockName?: T;
|
||||
};
|
||||
someBlock2?:
|
||||
| T
|
||||
| {
|
||||
test2?: T;
|
||||
id?: T;
|
||||
blockName?: T;
|
||||
};
|
||||
};
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
_status?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "simple_select".
|
||||
*/
|
||||
export interface SimpleSelect<T extends boolean = true> {
|
||||
text?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "media_select".
|
||||
*/
|
||||
export interface MediaSelect<T extends boolean = true> {
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
url?: T;
|
||||
thumbnailURL?: T;
|
||||
filename?: T;
|
||||
mimeType?: T;
|
||||
filesize?: T;
|
||||
width?: T;
|
||||
height?: T;
|
||||
focalX?: T;
|
||||
focalY?: T;
|
||||
sizes?:
|
||||
| T
|
||||
| {
|
||||
thumbnail?:
|
||||
| T
|
||||
| {
|
||||
url?: T;
|
||||
width?: T;
|
||||
height?: T;
|
||||
mimeType?: T;
|
||||
filesize?: T;
|
||||
filename?: T;
|
||||
};
|
||||
medium?:
|
||||
| T
|
||||
| {
|
||||
url?: T;
|
||||
width?: T;
|
||||
height?: T;
|
||||
mimeType?: T;
|
||||
filesize?: T;
|
||||
filename?: T;
|
||||
};
|
||||
large?:
|
||||
| T
|
||||
| {
|
||||
url?: T;
|
||||
width?: T;
|
||||
height?: T;
|
||||
mimeType?: T;
|
||||
filesize?: T;
|
||||
filename?: T;
|
||||
};
|
||||
};
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "users_select".
|
||||
*/
|
||||
export interface UsersSelect<T extends boolean = true> {
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
email?: T;
|
||||
resetPasswordToken?: T;
|
||||
resetPasswordExpiration?: T;
|
||||
salt?: T;
|
||||
hash?: T;
|
||||
loginAttempts?: T;
|
||||
lockUntil?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-locked-documents_select".
|
||||
*/
|
||||
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
|
||||
document?: T;
|
||||
globalSlug?: T;
|
||||
user?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-preferences_select".
|
||||
*/
|
||||
export interface PayloadPreferencesSelect<T extends boolean = true> {
|
||||
user?: T;
|
||||
key?: T;
|
||||
value?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-migrations_select".
|
||||
*/
|
||||
export interface PayloadMigrationsSelect<T extends boolean = true> {
|
||||
name?: T;
|
||||
batch?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "menu".
|
||||
@@ -265,6 +416,28 @@ export interface CustomT {
|
||||
export interface ObjectWithNumber {
|
||||
id?: number;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "menu_select".
|
||||
*/
|
||||
export interface MenuSelect<T extends boolean = true> {
|
||||
globalText?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
globalType?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "custom-ts_select".
|
||||
*/
|
||||
export interface CustomTsSelect<T extends boolean = true> {
|
||||
custom?: T;
|
||||
withDefinitionsUsage?: T;
|
||||
json?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
globalType?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "auth".
|
||||
|
||||
@@ -29,7 +29,7 @@ describe('collections-graphql', () => {
|
||||
if (payload.db.name === 'mongoose') {
|
||||
await new Promise((resolve, reject) => {
|
||||
payload.db?.collections?.point?.ensureIndexes(function (err) {
|
||||
if (err) reject(err)
|
||||
if (err) {reject(err)}
|
||||
resolve(true)
|
||||
})
|
||||
})
|
||||
@@ -578,13 +578,13 @@ describe('collections-graphql', () => {
|
||||
expect(docs).toContainEqual(expect.objectContaining({ id: specialPost.id }))
|
||||
})
|
||||
|
||||
if (['mongodb'].includes(process.env.PAYLOAD_DATABASE)) {
|
||||
describe('near', () => {
|
||||
const point = [10, 20]
|
||||
const [lat, lng] = point
|
||||
describe('near', () => {
|
||||
const point = [10, 20]
|
||||
const [lat, lng] = point
|
||||
|
||||
it('should return a document near a point', async () => {
|
||||
const nearQuery = `
|
||||
it('should return a document near a point', async () => {
|
||||
if (payload.db.name === 'sqlite') {return}
|
||||
const nearQuery = `
|
||||
query {
|
||||
Points(
|
||||
where: {
|
||||
@@ -600,16 +600,17 @@ describe('collections-graphql', () => {
|
||||
}
|
||||
}`
|
||||
|
||||
const { data } = await restClient
|
||||
.GRAPHQL_POST({ body: JSON.stringify({ query: nearQuery }) })
|
||||
.then((res) => res.json())
|
||||
const { docs } = data.Points
|
||||
const { data } = await restClient
|
||||
.GRAPHQL_POST({ body: JSON.stringify({ query: nearQuery }) })
|
||||
.then((res) => res.json())
|
||||
const { docs } = data.Points
|
||||
|
||||
expect(docs).toHaveLength(1)
|
||||
})
|
||||
expect(docs).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should not return a point far away', async () => {
|
||||
const nearQuery = `
|
||||
it('should not return a point far away', async () => {
|
||||
if (payload.db.name === 'sqlite') {return}
|
||||
const nearQuery = `
|
||||
query {
|
||||
Points(
|
||||
where: {
|
||||
@@ -625,29 +626,30 @@ describe('collections-graphql', () => {
|
||||
}
|
||||
}`
|
||||
|
||||
const { data } = await restClient
|
||||
.GRAPHQL_POST({ body: JSON.stringify({ query: nearQuery }) })
|
||||
.then((res) => res.json())
|
||||
const { docs } = data.Points
|
||||
const { data } = await restClient
|
||||
.GRAPHQL_POST({ body: JSON.stringify({ query: nearQuery }) })
|
||||
.then((res) => res.json())
|
||||
const { docs } = data.Points
|
||||
|
||||
expect(docs).toHaveLength(0)
|
||||
expect(docs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should sort find results by nearest distance', async () => {
|
||||
if (payload.db.name === 'sqlite') {return}
|
||||
// creating twice as many records as we are querying to get a random sample
|
||||
await mapAsync([...Array(10)], async () => {
|
||||
// randomize the creation timestamp
|
||||
await wait(Math.random())
|
||||
await payload.create({
|
||||
collection: pointSlug,
|
||||
data: {
|
||||
// only randomize longitude to make distance comparison easy
|
||||
point: [Math.random(), 0],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should sort find results by nearest distance', async () => {
|
||||
// creating twice as many records as we are querying to get a random sample
|
||||
await mapAsync([...Array(10)], async () => {
|
||||
// randomize the creation timestamp
|
||||
await wait(Math.random())
|
||||
await payload.create({
|
||||
collection: pointSlug,
|
||||
data: {
|
||||
// only randomize longitude to make distance comparison easy
|
||||
point: [Math.random(), 0],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const nearQuery = `
|
||||
const nearQuery = `
|
||||
query {
|
||||
Points(
|
||||
where: {
|
||||
@@ -655,6 +657,7 @@ describe('collections-graphql', () => {
|
||||
near: [0, 0, 100000, 0]
|
||||
}
|
||||
},
|
||||
sort: "point",
|
||||
limit: 5
|
||||
) {
|
||||
docs {
|
||||
@@ -664,32 +667,33 @@ describe('collections-graphql', () => {
|
||||
}
|
||||
}`
|
||||
|
||||
const { data } = await restClient
|
||||
.GRAPHQL_POST({ body: JSON.stringify({ query: nearQuery }) })
|
||||
.then((res) => res.json())
|
||||
const { docs } = data.Points
|
||||
const { data } = await restClient
|
||||
.GRAPHQL_POST({ body: JSON.stringify({ query: nearQuery }) })
|
||||
.then((res) => res.json())
|
||||
const { docs } = data.Points
|
||||
|
||||
let previous = 0
|
||||
docs.forEach(({ point: coordinates }) => {
|
||||
// The next document point should always be greater than the one before
|
||||
expect(previous).toBeLessThanOrEqual(coordinates[0])
|
||||
;[previous] = coordinates
|
||||
})
|
||||
let previous = 0
|
||||
docs.forEach(({ point: coordinates }) => {
|
||||
// The next document point should always be greater than the one before
|
||||
expect(previous).toBeLessThanOrEqual(coordinates[0])
|
||||
;[previous] = coordinates
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('within', () => {
|
||||
type Point = [number, number]
|
||||
const polygon: Point[] = [
|
||||
[9.0, 19.0], // bottom-left
|
||||
[9.0, 21.0], // top-left
|
||||
[11.0, 21.0], // top-right
|
||||
[11.0, 19.0], // bottom-right
|
||||
[9.0, 19.0], // back to starting point to close the polygon
|
||||
]
|
||||
describe('within', () => {
|
||||
type Point = [number, number]
|
||||
const polygon: Point[] = [
|
||||
[9.0, 19.0], // bottom-left
|
||||
[9.0, 21.0], // top-left
|
||||
[11.0, 21.0], // top-right
|
||||
[11.0, 19.0], // bottom-right
|
||||
[9.0, 19.0], // back to starting point to close the polygon
|
||||
]
|
||||
|
||||
it('should return a document with the point inside the polygon', async () => {
|
||||
const query = `
|
||||
it('should return a document with the point inside the polygon', async () => {
|
||||
if (payload.db.name === 'sqlite') {return}
|
||||
const query = `
|
||||
query {
|
||||
Points(
|
||||
where: {
|
||||
@@ -707,18 +711,19 @@ describe('collections-graphql', () => {
|
||||
}
|
||||
}`
|
||||
|
||||
const { data } = await restClient
|
||||
.GRAPHQL_POST({ body: JSON.stringify({ query }) })
|
||||
.then((res) => res.json())
|
||||
const { docs } = data.Points
|
||||
const { data } = await restClient
|
||||
.GRAPHQL_POST({ body: JSON.stringify({ query }) })
|
||||
.then((res) => res.json())
|
||||
const { docs } = data.Points
|
||||
|
||||
expect(docs).toHaveLength(1)
|
||||
expect(docs[0].point).toEqual([10, 20])
|
||||
})
|
||||
expect(docs).toHaveLength(1)
|
||||
expect(docs[0].point).toEqual([10, 20])
|
||||
})
|
||||
|
||||
it('should not return a document with the point outside the polygon', async () => {
|
||||
const reducedPolygon = polygon.map((vertex) => vertex.map((coord) => coord * 0.1))
|
||||
const query = `
|
||||
it('should not return a document with the point outside the polygon', async () => {
|
||||
if (payload.db.name === 'sqlite') {return}
|
||||
const reducedPolygon = polygon.map((vertex) => vertex.map((coord) => coord * 0.1))
|
||||
const query = `
|
||||
query {
|
||||
Points(
|
||||
where: {
|
||||
@@ -736,27 +741,28 @@ describe('collections-graphql', () => {
|
||||
}
|
||||
}`
|
||||
|
||||
const { data } = await restClient
|
||||
.GRAPHQL_POST({ body: JSON.stringify({ query }) })
|
||||
.then((res) => res.json())
|
||||
const { docs } = data.Points
|
||||
const { data } = await restClient
|
||||
.GRAPHQL_POST({ body: JSON.stringify({ query }) })
|
||||
.then((res) => res.json())
|
||||
const { docs } = data.Points
|
||||
|
||||
expect(docs).toHaveLength(0)
|
||||
})
|
||||
expect(docs).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('intersects', () => {
|
||||
type Point = [number, number]
|
||||
const polygon: Point[] = [
|
||||
[9.0, 19.0], // bottom-left
|
||||
[9.0, 21.0], // top-left
|
||||
[11.0, 21.0], // top-right
|
||||
[11.0, 19.0], // bottom-right
|
||||
[9.0, 19.0], // back to starting point to close the polygon
|
||||
]
|
||||
describe('intersects', () => {
|
||||
type Point = [number, number]
|
||||
const polygon: Point[] = [
|
||||
[9.0, 19.0], // bottom-left
|
||||
[9.0, 21.0], // top-left
|
||||
[11.0, 21.0], // top-right
|
||||
[11.0, 19.0], // bottom-right
|
||||
[9.0, 19.0], // back to starting point to close the polygon
|
||||
]
|
||||
|
||||
it('should return a document with the point intersecting the polygon', async () => {
|
||||
const query = `
|
||||
it('should return a document with the point intersecting the polygon', async () => {
|
||||
if (payload.db.name === 'sqlite') {return}
|
||||
const query = `
|
||||
query {
|
||||
Points(
|
||||
where: {
|
||||
@@ -774,18 +780,19 @@ describe('collections-graphql', () => {
|
||||
}
|
||||
}`
|
||||
|
||||
const { data } = await restClient
|
||||
.GRAPHQL_POST({ body: JSON.stringify({ query }) })
|
||||
.then((res) => res.json())
|
||||
const { docs } = data.Points
|
||||
const { data } = await restClient
|
||||
.GRAPHQL_POST({ body: JSON.stringify({ query }) })
|
||||
.then((res) => res.json())
|
||||
const { docs } = data.Points
|
||||
|
||||
expect(docs).toHaveLength(1)
|
||||
expect(docs[0].point).toEqual([10, 20])
|
||||
})
|
||||
expect(docs).toHaveLength(1)
|
||||
expect(docs[0].point).toEqual([10, 20])
|
||||
})
|
||||
|
||||
it('should not return a document with the point not intersecting a smaller polygon', async () => {
|
||||
const reducedPolygon = polygon.map((vertex) => vertex.map((coord) => coord * 0.1))
|
||||
const query = `
|
||||
it('should not return a document with the point not intersecting a smaller polygon', async () => {
|
||||
if (payload.db.name === 'sqlite') {return}
|
||||
const reducedPolygon = polygon.map((vertex) => vertex.map((coord) => coord * 0.1))
|
||||
const query = `
|
||||
query {
|
||||
Points(
|
||||
where: {
|
||||
@@ -803,15 +810,14 @@ describe('collections-graphql', () => {
|
||||
}
|
||||
}`
|
||||
|
||||
const { data } = await restClient
|
||||
.GRAPHQL_POST({ body: JSON.stringify({ query }) })
|
||||
.then((res) => res.json())
|
||||
const { docs } = data.Points
|
||||
const { data } = await restClient
|
||||
.GRAPHQL_POST({ body: JSON.stringify({ query }) })
|
||||
.then((res) => res.json())
|
||||
const { docs } = data.Points
|
||||
|
||||
expect(docs).toHaveLength(0)
|
||||
})
|
||||
expect(docs).toHaveLength(0)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('can query deeply nested fields within rows, tabs, collapsibles', async () => {
|
||||
const withNestedField = await createPost({ D1: { D2: { D3: { D4: 'nested message' } } } })
|
||||
|
||||
@@ -1103,185 +1103,194 @@ describe('collections-rest', () => {
|
||||
})
|
||||
})
|
||||
|
||||
if (['mongodb'].includes(process.env.PAYLOAD_DATABASE)) {
|
||||
describe('near', () => {
|
||||
const point = [10, 20]
|
||||
const [lat, lng] = point
|
||||
it('should return a document near a point', async () => {
|
||||
const near = `${lat + 0.01}, ${lng + 0.01}, 10000`
|
||||
const response = await restClient.GET(`/${pointSlug}`, {
|
||||
query: {
|
||||
where: {
|
||||
point: {
|
||||
near,
|
||||
},
|
||||
describe('near', () => {
|
||||
const point = [10, 20]
|
||||
const [lat, lng] = point
|
||||
it('should return a document near a point', async () => {
|
||||
if (payload.db.name === 'sqlite') {return}
|
||||
|
||||
const near = `${lat + 0.01}, ${lng + 0.01}, 10000`
|
||||
const response = await restClient.GET(`/${pointSlug}`, {
|
||||
query: {
|
||||
where: {
|
||||
point: {
|
||||
near,
|
||||
},
|
||||
},
|
||||
})
|
||||
const result = await response.json()
|
||||
|
||||
expect(response.status).toEqual(200)
|
||||
expect(result.docs).toHaveLength(1)
|
||||
},
|
||||
})
|
||||
const result = await response.json()
|
||||
|
||||
it('should not return a point far away', async () => {
|
||||
const near = `${lng + 1}, ${lat - 1}, 5000`
|
||||
const response = await restClient.GET(`/${pointSlug}`, {
|
||||
query: {
|
||||
where: {
|
||||
point: {
|
||||
near,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const result = await response.json()
|
||||
|
||||
expect(response.status).toEqual(200)
|
||||
expect(result.docs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should sort find results by nearest distance', async () => {
|
||||
// creating twice as many records as we are querying to get a random sample
|
||||
const promises = []
|
||||
for (let i = 0; i < 11; i++) {
|
||||
// setTimeout used to randomize the creation timestamp
|
||||
setTimeout(() => {
|
||||
promises.push(
|
||||
payload.create({
|
||||
collection: pointSlug,
|
||||
data: {
|
||||
// only randomize longitude to make distance comparison easy
|
||||
point: [Math.random(), 0],
|
||||
},
|
||||
}),
|
||||
)
|
||||
}, Math.random())
|
||||
}
|
||||
await Promise.all(promises)
|
||||
|
||||
const { docs } = await restClient
|
||||
.GET(`/${pointSlug}`, {
|
||||
query: {
|
||||
limit: 5,
|
||||
where: {
|
||||
point: {
|
||||
// querying large enough range to include all docs
|
||||
near: '0, 0, 100000, 0',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
.then((res) => res.json())
|
||||
|
||||
let previous = 0
|
||||
docs.forEach(({ point: coordinates }) => {
|
||||
// the next document point should always be greater than the one before
|
||||
expect(previous).toBeLessThanOrEqual(coordinates[0])
|
||||
;[previous] = coordinates
|
||||
})
|
||||
})
|
||||
expect(response.status).toEqual(200)
|
||||
expect(result.docs).toHaveLength(1)
|
||||
})
|
||||
|
||||
describe('within', () => {
|
||||
type Point = [number, number]
|
||||
const polygon: Point[] = [
|
||||
[9.0, 19.0], // bottom-left
|
||||
[9.0, 21.0], // top-left
|
||||
[11.0, 21.0], // top-right
|
||||
[11.0, 19.0], // bottom-right
|
||||
[9.0, 19.0], // back to starting point to close the polygon
|
||||
]
|
||||
it('should return a document with the point inside the polygon', async () => {
|
||||
// There should be 1 total points document populated by default with the point [10, 20]
|
||||
const response = await restClient.GET(`/${pointSlug}`, {
|
||||
query: {
|
||||
where: {
|
||||
point: {
|
||||
within: {
|
||||
type: 'Polygon',
|
||||
coordinates: [polygon],
|
||||
},
|
||||
},
|
||||
it('should not return a point far away', async () => {
|
||||
if (payload.db.name === 'sqlite') {return}
|
||||
|
||||
const near = `${lng + 1}, ${lat + 1}, 5000`
|
||||
const response = await restClient.GET(`/${pointSlug}`, {
|
||||
query: {
|
||||
where: {
|
||||
point: {
|
||||
near,
|
||||
},
|
||||
},
|
||||
})
|
||||
const result = await response.json()
|
||||
|
||||
expect(response.status).toEqual(200)
|
||||
expect(result.docs).toHaveLength(1)
|
||||
},
|
||||
})
|
||||
const result = await response.json()
|
||||
|
||||
it('should not return a document with the point outside a smaller polygon', async () => {
|
||||
const response = await restClient.GET(`/${pointSlug}`, {
|
||||
query: {
|
||||
where: {
|
||||
point: {
|
||||
within: {
|
||||
type: 'Polygon',
|
||||
coordinates: [polygon.map((vertex) => vertex.map((coord) => coord * 0.1))], // Reduce polygon to 10% of its size
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const result = await response.json()
|
||||
|
||||
expect(response.status).toEqual(200)
|
||||
expect(result.docs).toHaveLength(0)
|
||||
})
|
||||
expect(response.status).toEqual(200)
|
||||
expect(result.docs).toHaveLength(0)
|
||||
})
|
||||
|
||||
describe('intersects', () => {
|
||||
type Point = [number, number]
|
||||
const polygon: Point[] = [
|
||||
[9.0, 19.0], // bottom-left
|
||||
[9.0, 21.0], // top-left
|
||||
[11.0, 21.0], // top-right
|
||||
[11.0, 19.0], // bottom-right
|
||||
[9.0, 19.0], // back to starting point to close the polygon
|
||||
]
|
||||
it('should sort find results by nearest distance', async () => {
|
||||
if (payload.db.name === 'sqlite') {return}
|
||||
|
||||
it('should return a document with the point intersecting the polygon', async () => {
|
||||
// There should be 1 total points document populated by default with the point [10, 20]
|
||||
const response = await restClient.GET(`/${pointSlug}`, {
|
||||
// creating twice as many records as we are querying to get a random sample
|
||||
const promises = []
|
||||
for (let i = 0; i < 11; i++) {
|
||||
// setTimeout used to randomize the creation timestamp
|
||||
setTimeout(() => {
|
||||
promises.push(
|
||||
payload.create({
|
||||
collection: pointSlug,
|
||||
data: {
|
||||
// only randomize longitude to make distance comparison easy
|
||||
point: [Math.random(), 0],
|
||||
},
|
||||
}),
|
||||
)
|
||||
}, Math.random())
|
||||
}
|
||||
await Promise.all(promises)
|
||||
|
||||
const { docs } = await restClient
|
||||
.GET(`/${pointSlug}`, {
|
||||
query: {
|
||||
limit: 5,
|
||||
sort: 'point',
|
||||
where: {
|
||||
point: {
|
||||
intersects: {
|
||||
type: 'Polygon',
|
||||
coordinates: [polygon],
|
||||
},
|
||||
// querying large enough range to include all docs
|
||||
near: '0, 0, 100000, 0',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const result = await response.json()
|
||||
.then((res) => res.json())
|
||||
|
||||
expect(response.status).toEqual(200)
|
||||
expect(result.docs).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should not return a document with the point not intersecting a smaller polygon', async () => {
|
||||
const response = await restClient.GET(`/${pointSlug}`, {
|
||||
query: {
|
||||
where: {
|
||||
point: {
|
||||
intersects: {
|
||||
type: 'Polygon',
|
||||
coordinates: [polygon.map((vertex) => vertex.map((coord) => coord * 0.1))], // Reduce polygon to 10% of its size
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const result = await response.json()
|
||||
|
||||
expect(response.status).toEqual(200)
|
||||
expect(result.docs).toHaveLength(0)
|
||||
let previous = 0
|
||||
docs.forEach(({ point: coordinates }) => {
|
||||
// the next document point should always be greater than the one before
|
||||
expect(previous).toBeLessThanOrEqual(coordinates[0])
|
||||
;[previous] = coordinates
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('within', () => {
|
||||
type Point = [number, number]
|
||||
const polygon: Point[] = [
|
||||
[9.0, 19.0], // bottom-left
|
||||
[9.0, 21.0], // top-left
|
||||
[11.0, 21.0], // top-right
|
||||
[11.0, 19.0], // bottom-right
|
||||
[9.0, 19.0], // back to starting point to close the polygon
|
||||
]
|
||||
it('should return a document with the point inside the polygon', async () => {
|
||||
if (payload.db.name === 'sqlite') {return}
|
||||
// There should be 1 total points document populated by default with the point [10, 20]
|
||||
const response = await restClient.GET(`/${pointSlug}`, {
|
||||
query: {
|
||||
where: {
|
||||
point: {
|
||||
within: {
|
||||
type: 'Polygon',
|
||||
coordinates: [polygon],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const result = await response.json()
|
||||
|
||||
expect(response.status).toEqual(200)
|
||||
expect(result.docs).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should not return a document with the point outside a smaller polygon', async () => {
|
||||
if (payload.db.name === 'sqlite') {return}
|
||||
const response = await restClient.GET(`/${pointSlug}`, {
|
||||
query: {
|
||||
where: {
|
||||
point: {
|
||||
within: {
|
||||
type: 'Polygon',
|
||||
coordinates: [polygon.map((vertex) => vertex.map((coord) => coord * 0.1))], // Reduce polygon to 10% of its size
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const result = await response.json()
|
||||
|
||||
expect(response.status).toEqual(200)
|
||||
expect(result.docs).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('intersects', () => {
|
||||
type Point = [number, number]
|
||||
const polygon: Point[] = [
|
||||
[9.0, 19.0], // bottom-left
|
||||
[9.0, 21.0], // top-left
|
||||
[11.0, 21.0], // top-right
|
||||
[11.0, 19.0], // bottom-right
|
||||
[9.0, 19.0], // back to starting point to close the polygon
|
||||
]
|
||||
|
||||
it('should return a document with the point intersecting the polygon', async () => {
|
||||
if (payload.db.name === 'sqlite') {return}
|
||||
// There should be 1 total points document populated by default with the point [10, 20]
|
||||
const response = await restClient.GET(`/${pointSlug}`, {
|
||||
query: {
|
||||
where: {
|
||||
point: {
|
||||
intersects: {
|
||||
type: 'Polygon',
|
||||
coordinates: [polygon],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const result = await response.json()
|
||||
|
||||
expect(response.status).toEqual(200)
|
||||
expect(result.docs).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should not return a document with the point not intersecting a smaller polygon', async () => {
|
||||
if (payload.db.name === 'sqlite') {return}
|
||||
const response = await restClient.GET(`/${pointSlug}`, {
|
||||
query: {
|
||||
where: {
|
||||
point: {
|
||||
intersects: {
|
||||
type: 'Polygon',
|
||||
coordinates: [polygon.map((vertex) => vertex.map((coord) => coord * 0.1))], // Reduce polygon to 10% of its size
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const result = await response.json()
|
||||
|
||||
expect(response.status).toEqual(200)
|
||||
expect(result.docs).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('or', async () => {
|
||||
const post1 = await createPost({ title: 'post1' })
|
||||
|
||||
@@ -103,6 +103,11 @@ export default buildConfigWithDefaults({
|
||||
{ value: 'default', label: 'Default' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'point',
|
||||
type: 'point',
|
||||
defaultValue: [10, 20],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -598,6 +598,10 @@ describe('database', () => {
|
||||
expect(result.array[0].defaultValue).toStrictEqual('default value from database')
|
||||
expect(result.group.defaultValue).toStrictEqual('default value from database')
|
||||
expect(result.select).toStrictEqual('default')
|
||||
// eslint-disable-next-line jest/no-conditional-in-test
|
||||
if (payload.db.name !== 'sqlite') {
|
||||
expect(result.point).toStrictEqual({ coordinates: [10, 20], type: 'Point' })
|
||||
}
|
||||
})
|
||||
})
|
||||
describe('drizzle: schema hooks', () => {
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface Config {
|
||||
'payload-preferences': PayloadPreference;
|
||||
'payload-migrations': PayloadMigration;
|
||||
};
|
||||
collectionsJoins: {};
|
||||
collectionsSelect: {
|
||||
posts: PostsSelect<false> | PostsSelect<true>;
|
||||
'default-values': DefaultValuesSelect<false> | DefaultValuesSelect<true>;
|
||||
@@ -106,6 +107,11 @@ export interface DefaultValue {
|
||||
defaultValue?: string | null;
|
||||
};
|
||||
select?: ('option0' | 'option1' | 'default') | null;
|
||||
/**
|
||||
* @minItems 2
|
||||
* @maxItems 2
|
||||
*/
|
||||
point?: [number, number] | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -263,6 +269,7 @@ export interface CustomId {
|
||||
id: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
_status?: ('draft' | 'published') | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
@@ -400,6 +407,7 @@ export interface DefaultValuesSelect<T extends boolean = true> {
|
||||
defaultValue?: T;
|
||||
};
|
||||
select?: T;
|
||||
point?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
@@ -531,6 +539,7 @@ export interface CustomIdsSelect<T extends boolean = true> {
|
||||
id?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
_status?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
|
||||
@@ -982,88 +982,91 @@ describe('Fields', () => {
|
||||
expect(definitions['version.text']).toEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('point', () => {
|
||||
let doc
|
||||
const point = [7, -7]
|
||||
const localized = [5, -2]
|
||||
const group = { point: [1, 9] }
|
||||
|
||||
beforeEach(async () => {
|
||||
const findDoc = await payload.find({
|
||||
collection: 'point-fields',
|
||||
pagination: false,
|
||||
})
|
||||
;[doc] = findDoc.docs
|
||||
})
|
||||
|
||||
it('should read', async () => {
|
||||
const find = await payload.find({
|
||||
collection: 'point-fields',
|
||||
pagination: false,
|
||||
})
|
||||
|
||||
;[doc] = find.docs
|
||||
|
||||
expect(doc.point).toEqual(pointDoc.point)
|
||||
expect(doc.localized).toEqual(pointDoc.localized)
|
||||
expect(doc.group).toMatchObject(pointDoc.group)
|
||||
})
|
||||
|
||||
it('should create', async () => {
|
||||
doc = await payload.create({
|
||||
collection: 'point-fields',
|
||||
data: {
|
||||
group,
|
||||
localized,
|
||||
point,
|
||||
},
|
||||
})
|
||||
|
||||
expect(doc.point).toEqual(point)
|
||||
expect(doc.localized).toEqual(localized)
|
||||
expect(doc.group).toMatchObject(group)
|
||||
})
|
||||
|
||||
it('should not create duplicate point when unique', async () => {
|
||||
// first create the point field
|
||||
doc = await payload.create({
|
||||
collection: 'point-fields',
|
||||
data: {
|
||||
group,
|
||||
localized,
|
||||
point,
|
||||
},
|
||||
})
|
||||
|
||||
// Now make sure we can't create a duplicate (since 'localized' is a unique field)
|
||||
await expect(() =>
|
||||
payload.create({
|
||||
collection: 'point-fields',
|
||||
data: {
|
||||
group,
|
||||
localized,
|
||||
point,
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(Error)
|
||||
|
||||
await expect(async () =>
|
||||
payload.create({
|
||||
collection: 'number-fields',
|
||||
data: {
|
||||
min: 5,
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow('The following field is invalid: min')
|
||||
|
||||
expect(doc.point).toEqual(point)
|
||||
expect(doc.localized).toEqual(localized)
|
||||
expect(doc.group).toMatchObject(group)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe('point', () => {
|
||||
let doc
|
||||
const point = [7, -7]
|
||||
const localized = [5, -2]
|
||||
const group = { point: [1, 9] }
|
||||
|
||||
beforeEach(async () => {
|
||||
const findDoc = await payload.find({
|
||||
collection: 'point-fields',
|
||||
pagination: false,
|
||||
})
|
||||
;[doc] = findDoc.docs
|
||||
})
|
||||
|
||||
it('should read', async () => {
|
||||
if (payload.db.name === 'sqlite') {return}
|
||||
const find = await payload.find({
|
||||
collection: 'point-fields',
|
||||
pagination: false,
|
||||
})
|
||||
|
||||
;[doc] = find.docs
|
||||
|
||||
expect(doc.point).toEqual(pointDoc.point)
|
||||
expect(doc.localized).toEqual(pointDoc.localized)
|
||||
expect(doc.group).toMatchObject(pointDoc.group)
|
||||
})
|
||||
|
||||
it('should create', async () => {
|
||||
if (payload.db.name === 'sqlite') {return}
|
||||
doc = await payload.create({
|
||||
collection: 'point-fields',
|
||||
data: {
|
||||
group,
|
||||
localized,
|
||||
point,
|
||||
},
|
||||
})
|
||||
|
||||
expect(doc.point).toEqual(point)
|
||||
expect(doc.localized).toEqual(localized)
|
||||
expect(doc.group).toMatchObject(group)
|
||||
})
|
||||
|
||||
it('should not create duplicate point when unique', async () => {
|
||||
if (payload.db.name === 'sqlite') {return}
|
||||
// first create the point field
|
||||
doc = await payload.create({
|
||||
collection: 'point-fields',
|
||||
data: {
|
||||
group,
|
||||
localized,
|
||||
point,
|
||||
},
|
||||
})
|
||||
|
||||
// Now make sure we can't create a duplicate (since 'localized' is a unique field)
|
||||
await expect(() =>
|
||||
payload.create({
|
||||
collection: 'point-fields',
|
||||
data: {
|
||||
group,
|
||||
localized,
|
||||
point,
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(Error)
|
||||
|
||||
await expect(async () =>
|
||||
payload.create({
|
||||
collection: 'number-fields',
|
||||
data: {
|
||||
min: 5,
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow('The following field is invalid: min')
|
||||
|
||||
expect(doc.point).toEqual(point)
|
||||
expect(doc.localized).toEqual(localized)
|
||||
expect(doc.group).toMatchObject(group)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unique indexes', () => {
|
||||
it('should throw validation error saving on unique fields', async () => {
|
||||
const data = {
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
"comment-json": "^4.2.3",
|
||||
"create-payload-app": "workspace:*",
|
||||
"dotenv": "16.4.5",
|
||||
"drizzle-kit": "0.26.2",
|
||||
"drizzle-kit": "0.28.0",
|
||||
"eslint-plugin-playwright": "1.7.0",
|
||||
"execa": "5.1.1",
|
||||
"file-type": "19.3.0",
|
||||
|
||||
15
test/select/collections/Points/index.ts
Normal file
15
test/select/collections/Points/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
export const Points: CollectionConfig = {
|
||||
slug: 'points',
|
||||
fields: [
|
||||
{
|
||||
type: 'text',
|
||||
name: 'text',
|
||||
},
|
||||
{
|
||||
type: 'point',
|
||||
name: 'point',
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { devUser } from '../credentials.js'
|
||||
import { DeepPostsCollection } from './collections/DeepPosts/index.js'
|
||||
import { LocalizedPostsCollection } from './collections/LocalizedPosts/index.js'
|
||||
import { Pages } from './collections/Pages/index.js'
|
||||
import { Points } from './collections/Points/index.js'
|
||||
import { PostsCollection } from './collections/Posts/index.js'
|
||||
import { VersionedPostsCollection } from './collections/VersionedPosts/index.js'
|
||||
|
||||
@@ -21,6 +22,7 @@ export default buildConfigWithDefaults({
|
||||
VersionedPostsCollection,
|
||||
DeepPostsCollection,
|
||||
Pages,
|
||||
Points,
|
||||
],
|
||||
globals: [
|
||||
{
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
GlobalPost,
|
||||
LocalizedPost,
|
||||
Page,
|
||||
Point,
|
||||
Post,
|
||||
VersionedPost,
|
||||
} from './payload-types.js'
|
||||
@@ -40,14 +41,21 @@ describe('Select', () => {
|
||||
let post: Post
|
||||
let postId: number | string
|
||||
|
||||
let point: Point
|
||||
let pointId: number | string
|
||||
|
||||
beforeEach(async () => {
|
||||
post = await createPost()
|
||||
postId = post.id
|
||||
|
||||
point = await createPoint()
|
||||
pointId = point.id
|
||||
})
|
||||
|
||||
// Clean up to safely mutate in each test
|
||||
afterEach(async () => {
|
||||
await payload.delete({ id: postId, collection: 'posts' })
|
||||
await payload.delete({ id: pointId, collection: 'points' })
|
||||
})
|
||||
|
||||
describe('Include mode', () => {
|
||||
@@ -307,6 +315,23 @@ describe('Select', () => {
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
it('should select a point field', async () => {
|
||||
if (payload.db.name === 'sqlite') {
|
||||
return
|
||||
}
|
||||
|
||||
const res = await payload.findByID({
|
||||
collection: 'points',
|
||||
id: pointId,
|
||||
select: { point: true },
|
||||
})
|
||||
|
||||
expect(res).toStrictEqual({
|
||||
id: pointId,
|
||||
point: point.point,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Exclude mode', () => {
|
||||
@@ -486,6 +511,23 @@ describe('Select', () => {
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('should exclude a point field', async () => {
|
||||
if (payload.db.name === 'sqlite') {
|
||||
return
|
||||
}
|
||||
const res = await payload.findByID({
|
||||
collection: 'points',
|
||||
id: pointId,
|
||||
select: { point: false },
|
||||
})
|
||||
|
||||
const copy = { ...point }
|
||||
|
||||
delete copy['point']
|
||||
|
||||
expect(res).toStrictEqual(copy)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2032,3 +2074,7 @@ function createVersionedPost() {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function createPoint() {
|
||||
return payload.create({ collection: 'points', data: { text: 'some', point: [10, 20] } })
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface Config {
|
||||
'versioned-posts': VersionedPost;
|
||||
'deep-posts': DeepPost;
|
||||
pages: Page;
|
||||
points: Point;
|
||||
users: User;
|
||||
'payload-locked-documents': PayloadLockedDocument;
|
||||
'payload-preferences': PayloadPreference;
|
||||
@@ -28,6 +29,7 @@ export interface Config {
|
||||
'versioned-posts': VersionedPostsSelect<false> | VersionedPostsSelect<true>;
|
||||
'deep-posts': DeepPostsSelect<false> | DeepPostsSelect<true>;
|
||||
pages: PagesSelect<false> | PagesSelect<true>;
|
||||
points: PointsSelect<false> | PointsSelect<true>;
|
||||
users: UsersSelect<false> | UsersSelect<true>;
|
||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||
@@ -306,6 +308,21 @@ export interface Page {
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "points".
|
||||
*/
|
||||
export interface Point {
|
||||
id: string;
|
||||
text?: string | null;
|
||||
/**
|
||||
* @minItems 2
|
||||
* @maxItems 2
|
||||
*/
|
||||
point?: [number, number] | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "users".
|
||||
@@ -350,6 +367,10 @@ export interface PayloadLockedDocument {
|
||||
relationTo: 'pages';
|
||||
value: string | Page;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'points';
|
||||
value: string | Point;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'users';
|
||||
value: string | User;
|
||||
@@ -631,6 +652,16 @@ export interface PagesSelect<T extends boolean = true> {
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "points_select".
|
||||
*/
|
||||
export interface PointsSelect<T extends boolean = true> {
|
||||
text?: T;
|
||||
point?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "users_select".
|
||||
|
||||
Reference in New Issue
Block a user