Appwrite-Abfragen: select, notEqual, countDocuments und Nachbau dazu
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8c1055e8a0
commit
2dd9c0dec7
3 files changed
+190
-7
No files matched your search
@@ -91,6 +91,14 @@ export const Query = {
|
||||
cursorAfter: (documentId) => ({ method: 'cursorAfter', values: [documentId] }),
|
||||
orderDesc: (attribute) => ({ method: 'orderDesc', attribute }),
|
||||
orderAsc: (attribute) => ({ method: 'orderAsc', attribute }),
|
||||
/** Nur diese Felder zurueckgeben (die $-Felder wie $id liefert Appwrite immer mit) */
|
||||
select: (attributes) => ({ method: 'select', values: attributes }),
|
||||
/** Ein Wert oder eine Liste: kein Dokument mit einem der Werte ("keiner von") */
|
||||
notEqual: (attribute, value) => ({
|
||||
method: 'notEqual',
|
||||
attribute,
|
||||
values: Array.isArray(value) ? value : [value],
|
||||
}),
|
||||
}
|
||||
|
||||
export const ID = {
|
||||
@@ -169,6 +177,15 @@ export async function listDocuments(collectionId, queries = []) {
|
||||
return result.documents
|
||||
}
|
||||
|
||||
/**
|
||||
* Anzahl der Treffer: ein Aufruf mit limit(1), gelesen wird das Feld total der Antwort (Treffer ohne limit/cursor).
|
||||
* queries sind nur Filter, nie limit/order/cursor. Liefert null, wenn die Antwort kein total hat.
|
||||
*/
|
||||
export async function countDocuments(collectionId, queries = []) {
|
||||
const result = await adminFetch(collectionPath(collectionId), { queries: [...queries, Query.limit(1)] })
|
||||
return typeof result?.total === 'number' ? result.total : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Aufruf mit allen Seiten: sortiert nach $id, Seite fuer Seite per cursorAfter, bis eine Seite kuerzer als
|
||||
* pageSize ist. queries enthalten nur Filter, nie limit/order/cursor (die setzt die Funktion selbst). Mehr als max
|
||||
|
||||
+144
-1
@@ -17,7 +17,7 @@ const plain = await startAppwriteMock()
|
||||
const st = await startFakeStripe()
|
||||
after(() => Promise.all([aw.close(), plain.close(), st.close()]))
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||
const { Query, createDocument, createDocuments, getDocument, updateDocument, listDocuments, listAllDocuments } = await import(
|
||||
const { Query, createDocument, createDocuments, getDocument, updateDocument, listDocuments, listAllDocuments, countDocuments } = await import(
|
||||
'../server/services/appwriteAdmin.js'
|
||||
)
|
||||
|
||||
@@ -39,6 +39,9 @@ test('Query-Helfer bauen die Appwrite-Abfragen', () => {
|
||||
assert.deepEqual(Query.greaterThanEqual('startsAt', 'a'), { method: 'greaterThanEqual', attribute: 'startsAt', values: ['a'] })
|
||||
assert.deepEqual(Query.lessThanEqual('startsAt', 'b'), { method: 'lessThanEqual', attribute: 'startsAt', values: ['b'] })
|
||||
assert.deepEqual(Query.cursorAfter('doc1'), { method: 'cursorAfter', values: ['doc1'] })
|
||||
assert.deepEqual(Query.select(['a', 'b']), { method: 'select', values: ['a', 'b'] })
|
||||
assert.deepEqual(Query.notEqual('status', 'x'), { method: 'notEqual', attribute: 'status', values: ['x'] })
|
||||
assert.deepEqual(Query.notEqual('status', ['x', 'y']), { method: 'notEqual', attribute: 'status', values: ['x', 'y'] })
|
||||
})
|
||||
|
||||
test('Bulk-Create legt 3 Dokumente in einer Anfrage an', async () => {
|
||||
@@ -164,6 +167,146 @@ test('defaultLimit 25 liefert ohne limit-Abfrage 25 von 30', async () => {
|
||||
assert.equal((await listDocuments('termineOutbox', [...filter, Query.limit(5)])).length, 5)
|
||||
})
|
||||
|
||||
const seedOutbox = (feature, count, over = () => ({})) => {
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
aw.seed('termineOutbox', { $id: `t2_${feature}${String(i).padStart(2, '0')}`, featureId: feature, status: 'wartet', ...over(i) })
|
||||
}
|
||||
}
|
||||
const totalOf = async (queries) => {
|
||||
const url = `${rawDocs(aw, 'termineOutbox')}?${queries.map((q) => `queries[]=${encodeURIComponent(JSON.stringify(q))}`).join('&')}`
|
||||
return (await (await fetch(url)).json()).total
|
||||
}
|
||||
|
||||
test('select liefert nur die genannten Felder plus alle $-Felder', async () => {
|
||||
seedOutbox('f_sel', 2, (i) => ({ status: i === 0 ? 'wartet' : 'gesendet', empfaenger: 'a@example.invalid' }))
|
||||
const filter = [Query.equal('featureId', 'f_sel'), Query.orderAsc('$id')]
|
||||
const full = await listDocuments('termineOutbox', filter)
|
||||
assert.ok('featureId' in full[0] && 'status' in full[0] && 'empfaenger' in full[0])
|
||||
const slim = await listDocuments('termineOutbox', [...filter, Query.select(['status'])])
|
||||
assert.deepEqual(slim.map((d) => d.$id), ['t2_f_sel00', 't2_f_sel01'])
|
||||
assert.deepEqual(slim.map((d) => d.status), ['wartet', 'gesendet'])
|
||||
for (const doc of slim) {
|
||||
assert.deepEqual(
|
||||
Object.keys(doc).filter((key) => !key.startsWith('$')),
|
||||
['status']
|
||||
)
|
||||
// alle $-Felder bleiben, der interne Zaehler des Nachbaus nicht
|
||||
assert.deepEqual(Object.keys(doc).filter((key) => key.startsWith('$')).sort(), ['$collectionId', '$createdAt', '$id', '$updatedAt'])
|
||||
}
|
||||
// nur $id: die Abfrage filtert und sortiert trotzdem nach Feldern, die nicht ausgewaehlt sind
|
||||
const ids = await listDocuments('termineOutbox', [Query.equal('status', 'gesendet'), Query.equal('featureId', 'f_sel'), Query.select(['$id'])])
|
||||
assert.deepEqual(ids.map((d) => d.$id), ['t2_f_sel01'])
|
||||
assert.deepEqual(Object.keys(ids[0]).filter((key) => !key.startsWith('$')), [])
|
||||
// ein Feld, das das Dokument nicht hat, erscheint nicht
|
||||
const missing = await listDocuments('termineOutbox', [...filter, Query.select(['gibtEsNicht'])])
|
||||
assert.deepEqual(Object.keys(missing[0]).filter((key) => !key.startsWith('$')), [])
|
||||
// Einzelabruf bleibt vollstaendig
|
||||
assert.equal((await getDocument('termineOutbox', 't2_f_sel00')).featureId, 'f_sel')
|
||||
})
|
||||
|
||||
test('notEqual schliesst einen Wert oder alle Werte der Liste aus', async () => {
|
||||
seedOutbox('f_ne', 4, (i) => ({ status: ['wartet', 'gesendet', 'fehler', 'abgebrochen'][i] }))
|
||||
const filter = Query.equal('featureId', 'f_ne')
|
||||
const order = Query.orderAsc('$id')
|
||||
const without = async (value) => (await listDocuments('termineOutbox', [filter, Query.notEqual('status', value), order])).map((d) => d.status)
|
||||
assert.deepEqual(await without('wartet'), ['gesendet', 'fehler', 'abgebrochen'])
|
||||
assert.deepEqual(await without(['gesendet', 'fehler']), ['wartet', 'abgebrochen'])
|
||||
assert.deepEqual(await without(['wartet', 'gesendet', 'fehler', 'abgebrochen']), [])
|
||||
assert.deepEqual(await without(['gibtEsNicht']), ['wartet', 'gesendet', 'fehler', 'abgebrochen'])
|
||||
// zwei notEqual auf demselben Feld schneiden sich (beide Bedingungen muessen gelten)
|
||||
const both = await listDocuments('termineOutbox', [filter, Query.notEqual('status', 'wartet'), Query.notEqual('status', 'fehler'), order])
|
||||
assert.deepEqual(both.map((d) => d.status), ['gesendet', 'abgebrochen'])
|
||||
})
|
||||
|
||||
test('total zaehlt alle Treffer, auch ueber limit und cursorAfter hinaus; countDocuments liefert es', async () => {
|
||||
seedOutbox('f_total', 5)
|
||||
seedOutbox('f_total_fremd', 3)
|
||||
const filter = Query.equal('featureId', 'f_total')
|
||||
assert.equal(await totalOf([filter, Query.limit(2)]), 5)
|
||||
assert.equal(await totalOf([filter, Query.orderAsc('$id'), Query.cursorAfter('t2_f_total01'), Query.limit(2)]), 5)
|
||||
// das Cursor-Dokument zaehlt nur mit, wenn es zu den Filtern passt
|
||||
assert.equal(await totalOf([Query.equal('featureId', 'f_total'), Query.orderAsc('$id'), Query.cursorAfter('t2_f_total_fremd00'), Query.limit(2)]), 5)
|
||||
assert.equal(await totalOf([Query.equal('featureId', 'f_nirgends'), Query.limit(2)]), 0)
|
||||
assert.equal(await totalOf([filter]), 5, 'ohne limit gilt der Standard von 25, total bleibt die Trefferzahl')
|
||||
assert.equal(await countDocuments('termineOutbox', [filter]), 5)
|
||||
assert.equal(await countDocuments('termineOutbox', [filter, Query.notEqual('$id', ['t2_f_total00', 't2_f_total01'])]), 3)
|
||||
assert.equal(await countDocuments('termineOutbox', [Query.equal('featureId', 'f_nirgends')]), 0)
|
||||
// 30 Treffer ueber dem Standardlimit 25: total sagt 30, die Liste hat 25
|
||||
seedOutbox('f_total_dreissig', 30)
|
||||
assert.equal(await countDocuments('termineOutbox', [Query.equal('featureId', 'f_total_dreissig')]), 30)
|
||||
assert.equal((await listDocuments('termineOutbox', [Query.equal('featureId', 'f_total_dreissig')])).length, 25)
|
||||
})
|
||||
|
||||
test('countDocuments fragt mit limit(1) in einem Aufruf und liefert null ohne total', async () => {
|
||||
seedOutbox('f_count', 4)
|
||||
aw.resetListCalls()
|
||||
assert.equal(await countDocuments('termineOutbox', [Query.equal('featureId', 'f_count')]), 4)
|
||||
assert.deepEqual(aw.listCalls('termineOutbox'), [
|
||||
{
|
||||
collection: 'termineOutbox',
|
||||
queries: [
|
||||
{ method: 'equal', attribute: 'featureId', values: ['f_count'] },
|
||||
{ method: 'limit', values: [1] },
|
||||
],
|
||||
},
|
||||
])
|
||||
assert.equal(await countDocuments('termineOutbox'), await countDocuments('termineOutbox', []))
|
||||
// Antwort ohne total (oder mit einem Nicht-Zahlenwert): null, nicht 0 und nicht die Seitenlaenge
|
||||
const realFetch = globalThis.fetch
|
||||
const answer = (body) => () => Promise.resolve(new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }))
|
||||
try {
|
||||
globalThis.fetch = answer({ documents: [{ $id: 'x' }] })
|
||||
assert.equal(await countDocuments('termineOutbox', []), null)
|
||||
globalThis.fetch = answer({ total: 'viele', documents: [] })
|
||||
assert.equal(await countDocuments('termineOutbox', []), null)
|
||||
globalThis.fetch = answer({ total: 7, documents: [{ $id: 'x' }] })
|
||||
assert.equal(await countDocuments('termineOutbox', []), 7)
|
||||
} finally {
|
||||
globalThis.fetch = realFetch
|
||||
}
|
||||
// ein Fehler der Abfrage wird nicht verschluckt
|
||||
aw.failNext({ collection: 'termineOutbox', method: 'GET', status: 500 })
|
||||
await assert.rejects(countDocuments('termineOutbox', []), (err) => err.status === 500)
|
||||
})
|
||||
|
||||
test('listCalls zeichnet nur Listenabfragen auf, geparst, je Collection filterbar', async () => {
|
||||
aw.resetListCalls()
|
||||
assert.deepEqual(aw.listCalls(), [])
|
||||
await createDocument('termineClaims', cell('t2_lc1', { featureId: 'f_lc' }), 't2_lc1')
|
||||
await createDocuments('termineClaims', [cell('t2_lc2', { featureId: 'f_lc' })])
|
||||
await getDocument('termineClaims', 't2_lc1')
|
||||
await updateDocument('termineClaims', 't2_lc1', { bookingId: 'b9' })
|
||||
assert.deepEqual(aw.listCalls(), [], 'Anlegen, Bulk, Einzelabruf und Aendern sind keine Listenabfragen')
|
||||
await listDocuments('termineClaims', [Query.equal('featureId', 'f_lc'), Query.orderAsc('$id'), Query.limit(3)])
|
||||
await listDocuments('termineBookings')
|
||||
await listAllDocuments('termineClaims', [Query.equal('featureId', 'f_lc')], { pageSize: 1 })
|
||||
assert.deepEqual(aw.listCalls('termineClaims').slice(0, 1), [
|
||||
{
|
||||
collection: 'termineClaims',
|
||||
queries: [
|
||||
{ method: 'equal', attribute: 'featureId', values: ['f_lc'] },
|
||||
{ method: 'orderAsc', attribute: '$id' },
|
||||
{ method: 'limit', values: [3] },
|
||||
],
|
||||
},
|
||||
])
|
||||
assert.deepEqual(aw.listCalls('termineBookings'), [{ collection: 'termineBookings', queries: [] }])
|
||||
assert.deepEqual(aw.listCalls().map((c) => c.collection), ['termineClaims', 'termineBookings', 'termineClaims', 'termineClaims', 'termineClaims'])
|
||||
assert.deepEqual(aw.listCalls('gibtEsNicht'), [])
|
||||
// listAllDocuments liest ueber Cursor weiter: 2 Treffer bei pageSize 1 = 3 Seiten, die zweite ab dem ersten Treffer
|
||||
const pages = aw.listCalls('termineClaims').slice(1)
|
||||
assert.equal(pages.length, 3)
|
||||
assert.equal(pages[0].queries.some((q) => q.method === 'cursorAfter'), false)
|
||||
assert.deepEqual(pages[1].queries.at(-1), { method: 'cursorAfter', values: ['t2_lc1'] })
|
||||
assert.deepEqual(pages[2].queries.at(-1), { method: 'cursorAfter', values: ['t2_lc2'] })
|
||||
// die Rueckgabe ist eine Kopie: Aendern wirkt nicht auf die Aufzeichnung
|
||||
aw.listCalls()[0].queries.length = 0
|
||||
assert.equal(aw.listCalls('termineClaims')[0].queries.length, 3)
|
||||
aw.resetListCalls()
|
||||
assert.deepEqual(aw.listCalls(), [])
|
||||
assert.deepEqual(aw.listCalls('termineClaims'), [])
|
||||
})
|
||||
|
||||
test('Datum mit Z kommt als +00:00 zurueck (Anlegen, Aendern, Bulk, Seed)', async () => {
|
||||
const made = await createDocument('termineBookings', { featureId: 'f_dt', startsAt: '2026-10-05T10:00:00Z', endsAt: '2026-10-05T12:30:00+02:00', status: 'x' }, 't2_dt1')
|
||||
assert.equal(made.startsAt, '2026-10-05T10:00:00.000+00:00')
|
||||
|
||||
@@ -11,9 +11,12 @@ import http from 'node:http'
|
||||
*
|
||||
* Bulk-Create (POST .../documents mit { documents: [{ $id, ...felder }] }, 1 bis 100 Dokumente) ist alles oder
|
||||
* nichts: vorhandene oder doppelte $id => 409, ungueltige $id, unbekanntes Attribut oder ungueltiges Datum => 400,
|
||||
* dann ist nichts geschrieben. Abfragen: equal, greaterThan(Equal), lessThan(Equal), between (inklusiv), orderAsc/
|
||||
* orderDesc, limit, cursorAfter. Zwei Werte, die beide wie "YYYY-MM-DDT..." aussehen, vergleicht er als Zeitpunkte
|
||||
* (Schreibweise Z, +00:00, +02:00 ist egal), alle anderen wie bisher.
|
||||
* dann ist nichts geschrieben. Abfragen: equal, notEqual (mit Liste: keiner von; ein Dokument ohne das Feld passt),
|
||||
* greaterThan(Equal), lessThan(Equal), between (inklusiv), orderAsc/orderDesc, limit, cursorAfter, select (nur die
|
||||
* genannten Felder plus alle $-Felder). Die Antwort traegt total = Treffer der Filter, unabhaengig von limit und
|
||||
* cursorAfter. listCalls(collection?) / resetListCalls() zeichnen die Listenabfragen auf. Zwei Werte, die beide
|
||||
* wie "YYYY-MM-DDT..." aussehen, vergleicht er als Zeitpunkte (Schreibweise Z, +00:00, +02:00 ist egal), alle
|
||||
* anderen wie bisher.
|
||||
*
|
||||
* Optionen (ohne sie verhaelt sich der Nachbau wie zuvor):
|
||||
* datetimes { collection: ['feld', ...] } Datumsfelder; beim Schreiben (Anlegen, Aendern, Bulk, seed) wie
|
||||
@@ -33,6 +36,8 @@ export async function startAppwriteMock({ schema = {}, datetimes = {}, defaultLi
|
||||
let lastLocale = null
|
||||
let failUserLookups = false
|
||||
let sessionDeletes = 0
|
||||
// Aufzeichnung der Listenabfragen (GET .../documents ohne ID): [{ collection, queries: [geparste Abfragen] }]
|
||||
let listLog = []
|
||||
|
||||
// Wie Appwrite (Validator UID): 'unique()' ist erlaubt, sonst [A-Za-z0-9][A-Za-z0-9._-]{0,35}
|
||||
const validDocumentId = (id) => id === 'unique()' || (typeof id === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/.test(id))
|
||||
@@ -127,6 +132,8 @@ export async function startAppwriteMock({ schema = {}, datetimes = {}, defaultLi
|
||||
switch (query.method) {
|
||||
case 'equal':
|
||||
return query.values.some((candidate) => same(value, candidate))
|
||||
case 'notEqual':
|
||||
return !query.values.some((candidate) => same(value, candidate))
|
||||
case 'greaterThan':
|
||||
return greater(value, first)
|
||||
case 'greaterThanEqual':
|
||||
@@ -142,7 +149,10 @@ export async function startAppwriteMock({ schema = {}, datetimes = {}, defaultLi
|
||||
}
|
||||
}
|
||||
|
||||
const supportedQueries = ['equal', 'greaterThan', 'greaterThanEqual', 'lessThan', 'lessThanEqual', 'between', 'orderAsc', 'orderDesc', 'limit', 'cursorAfter']
|
||||
const supportedQueries = ['equal', 'greaterThan', 'greaterThanEqual', 'lessThan', 'lessThanEqual', 'between', 'notEqual', 'orderAsc', 'orderDesc', 'limit', 'cursorAfter', 'select']
|
||||
|
||||
// Nur die genannten Felder plus alle $-Felder (wie Appwrite); $seq ist intern und faellt in publicDoc weg
|
||||
const project = (doc, attributes) => Object.fromEntries(Object.entries(doc).filter(([key]) => key.startsWith('$') || attributes.includes(key)))
|
||||
|
||||
const list = (collection, rawQueries) => {
|
||||
const queries = rawQueries.map((q) => JSON.parse(q))
|
||||
@@ -153,7 +163,11 @@ export async function startAppwriteMock({ schema = {}, datetimes = {}, defaultLi
|
||||
if (cursorId !== undefined && !docs(collection).has(cursorId)) {
|
||||
return [400, { message: 'The cursor is invalid. This can happen if the item represented by the cursor has been deleted.', code: 400, type: 'general_cursor_not_found' }]
|
||||
}
|
||||
let result = [...docs(collection).values()].filter((doc) => doc.$id === cursorId || queries.every((q) => matches(doc, q)))
|
||||
const all = [...docs(collection).values()]
|
||||
const hit = (doc) => queries.every((q) => matches(doc, q))
|
||||
// total wie Appwrite: alle Treffer der Filter, ohne limit und cursorAfter
|
||||
const total = all.filter(hit).length
|
||||
let result = all.filter((doc) => doc.$id === cursorId || hit(doc))
|
||||
for (const q of queries.filter((q) => q.method === 'orderAsc' || q.method === 'orderDesc')) {
|
||||
const dir = q.method === 'orderAsc' ? 1 : -1
|
||||
result.sort((a, b) => {
|
||||
@@ -165,7 +179,9 @@ export async function startAppwriteMock({ schema = {}, datetimes = {}, defaultLi
|
||||
if (cursorId !== undefined) result = result.slice(result.findIndex((doc) => doc.$id === cursorId) + 1)
|
||||
const limit = queries.find((q) => q.method === 'limit')?.values[0] ?? defaultLimit
|
||||
if (limit !== null) result = result.slice(0, limit)
|
||||
return [200, { total: result.length, documents: result.map(publicDoc) }]
|
||||
const selected = queries.filter((q) => q.method === 'select').flatMap((q) => q.values)
|
||||
if (queries.some((q) => q.method === 'select')) result = result.map((doc) => project(doc, selected))
|
||||
return [200, { total, documents: result.map(publicDoc) }]
|
||||
}
|
||||
|
||||
const publicDoc = ({ $seq, ...doc }) => doc
|
||||
@@ -324,6 +340,8 @@ export async function startAppwriteMock({ schema = {}, datetimes = {}, defaultLi
|
||||
const store = docs(collection)
|
||||
const notFound = [404, { message: 'Document with the requested ID could not be found.', code: 404, type: 'document_not_found' }]
|
||||
|
||||
if (!id && req.method === 'GET') listLog.push({ collection, queries: url.searchParams.getAll('queries[]').map((q) => JSON.parse(q)) })
|
||||
|
||||
// Eingeplanter Fehler (failNext): genau eine passende Anfrage scheitert
|
||||
if (
|
||||
pendingFailure &&
|
||||
@@ -389,6 +407,11 @@ export async function startAppwriteMock({ schema = {}, datetimes = {}, defaultLi
|
||||
url: `http://127.0.0.1:${server.address().port}/v1`,
|
||||
docs: (collection) => new Map([...docs(collection)].map(([id, doc]) => [id, publicDoc(doc)])),
|
||||
seed,
|
||||
/** Aufgezeichnete Listenabfragen (nur GET auf eine Collection, auch fehlgeschlagene), je Aufruf eine Kopie; optional einer Collection */
|
||||
listCalls: (collection = null) => structuredClone(listLog.filter((call) => !collection || call.collection === collection)),
|
||||
resetListCalls: () => {
|
||||
listLog = []
|
||||
},
|
||||
/** Naechste passende Dokument-Anfrage scheitert mit status (einmalig); after passende Anfragen gehen vorher durch. */
|
||||
failNext: ({ collection = null, method = null, status = 500, message = '', after = 0 }) => {
|
||||
pendingFailure = { collection, method, status, message, after }
|
||||
|
||||
Reference in new issue
Block a user