From ee623e8b8ac95ad3f324d3490a8ad1539e80f3cc Mon Sep 17 00:00:00 2001 From: Gregor Becker Date: Sun, 19 Jul 2026 23:35:06 +0200 Subject: [PATCH] feat(pinia-orm): support Intl.Collator for locale aware sorting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit orderBy() (query & repository), useSortBy and the orderBy util now accept an Intl.Collator besides the existing sort flags. String values are then compared with the collator, enabling locale aware ordering (e.g. Lithuanian A, B, Š, T, U) and options like numeric sorting. Different flags can be given per order clause. closes #2006 --- .../1.composables/2.helpers/use-sort-by.md | 6 +++- docs/content/2.api/3.query/order-by.md | 13 ++++++++- .../src/composables/collection/useCollect.ts | 6 ++-- .../src/composables/collection/useSortBy.ts | 4 +-- packages/pinia-orm/src/query/Options.ts | 2 ++ packages/pinia-orm/src/query/Query.ts | 8 ++++-- .../pinia-orm/src/repository/Repository.ts | 5 ++-- packages/pinia-orm/src/support/Utils.ts | 15 +++++++--- .../repository/retrieves_order_by.spec.ts | 28 +++++++++++++++++++ .../tests/unit/support/Utils_Order_By.spec.ts | 26 +++++++++++++++++ 10 files changed, 97 insertions(+), 16 deletions(-) diff --git a/docs/content/2.api/1.composables/2.helpers/use-sort-by.md b/docs/content/2.api/1.composables/2.helpers/use-sort-by.md index 77fa467b3..af3c12907 100644 --- a/docs/content/2.api/1.composables/2.helpers/use-sort-by.md +++ b/docs/content/2.api/1.composables/2.helpers/use-sort-by.md @@ -20,6 +20,9 @@ useSortBy(users, 'name') // sort by the `name` attribute case insensitive useSortBy(users, 'name', 'SORT_FLAG_CASE') +// sort by the `name` attribute locale aware +useSortBy(users, 'name', new Intl.Collator('lt')) + // sorts the collection by 'name' descending and then by 'lastname' ascending useSortBy(users, [ ['name', 'desc'], @@ -36,6 +39,7 @@ useSortBy(users, (model) => model.age) ````ts export type sorting = ((record: T) => any) | string | [string, 'asc' | 'desc'][] export type SortFlags = 'SORT_REGULAR' | 'SORT_FLAG_CASE' +export type SortComparator = SortFlags | Intl.Collator -export function useSortBy(collection: T[], sort: sorting, flags?: SortFlags): T[] +export function useSortBy(collection: T[], sort: sorting, flags?: SortComparator): T[] ```` diff --git a/docs/content/2.api/3.query/order-by.md b/docs/content/2.api/3.query/order-by.md index cb39a66b4..8caa937ac 100644 --- a/docs/content/2.api/3.query/order-by.md +++ b/docs/content/2.api/3.query/order-by.md @@ -24,10 +24,21 @@ useRepo(User) // Sort user name by its third character. useRepo(User).orderBy(user => user.name[2]).get() + +// Sort user names case insensitive. +useRepo(User).orderBy('name', 'asc', 'SORT_FLAG_CASE').get() + +// Sort user names locale aware with an Intl.Collator, +// e.g. for Lithuanian: A, B, Š, T, U instead of A, B, T, U, Š. +useRepo(User).orderBy('name', 'asc', new Intl.Collator('lt')).get() ```` +The optional third argument accepts either one of the sort flags (`'SORT_REGULAR'`, `'SORT_FLAG_CASE'`) or any [`Intl.Collator`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Collator) instance. A collator is used to compare string values, which enables locale aware sorting as well as options like numeric ordering (`new Intl.Collator(undefined, { numeric: true })`). + ## Typescript Declarations ````ts -function orderBy(field: OrderBy, direction: OrderDirection = 'asc'): Query +function orderBy(field: OrderBy, direction: OrderDirection = 'asc', flags: SortComparator = 'SORT_REGULAR'): Query + +type SortComparator = 'SORT_REGULAR' | 'SORT_FLAG_CASE' | Intl.Collator ```` diff --git a/packages/pinia-orm/src/composables/collection/useCollect.ts b/packages/pinia-orm/src/composables/collection/useCollect.ts index 2217a5c52..bb8b447db 100644 --- a/packages/pinia-orm/src/composables/collection/useCollect.ts +++ b/packages/pinia-orm/src/composables/collection/useCollect.ts @@ -1,5 +1,5 @@ import type { Collection, Model } from '../../../src' -import type { SortFlags } from '../../support/Utils' +import type { SortComparator } from '../../support/Utils' import { useSum } from './useSum' import { useMax } from './useMax' import { useMin } from './useMin' @@ -15,7 +15,7 @@ export interface UseCollect { max: (field: string) => number pluck: (field: string) => any[] groupBy: (fields: string[] | string) => Record> - sortBy: (sort: sorting, flags?: SortFlags) => M[] + sortBy: (sort: sorting, flags?: SortComparator) => M[] keys: () => string[] } @@ -29,7 +29,7 @@ export function useCollect (models: Collection): Use max: field => useMax(models, field), pluck: field => usePluck(models, field), groupBy: fields => useGroupBy(models, fields), - sortBy: (sort, flags: SortFlags = 'SORT_REGULAR') => useSortBy(models, sort, flags), + sortBy: (sort, flags: SortComparator = 'SORT_REGULAR') => useSortBy(models, sort, flags), keys: () => useKeys(models), } } diff --git a/packages/pinia-orm/src/composables/collection/useSortBy.ts b/packages/pinia-orm/src/composables/collection/useSortBy.ts index e547498ae..a8d3b57ec 100644 --- a/packages/pinia-orm/src/composables/collection/useSortBy.ts +++ b/packages/pinia-orm/src/composables/collection/useSortBy.ts @@ -1,4 +1,4 @@ -import type { SortFlags } from '../../support/Utils' +import type { SortComparator } from '../../support/Utils' import { orderBy } from '../../support/Utils' export type sorting = ((record: T) => any) | string | [string, 'asc' | 'desc'][] @@ -7,7 +7,7 @@ export type sorting = ((record: T) => any) | string | [string, 'asc' | 'desc' * Creates an array of elements, sorted in specified order by the results * of running each element in a collection thru each iteratee. */ -export function useSortBy> (collection: T[], sort: sorting, flags?: SortFlags): T[] { +export function useSortBy> (collection: T[], sort: sorting, flags?: SortComparator): T[] { const directions = [] const iteratees = [] diff --git a/packages/pinia-orm/src/query/Options.ts b/packages/pinia-orm/src/query/Options.ts index 0b9932c19..f0805ad41 100644 --- a/packages/pinia-orm/src/query/Options.ts +++ b/packages/pinia-orm/src/query/Options.ts @@ -1,4 +1,5 @@ import type { Model, WithKeys } from '../model/Model' +import type { SortComparator } from '../support/Utils' import type { Query } from './Query' export interface Where { @@ -23,6 +24,7 @@ export interface WhereGroup { export interface Order { field: OrderBy direction: OrderDirection + flags?: SortComparator } export interface Group { diff --git a/packages/pinia-orm/src/query/Query.ts b/packages/pinia-orm/src/query/Query.ts index 1784529ea..8b68cf3f0 100644 --- a/packages/pinia-orm/src/query/Query.ts +++ b/packages/pinia-orm/src/query/Query.ts @@ -9,6 +9,7 @@ import { isFunction, orderBy, } from '../support/Utils' +import type { SortComparator } from '../support/Utils' import type { Collection, Element, Elements, GroupedCollection, Item, NormalizedData } from '../data/Data' import type { Database } from '../database/Database' import { Relation } from '../model/attributes/relations/Relation' @@ -359,8 +360,8 @@ export class Query { /** * Add an "order by" clause to the query. */ - orderBy (field: OrderBy, direction: OrderDirection = 'asc'): this { - this.orders.push({ field, direction }) + orderBy (field: OrderBy, direction: OrderDirection = 'asc', flags: SortComparator = 'SORT_REGULAR'): this { + this.orders.push({ field, direction, flags }) return this } @@ -625,8 +626,9 @@ export class Query { protected filterOrder (models: Collection): Collection { const fields = this.orders.map(order => order.field) const directions = this.orders.map(order => order.direction) + const flags = this.orders.map(order => order.flags ?? 'SORT_REGULAR') - return orderBy(models, fields, directions) + return orderBy(models, fields, directions, flags) } /** diff --git a/packages/pinia-orm/src/repository/Repository.ts b/packages/pinia-orm/src/repository/Repository.ts index 8d4bd7617..dadd589ed 100644 --- a/packages/pinia-orm/src/repository/Repository.ts +++ b/packages/pinia-orm/src/repository/Repository.ts @@ -1,6 +1,7 @@ import type { Pinia } from 'pinia' import type { Constructor } from '../types' import { assert, isArray } from '../support/Utils' +import type { SortComparator } from '../support/Utils' import type { Collection, Element, Item } from '../data/Data' import type { Database } from '../database/Database' import type { Model, WithKeys } from '../model/Model' @@ -332,8 +333,8 @@ export class Repository { /** * Add an "order by" clause to the query. */ - orderBy (field: OrderBy, direction?: OrderDirection): Query { - return this.query().orderBy(field, direction) + orderBy (field: OrderBy, direction?: OrderDirection, flags?: SortComparator): Query { + return this.query().orderBy(field, direction, flags) } /** diff --git a/packages/pinia-orm/src/support/Utils.ts b/packages/pinia-orm/src/support/Utils.ts index dd95a5167..f0c16f52a 100644 --- a/packages/pinia-orm/src/support/Utils.ts +++ b/packages/pinia-orm/src/support/Utils.ts @@ -8,6 +8,8 @@ interface SortableArray { export type SortFlags = 'SORT_REGULAR' | 'SORT_FLAG_CASE' +export type SortComparator = SortFlags | Intl.Collator + /** * Compare two values with custom string operator */ @@ -77,7 +79,7 @@ export function orderBy ( collection: T[], iteratees: (((record: T) => any) | string)[], directions: string[], - flags: SortFlags = 'SORT_REGULAR', + flags: SortComparator | SortComparator[] = 'SORT_REGULAR', ): T[] { let index = -1 @@ -130,7 +132,7 @@ function compareMultiple ( object: SortableArray, other: SortableArray, directions: string[], - flags: SortFlags, + flags: SortComparator | SortComparator[], ): number { let index = -1 @@ -139,7 +141,7 @@ function compareMultiple ( const length = objCriteria.length while (++index < length) { - const result = compareAscending(objCriteria[index], othCriteria[index], flags) + const result = compareAscending(objCriteria[index], othCriteria[index], isArray(flags) ? flags[index] ?? 'SORT_REGULAR' : flags) if (result) { const direction = directions[index] @@ -153,8 +155,13 @@ function compareMultiple ( /** * Compares values to sort them in ascending order. */ -function compareAscending (value: any, other: any, flags: SortFlags): number { +function compareAscending (value: any, other: any, flags: SortComparator): number { if (value !== other) { + if (typeof flags === 'object' && typeof value === 'string' && typeof other === 'string') { + const result = flags.compare(value, other) + return result > 0 ? 1 : result < 0 ? -1 : 0 + } + const valIsDefined = value !== undefined const valIsNull = value === null const valIsReflexive = value === value diff --git a/packages/pinia-orm/tests/feature/repository/retrieves_order_by.spec.ts b/packages/pinia-orm/tests/feature/repository/retrieves_order_by.spec.ts index 12922fcc9..656063f2c 100644 --- a/packages/pinia-orm/tests/feature/repository/retrieves_order_by.spec.ts +++ b/packages/pinia-orm/tests/feature/repository/retrieves_order_by.spec.ts @@ -115,6 +115,34 @@ describe('feature/repository/retrieves_order_by', () => { assertModels(users, expected) }) + it('can sort records locale aware with a collator', () => { + const userRepo = useRepo(User) + + fillState({ + users: { + 1: { id: 1, name: 'T', age: 40 }, + 2: { id: 2, name: 'A', age: 30 }, + 3: { id: 3, name: 'Š', age: 20 }, + 4: { id: 4, name: 'U', age: 20 }, + 5: { id: 5, name: 'B', age: 50 }, + }, + }) + + const users = userRepo.orderBy('name', 'asc', new Intl.Collator('lt')).get() + + const expected = [ + { id: 2, name: 'A', age: 30 }, + { id: 5, name: 'B', age: 50 }, + { id: 3, name: 'Š', age: 20 }, + { id: 1, name: 'T', age: 40 }, + { id: 4, name: 'U', age: 20 }, + ] + + expect(users).toHaveLength(5) + assertInstanceOf(users, User) + assertModels(users, expected) + }) + it('can sort nested records by pivot', () => { Model.clearRegistries() class User extends Model { diff --git a/packages/pinia-orm/tests/unit/support/Utils_Order_By.spec.ts b/packages/pinia-orm/tests/unit/support/Utils_Order_By.spec.ts index 5cdad1d24..928fa793b 100644 --- a/packages/pinia-orm/tests/unit/support/Utils_Order_By.spec.ts +++ b/packages/pinia-orm/tests/unit/support/Utils_Order_By.spec.ts @@ -98,4 +98,30 @@ describe('unit/support/Utils_Order_by', () => { expect(orderBy(collection, [v => v.id], ['asc'])).toEqual(expected) }) + + it('can order collection with a collator', () => { + const collection = [{ name: 'T' }, { name: 'Š' }, { name: 'A' }] + + const expected = [{ name: 'A' }, { name: 'Š' }, { name: 'T' }] + + expect(orderBy(collection, ['name'], ['asc'], new Intl.Collator('lt'))).toEqual(expected) + }) + + it('can order collection with different flags per field', () => { + const collection = [ + { name: 'T', group: 'b' }, + { name: 'Š', group: 'B' }, + { name: 'A', group: 'B' }, + { name: 'B', group: 'a' }, + ] + + const expected = [ + { name: 'B', group: 'a' }, + { name: 'A', group: 'B' }, + { name: 'Š', group: 'B' }, + { name: 'T', group: 'b' }, + ] + + expect(orderBy(collection, ['group', 'name'], ['asc', 'asc'], ['SORT_FLAG_CASE', new Intl.Collator('lt')])).toEqual(expected) + }) })