Skip to content

Commit a2950e0

Browse files
authored
FIX: sort strings in UTF-8 encoded byte order (#2275)
1 parent 1e714a8 commit a2950e0

File tree

3 files changed

+170
-14
lines changed

3 files changed

+170
-14
lines changed

dev/src/order.ts

+17-2
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ function compareObjects(left: ApiMapValue, right: ApiMapValue): number {
214214
leftKeys.sort();
215215
rightKeys.sort();
216216
for (let i = 0; i < leftKeys.length && i < rightKeys.length; i++) {
217-
const keyComparison = primitiveComparator(leftKeys[i], rightKeys[i]);
217+
const keyComparison = compareUtf8Strings(leftKeys[i], rightKeys[i]);
218218
if (keyComparison !== 0) {
219219
return keyComparison;
220220
}
@@ -248,6 +248,21 @@ function compareVectors(left: ApiMapValue, right: ApiMapValue): number {
248248
return compareArrays(leftArray, rightArray);
249249
}
250250

251+
function stringToUtf8Bytes(str: string): Uint8Array {
252+
return new TextEncoder().encode(str);
253+
}
254+
255+
/*!
256+
* Compare strings in UTF-8 encoded byte order
257+
* @private
258+
* @internal
259+
*/
260+
export function compareUtf8Strings(left: string, right: string): number {
261+
const leftBytes = stringToUtf8Bytes(left);
262+
const rightBytes = stringToUtf8Bytes(right);
263+
return compareBlobs(Buffer.from(leftBytes), Buffer.from(rightBytes));
264+
}
265+
251266
/*!
252267
* @private
253268
* @internal
@@ -269,7 +284,7 @@ export function compare(left: api.IValue, right: api.IValue): number {
269284
case TypeOrder.BOOLEAN:
270285
return primitiveComparator(left.booleanValue!, right.booleanValue!);
271286
case TypeOrder.STRING:
272-
return primitiveComparator(left.stringValue!, right.stringValue!);
287+
return compareUtf8Strings(left.stringValue!, right.stringValue!);
273288
case TypeOrder.NUMBER:
274289
return compareNumberProtos(left, right);
275290
case TypeOrder.TIMESTAMP:

dev/src/path.ts

+3-12
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import * as firestore from '@google-cloud/firestore';
1818

1919
import {google} from '../protos/firestore_v1_proto_api';
20+
import {compareUtf8Strings, primitiveComparator} from './order';
2021

2122
import {isObject} from './util';
2223
import {
@@ -170,7 +171,7 @@ abstract class Path<T> {
170171
return comparison;
171172
}
172173
}
173-
return Math.sign(this.segments.length - other.segments.length);
174+
return primitiveComparator(this.segments.length, other.segments.length);
174175
}
175176

176177
private compareSegments(lhs: string, rhs: string): number {
@@ -191,7 +192,7 @@ abstract class Path<T> {
191192
);
192193
} else {
193194
// both non-numeric
194-
return this.compareStrings(lhs, rhs);
195+
return compareUtf8Strings(lhs, rhs);
195196
}
196197
}
197198

@@ -215,16 +216,6 @@ abstract class Path<T> {
215216
}
216217
}
217218

218-
private compareStrings(lhs: string, rhs: string): number {
219-
if (lhs < rhs) {
220-
return -1;
221-
} else if (lhs > rhs) {
222-
return 1;
223-
} else {
224-
return 0;
225-
}
226-
}
227-
228219
/**
229220
* Returns a copy of the underlying segments.
230221
*

dev/system-test/firestore.ts

+150
Original file line numberDiff line numberDiff line change
@@ -4084,6 +4084,156 @@ describe('Query class', () => {
40844084
docChanges: expectedChanges,
40854085
});
40864086
});
4087+
4088+
describe('sort unicode strings', () => {
4089+
it('snapshot listener sorts unicode strings same as server', async () => {
4090+
const collection = await testCollectionWithDocs({
4091+
a: {value: 'Łukasiewicz'},
4092+
b: {value: 'Sierpiński'},
4093+
c: {value: '岩澤'},
4094+
d: {value: '🄟'},
4095+
e: {value: 'P'},
4096+
f: {value: '︒'},
4097+
g: {value: '🐵'},
4098+
});
4099+
4100+
const query = collection.orderBy('value');
4101+
const expectedDocs = ['b', 'a', 'c', 'f', 'e', 'd', 'g'];
4102+
4103+
const getSnapshot = await query.get();
4104+
expect(getSnapshot.docs.map(d => d.id)).to.deep.equal(expectedDocs);
4105+
4106+
const unsubscribe = query.onSnapshot(snapshot =>
4107+
currentDeferred.resolve(snapshot)
4108+
);
4109+
const watchSnapshot = await waitForSnapshot();
4110+
snapshotsEqual(watchSnapshot, {
4111+
docs: getSnapshot.docs,
4112+
docChanges: getSnapshot.docChanges(),
4113+
});
4114+
unsubscribe();
4115+
});
4116+
4117+
it('snapshot listener sorts unicode strings in array same as server', async () => {
4118+
const collection = await testCollectionWithDocs({
4119+
a: {value: ['Łukasiewicz']},
4120+
b: {value: ['Sierpiński']},
4121+
c: {value: ['岩澤']},
4122+
d: {value: ['🄟']},
4123+
e: {value: ['P']},
4124+
f: {value: ['︒']},
4125+
g: {value: ['🐵']},
4126+
});
4127+
4128+
const query = collection.orderBy('value');
4129+
const expectedDocs = ['b', 'a', 'c', 'f', 'e', 'd', 'g'];
4130+
4131+
const getSnapshot = await query.get();
4132+
expect(getSnapshot.docs.map(d => d.id)).to.deep.equal(expectedDocs);
4133+
4134+
const unsubscribe = query.onSnapshot(snapshot =>
4135+
currentDeferred.resolve(snapshot)
4136+
);
4137+
const watchSnapshot = await waitForSnapshot();
4138+
snapshotsEqual(watchSnapshot, {
4139+
docs: getSnapshot.docs,
4140+
docChanges: getSnapshot.docChanges(),
4141+
});
4142+
unsubscribe();
4143+
});
4144+
4145+
it('snapshot listener sorts unicode strings in map same as server', async () => {
4146+
const collection = await testCollectionWithDocs({
4147+
a: {value: {foo: 'Łukasiewicz'}},
4148+
b: {value: {foo: 'Sierpiński'}},
4149+
c: {value: {foo: '岩澤'}},
4150+
d: {value: {foo: '🄟'}},
4151+
e: {value: {foo: 'P'}},
4152+
f: {value: {foo: '︒'}},
4153+
g: {value: {foo: '🐵'}},
4154+
});
4155+
4156+
const query = collection.orderBy('value');
4157+
const expectedDocs = ['b', 'a', 'c', 'f', 'e', 'd', 'g'];
4158+
4159+
const getSnapshot = await query.get();
4160+
expect(getSnapshot.docs.map(d => d.id)).to.deep.equal(expectedDocs);
4161+
4162+
const unsubscribe = query.onSnapshot(snapshot =>
4163+
currentDeferred.resolve(snapshot)
4164+
);
4165+
const watchSnapshot = await waitForSnapshot();
4166+
snapshotsEqual(watchSnapshot, {
4167+
docs: getSnapshot.docs,
4168+
docChanges: getSnapshot.docChanges(),
4169+
});
4170+
unsubscribe();
4171+
});
4172+
4173+
it('snapshot listener sorts unicode strings in map key same as server', async () => {
4174+
const collection = await testCollectionWithDocs({
4175+
a: {value: {Łukasiewicz: true}},
4176+
b: {value: {Sierpiński: true}},
4177+
c: {value: {岩澤: true}},
4178+
d: {value: {'🄟': true}},
4179+
e: {value: {: true}},
4180+
f: {value: {'︒': true}},
4181+
g: {value: {'🐵': true}},
4182+
});
4183+
4184+
const query = collection.orderBy('value');
4185+
const expectedDocs = ['b', 'a', 'c', 'f', 'e', 'd', 'g'];
4186+
4187+
const getSnapshot = await query.get();
4188+
expect(getSnapshot.docs.map(d => d.id)).to.deep.equal(expectedDocs);
4189+
4190+
const unsubscribe = query.onSnapshot(snapshot =>
4191+
currentDeferred.resolve(snapshot)
4192+
);
4193+
const watchSnapshot = await waitForSnapshot();
4194+
snapshotsEqual(watchSnapshot, {
4195+
docs: getSnapshot.docs,
4196+
docChanges: getSnapshot.docChanges(),
4197+
});
4198+
unsubscribe();
4199+
});
4200+
4201+
it('snapshot listener sorts unicode strings in document key same as server', async () => {
4202+
const collection = await testCollectionWithDocs({
4203+
Łukasiewicz: {value: true},
4204+
Sierpiński: {value: true},
4205+
岩澤: {value: true},
4206+
'🄟': {value: true},
4207+
: {value: true},
4208+
'︒': {value: true},
4209+
'🐵': {value: true},
4210+
});
4211+
4212+
const query = collection.orderBy(FieldPath.documentId());
4213+
const expectedDocs = [
4214+
'Sierpiński',
4215+
'Łukasiewicz',
4216+
'岩澤',
4217+
'︒',
4218+
'P',
4219+
'🄟',
4220+
'🐵',
4221+
];
4222+
4223+
const getSnapshot = await query.get();
4224+
expect(getSnapshot.docs.map(d => d.id)).to.deep.equal(expectedDocs);
4225+
4226+
const unsubscribe = query.onSnapshot(snapshot =>
4227+
currentDeferred.resolve(snapshot)
4228+
);
4229+
const watchSnapshot = await waitForSnapshot();
4230+
snapshotsEqual(watchSnapshot, {
4231+
docs: getSnapshot.docs,
4232+
docChanges: getSnapshot.docChanges(),
4233+
});
4234+
unsubscribe();
4235+
});
4236+
});
40874237
});
40884238

40894239
(process.env.FIRESTORE_EMULATOR_HOST === undefined

0 commit comments

Comments
 (0)