Skip to content

Commit

Permalink
handle ordinal life cycle
Browse files Browse the repository at this point in the history
- with a temporary script to create ordinals on existing elements
- allow to update ordinals, by accepting paginated elements subset for performance and client friendlyness
- should test that removing element then reordering is ok
  • Loading branch information
jum-s committed Apr 22, 2024
1 parent 92aad55 commit 40a8d56
Show file tree
Hide file tree
Showing 8 changed files with 261 additions and 7 deletions.
89 changes: 89 additions & 0 deletions scripts/db_actions/assign_elements_ordinal_to_all_listings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
#!/usr/bin/env tsx
// Temporary updater for assigning elements `ordinal` to elements documents which do not have one
import { compact, property, sortBy } from 'lodash-es'
import { getElementsByListings } from '#controllers/listings/lib/elements'
import dbFactory from '#db/couchdb/base'
import { isNonEmptyArray } from '#lib/boolean_validations'
import { wait } from '#lib/promises'
import { updateElementDoc } from '#models/element'
import { shellExec } from '#scripts/scripts_utils'
import config from '#server/config'

const dbBaseUrl = config.db.getOrigin()

const assignElementsOrdinalToAllListings = async () => {
const getAllListingsIdsCurlCommand = `curl -H 'Content-Type:application/json' -H 'Accept: application/json' '${dbBaseUrl}/${config.db.name('lists')}/_all_docs?include_docs=true' | jq '.rows[] | .doc._id' | jq -s`
const { stdout } = await shellExec(getAllListingsIdsCurlCommand)
const allListingsIds = JSON.parse(stdout).slice(0, -2) // remove last array item `"_design/lists"`
await addListingsOrdinals(allListingsIds)
process.exit(0)
}

function areSomeWithoutOrdinal (elements) {
return elements.find(el => (typeof (el.ordinal) === 'undefined'))
}

async function updateOrdinals (elements) {
const orderedUris = elements.map(property('uri'))
return reorderElements(orderedUris, elements)
}

const addListingOrdinals = async listingId => {
const elements = await getElementsByListings([ listingId ])
if (isNonEmptyArray(elements)) {
if (areSomeWithoutOrdinal(elements)) {
console.log('##### 33 assign_elements_ordinal_to_all_listings.ts elements', elements)
// Just making sure
const sortedElements = sortBy(elements, 'created')
console.log('updating listing:', listingId)
return updateOrdinals(sortedElements)
}
}
}

async function addListingsOrdinals (listingsIds) {
const remainingListingsIds = listingsIds.slice(0) // clone
const nextBatch = async () => {
// One by one since, updating all elements from a listing may be heavy
const batchListingsIds = remainingListingsIds.splice(0, 1)
if (batchListingsIds.length === 0) return
await Promise.all(compact(batchListingsIds.map(addListingOrdinals)))
// Give couchdb some rest
// await wait(1000)
return nextBatch()
}
await nextBatch()
}

// Do not reuse reorderElements from controllers/listings/lib/elements.ts to be able to also update elements without ordinal. See reorderAndUpdateDocs below
const db = await dbFactory('elements')

export async function reorderElements (uris, currentElements) {
const docsToUpdate = reorderAndUpdateDocs({
updateDocFn: updateElementDoc,
newOrderedKeys: uris,
currentDocs: currentElements,
attributeToSortBy: 'uri',
indexKey: 'ordinal',
})
if (docsToUpdate.length > 0) {
await db.bulk(docsToUpdate)
}
}

function reorderAndUpdateDocs ({ updateDocFn, newOrderedKeys, currentDocs, attributeToSortBy, indexKey }) {
const docsToUpdate = []
for (let i = 0; i < newOrderedKeys.length; i++) {
const currentDoc = currentDocs.find(el => el[attributeToSortBy] === newOrderedKeys[i])
// Next line is different than in reorderAndUpdateDocs
// to be able to assign new `ordinal` key
if (currentDoc[indexKey] === undefined || currentDoc[indexKey] !== i) {
const newAttributes = {}
newAttributes[indexKey] = i
docsToUpdate.push(updateDocFn(newAttributes, currentDoc))
}
}
return docsToUpdate
}

assignElementsOrdinalToAllListings()
26 changes: 26 additions & 0 deletions server/controllers/listings/lib/elements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,35 @@ export async function bulkUpdateElements ({ oldElements, attribute, value }) {

const elementsBulkUpdate = db.bulk

export async function reorderElements (uris, currentElements) {
const docsToUpdate = reorderAndUpdateDocs({
updateDocFn: updateElementDoc,
newOrderedKeys: uris,
currentDocs: currentElements,
attributeToSortBy: 'uri',
indexKey: 'ordinal',
})
if (docsToUpdate.length > 0) {
await elementsBulkUpdate(docsToUpdate)
}
}

function highestOrdinal (elements: ListingElement[]) {
if (elements.length === 0) return -1

const highestOrdinalElement = maxBy(elements, 'ordinal')
return highestOrdinalElement.ordinal
}

function reorderAndUpdateDocs ({ updateDocFn, newOrderedKeys, currentDocs, attributeToSortBy, indexKey }) {
const docsToUpdate = []
for (let i = 0; i < newOrderedKeys.length; i++) {
const currentDoc = currentDocs.find(el => el[attributeToSortBy] === newOrderedKeys[i])
if (currentDoc[indexKey] !== i) {
const newAttributes = {}
newAttributes[indexKey] = i
docsToUpdate.push(updateDocFn(newAttributes, currentDoc))
}
}
return docsToUpdate
}
24 changes: 18 additions & 6 deletions server/controllers/listings/lib/listings.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { groupBy, map, pick } from 'lodash-es'
import { groupBy, map, pick, difference } from 'lodash-es'
import { getEntitiesByUris } from '#controllers/entities/lib/get_entities_by_uris'
import { getElementsByListings, createListingElements, deleteListingsElements } from '#controllers/listings/lib/elements'
import { filterFoundElementsUris } from '#controllers/listings/lib/helpers'
Expand Down Expand Up @@ -28,7 +28,6 @@ export async function getListingsByIdsWithElements (ids: ListingId[]) {
if (!isNonEmptyArray(listings)) return []
const listingIds = map(listings, '_id')
const elements = await getElementsByListings(listingIds)
if (!isNonEmptyArray(listings)) return []
const elementsByListing: ElementsByListing = groupBy(elements, 'list')
listings.forEach(assignElementsToListing(elementsByListing))
return listings as ListingWithElements[]
Expand All @@ -54,14 +53,15 @@ export async function updateListingAttributes (params) {
export const bulkDeleteListings = db.bulkDelete

export async function addListingElements ({ listing, uris, userId }: { listing: ListingWithElements, uris: EntityUri, userId: UserId }) {
const currentElements = listing.elements
const currentElements = listing.elements || []
const { foundElements, notFoundUris } = filterFoundElementsUris(currentElements, uris)
await validateExistingEntities(notFoundUris)
const { docs: createdElements } = await createListingElements({ uris: notFoundUris, listing, userId })
const res = { ok: true, createdElements }
if (isNonEmptyArray(foundElements)) {
return { ok: true, alreadyInList: foundElements, createdElements }
return Object.assign(res, { alreadyInList: foundElements })
}
return { ok: true, createdElements }
return res
}

export function validateListingsOwnership (userId: UserId, listings: Listing[]) {
Expand All @@ -72,6 +72,17 @@ export function validateListingsOwnership (userId: UserId, listings: Listing[])
}
}

export const validateElementsUrisInListing = (uris, listingElements) => {
if (listingElements.length === 0) return true
const listingElementsUris = map(listingElements, 'uri')
// truncate array to allow more performant validation on elements subset
listingElementsUris.length = uris.length
const urisNotInListing = difference(listingElementsUris, uris)
if (urisNotInListing.length > 0) {
throw newError('some elements are not in the list', 400, { uris: urisNotInListing })
}
}

export async function getListingWithElements (listingId: ListingId) {
const listings = await getListingsByIdsWithElements([ listingId ])
return listings[0]
Expand All @@ -86,7 +97,8 @@ export async function deleteUserListingsAndElements (userId: UserId) {
}

const assignElementsToListing = (elementsByListing: ElementsByListing) => listing => {
listing.elements = elementsByListing[listing._id] || []
const listingElements = elementsByListing[listing._id] || []
listing.elements = listingElements.sort((a, b) => a.ordinal - b.ordinal)
}

async function validateExistingEntities (uris: EntityUri[]) {
Expand Down
2 changes: 2 additions & 0 deletions server/controllers/listings/listings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import byIds from './by_ids.js'
import create from './create.js'
import deleteByIds from './delete_by_ids.js'
import removeElements from './remove_elements.js'
import reorder from './reorder.js'
import update from './update.js'

export default {
Expand All @@ -24,6 +25,7 @@ export default {
'add-elements': addElements,
'remove-elements': removeElements,
delete: deleteByIds,
reorder,
},
}),
put: ActionsControllers({
Expand Down
30 changes: 30 additions & 0 deletions server/controllers/listings/reorder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { reorderElements } from '#controllers/listings/lib/elements'
import { getListingWithElements, validateListingsOwnership, validateElementsUrisInListing } from '#controllers/listings/lib/listings'
import { newError } from '#lib/error/error'

const sanitization = {
id: {},
uris: {},
}

const controller = async ({ id, uris, reqUserId }) => {
const listing = await getListingWithElements(id)
validateListingsOwnership(reqUserId, [ listing ])
const elements = listing.elements
if (!elements || elements.length === 0) {
throw newError('no elements to reorder', 400, { list: listing })
}
validateElementsUrisInListing(uris, elements)
await reorderElements(uris, elements)
const resListing = await getListingWithElements(id)
return {
ok: true,
list: resListing,
}
}

export default {
sanitization,
controller,
track: [ 'lists', 'reorder' ],
}
2 changes: 2 additions & 0 deletions server/models/element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ const attributes = {
validAtCreation: [
'list',
'uri',
'ordinal',
],
updatable: [
'ordinal',
'uri',
],
}
Expand Down
11 changes: 11 additions & 0 deletions tests/api/fixtures/listings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,14 @@ export const createElement = async ({ visibility = [ 'public' ], uri, listing },
uri,
}
}

export const removeElement = async ({ uri, listing }, userPromise) => {
userPromise = userPromise || getUser()
const user = await userPromise
const removeElements = '/api/lists?action=remove-elements'

return customAuthReq(user, 'post', removeElements, {
id: listing._id,
uris: [ uri ],
})
}
84 changes: 83 additions & 1 deletion tests/api/listings/ordinal.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,91 @@
import { createListingWithElements } from '../fixtures/listings.js'
import { map } from 'lodash-es'
import { getByIdWithElements } from '#tests/api/utils/listings'
import { shouldNotBeCalled, rethrowShouldNotBeCalledErrors } from '#tests/unit/utils'
import { createListing, createListingWithElements, createElement, removeElement } from '../fixtures/listings.js'
import { authReq } from '../utils/utils.js'

const endpoint = '/api/lists?action='
const reorder = `${endpoint}reorder`

describe('element:listing-ordinal', () => {
it('should create elements with a listingId', async () => {
const { listing } = await createListingWithElements()
listing.elements[0].ordinal.should.equal(0)
listing.elements[1].ordinal.should.equal(1)
})

it('should reject without elements to reorder', async () => {
const { listing } = await createListingWithElements()
try {
await authReq('post', reorder, { id: listing._id })
.then(shouldNotBeCalled)
} catch (err) {
rethrowShouldNotBeCalledErrors(err)
err.body.status_verbose.should.equal('missing parameter in body: uris')
err.statusCode.should.equal(400)
}
})

it('should reject listing without elements', async () => {
const { listing } = await createListing()
const { element } = await createElement({})
try {
await authReq('post', reorder, {
id: listing._id,
uris: [ element.uri ],
})
.then(shouldNotBeCalled)
} catch (err) {
rethrowShouldNotBeCalledErrors(err)
err.body.status_verbose.should.equal('no elements to reorder')
err.statusCode.should.equal(400)
}
})

it('should reject with elements not belonging to a listing', async () => {
const { listing } = await createListingWithElements()
const { element } = await createElement({})
try {
await authReq('post', reorder, {
id: listing._id,
uris: [ element.uri ],
})
.then(shouldNotBeCalled)
} catch (err) {
rethrowShouldNotBeCalledErrors(err)
err.body.status_verbose.should.equal('some elements are not in the list')
err.statusCode.should.equal(400)
}
})

it('should reorder elements', async () => {
const { listing } = await createListingWithElements()
const [ uri1, uri2 ] = map(listing.elements, 'uri')
await authReq('post', reorder, {
id: listing._id,
uris: [ uri2, uri1 ],
})
const resListing = await getByIdWithElements({ id: listing._id })
resListing.elements[0].uri.should.equal(uri2)
resListing.elements[1].ordinal.should.equal(1)
})

it('should assign new ordinal if one element has been removed', async () => {
const { listing, uris } = await createListingWithElements()
const [ uri1, uri2 ] = uris
const { uri: uri3 } = await createElement({ listing })
await removeElement({ uri: uri2, listing })

const resListing = await getByIdWithElements({ id: listing._id })
resListing.elements[1].uri.should.equal(uri3)
resListing.elements[1].ordinal.should.equal(2)

await authReq('post', reorder, {
id: listing._id,
uris: [ uri1, uri3 ],
})
const resListing2 = await getByIdWithElements({ id: listing._id })
resListing2.elements[1].uri.should.equal(uri3)
resListing2.elements[1].ordinal.should.equal(1)
})
})

0 comments on commit 40a8d56

Please sign in to comment.