Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions lib/query/dcql.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Copyright (c) 2025-2026 Digital Bazaar, Inc. All rights reserved.
*/
import {
fromJsonPointerMap, isNumber, toJsonPointerMap, toNumberIfNumber
fromJsonPointerMap, isNumber, toIntegerIfInteger, toJsonPointerMap
} from './util.js';
import {exampleToJsonPointerMap} from './queryByExample.js';
import jsonpointer from 'json-pointer';
Expand Down Expand Up @@ -147,7 +147,7 @@ export function _fromQueryByExampleQuery({
const pathsMap = new Map();
for(const [pointer, value] of pointers) {
// parse path into DCQL path w/ numbers for array indexes
let path = jsonpointer.parse(pointer).map(toNumberIfNumber);
let path = jsonpointer.parse(pointer).map(toIntegerIfInteger);

// special process non-`@context` paths to convert some array indexes
// to DCQL `null` (which means "any" index)
Expand Down
8 changes: 4 additions & 4 deletions lib/query/match.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/*!
* Copyright (c) 2025 Digital Bazaar, Inc. All rights reserved.
* Copyright (c) 2025-2026 Digital Bazaar, Inc. All rights reserved.
*/
import {isObject, resolvePointer, toNumberIfNumber} from './util.js';
import {isObject, resolvePointer, toIntegerIfInteger} from './util.js';

/**
* Returns whether a credential matches against a JSON pointer map.
Expand Down Expand Up @@ -66,8 +66,8 @@ function _match({cursor, matchValue, options}) {

// string/number coercion
if(options.coerceNumbers) {
const cursorNumber = toNumberIfNumber(cursor);
const matchNumber = toNumberIfNumber(matchValue);
const cursorNumber = toIntegerIfInteger(cursor);
const matchNumber = toIntegerIfInteger(matchValue);
return cursorNumber !== undefined && cursorNumber === matchNumber;
}

Expand Down
15 changes: 6 additions & 9 deletions lib/query/util.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*!
* Copyright (c) 2022-2025 Digital Bazaar, Inc. All rights reserved.
* Copyright (c) 2022-2026 Digital Bazaar, Inc. All rights reserved.
*/
import {assert} from '../util.js';
import jsonpointer from 'json-pointer';
Expand All @@ -10,7 +10,7 @@ export function fromJsonPointerMap({map} = {}) {
}

export function isNumber(x) {
return typeof toNumberIfNumber(x) === 'number';
return typeof toIntegerIfInteger(x) === 'number';
}

export function isObject(x) {
Expand All @@ -37,13 +37,10 @@ export function toJsonPointerMap({obj, flat = false} = {}) {
return _toPointers({cursor: obj, map: new Map(), flat});
}

export function toNumberIfNumber(x) {
if(typeof x === 'number') {
return x;
}
const num = parseInt(x, 10);
if(!isNaN(num)) {
return num;
export function toIntegerIfInteger(x) {
if(typeof x === 'string') {
const i = parseInt(x, 10);
return i.toString() === x ? i : x;
}
return x;
}
Expand Down
22 changes: 22 additions & 0 deletions tests/unit/mockCredentials.js
Original file line number Diff line number Diff line change
Expand Up @@ -269,5 +269,27 @@ export const edgeCaseCredentials = [
}]
]
}
},

// Credential with numeric-like strings (integer-coercion regression)
{
'@context': [
'https://www.w3.org/ns/credentials/v2',
'https://www.w3.org/ns/credentials/examples/v2'
],
type: ['VerifiableCredential', 'ScoreCredential'],
credentialSubject: {
id: 'did:example:integer-coercion',
name: 'Integer Coercion Person',
// decimal string: must NOT be truncated to an integer
score: '3.14',
// partially-numeric string: trailing chars must NOT be ignored
value: '21abc',
// valid integer string: must STILL coerce
count: '21'
},
issuer: {
id: 'did:example:issuer'
}
}
];
101 changes: 101 additions & 0 deletions tests/unit/query.match.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,107 @@ describe('query.match', () => {
// should match the credential with yearOfBirth: 1998 (number)
expect(matches).to.have.length(1);
});

it('should NOT coerce a decimal string to an integer', function() {
const queryByExample = {
example: {
credentialSubject: {
// integer query must not match the string '3.14'
score: 3
}
}
};

const matches = _matchCredentials({
credentials: edgeCaseCredentials,
queryByExample
});

// '3.14' must not be truncated to 3
expect(matches).to.have.length(0);
});

it('should NOT coerce a partially-numeric string to an integer',
function() {
const queryByExample = {
example: {
credentialSubject: {
// integer query must not match the string '21abc'
value: 21
}
}
};

const matches = _matchCredentials({
credentials: edgeCaseCredentials,
queryByExample
});

// '21abc' must not have its trailing characters ignored
expect(matches).to.have.length(0);
});

it('should still coerce a valid integer', function() {
const queryByExample = {
example: {
credentialSubject: {
// integer query must still match the string '21'
count: 21
}
}
};

const matches = _matchCredentials({
credentials: edgeCaseCredentials,
queryByExample
});

// valid integer strings continue to coerce
expect(matches).to.have.length(1);
expect(matches[0].credentialSubject.name)
.to.equal('Integer Coercion Person');
});

// NOTE: decimal strings are deliberately left uncoerced ("integer only"
// coercion); this test documents and locks in that behavior so a future
// switch to numeric coercion (e.g., `Number()`) is caught.
it('should NOT coerce decimals, only match decimal strings exactly',
function() {
// a decimal written as a NUMBER must not match the string '3.14'
const numberQuery = {
example: {
credentialSubject: {
score: 3.14
}
}
};

const numberMatches = _matchCredentials({
credentials: edgeCaseCredentials,
queryByExample: numberQuery
});

// '3.14' (string) is not coerced, so it does not equal 3.14 (number)
expect(numberMatches).to.have.length(0);

// a decimal written as a STRING matches the identical string directly
const stringQuery = {
example: {
credentialSubject: {
score: '3.14'
}
}
};

const stringMatches = _matchCredentials({
credentials: edgeCaseCredentials,
queryByExample: stringQuery
});

expect(stringMatches).to.have.length(1);
expect(stringMatches[0].credentialSubject.name)
.to.equal('Integer Coercion Person');
});
});

describe('Real-world Scenarios', function() {
Expand Down
Loading