Skip to content

Commit 76249bc

Browse files
committed
Add QueryByExample => DCQL generation code.
1 parent 2dfcfd4 commit 76249bc

File tree

3 files changed

+211
-9
lines changed

3 files changed

+211
-9
lines changed

lib/query/dcql.js

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,89 @@
11
/*!
22
* Copyright (c) 2025 Digital Bazaar, Inc. All rights reserved.
33
*/
4+
import {isNumber, toJsonPointerMap} from '../util.js';
5+
import jsonpointer from 'json-pointer';
6+
47
// exported for testing purposes only
5-
export function _fromQueryByExampleQuery() {
6-
// FIXME: implement
7-
return {};
8+
export function _fromQueryByExampleQuery({
9+
credentialQuery, nullyifyArrayIndices = false
10+
}) {
11+
const result = {
12+
id: crypto.randomUUID(),
13+
format: 'ldp_vc',
14+
meta: {
15+
type_values: ['https://www.w3.org/2018/credentials#VerifiableCredential']
16+
}
17+
};
18+
19+
const {example = {}} = credentialQuery || {};
20+
21+
// determine credential format
22+
if(Array.isArray(credentialQuery.acceptedEnvelopes)) {
23+
const set = new Set(credentialQuery.acceptedEnvelopes);
24+
if(set.has('application/jwt')) {
25+
result.format = 'jwt_vc_json';
26+
} else if(set.has('application/mdl')) {
27+
result.format = 'mso_mdoc';
28+
result.meta = {doctype_value: 'org.iso.18013.5.1.mDL'};
29+
} else if(set.has('application/dc+sd-jwt')) {
30+
result.format = 'dc+sd-jwt';
31+
result.meta = {vct_values: []};
32+
if(Array.isArray(example?.type)) {
33+
result.meta.vct_values.push(...example.type);
34+
} else if(typeof example.type === 'string') {
35+
result.meta.vct_values.push(example.type);
36+
}
37+
}
38+
}
39+
40+
// convert `example` into json pointers and walk to produce DCQL claim paths
41+
const pointers = toJsonPointerMap({obj: example, flat: true});
42+
const pathsMap = new Map();
43+
for(const [pointer, value] of pointers) {
44+
let path = jsonpointer.parse(pointer);
45+
const isContext = path[0] === '@context';
46+
47+
if(!isContext && isNumber(path.at(-1))) {
48+
// pointer terminates at an array element which means candidate matching
49+
// values are expressed; make sure to share the path for all candidates
50+
path.pop();
51+
}
52+
53+
// convert subpaths into `null` numbers
54+
if(!isContext && nullyifyArrayIndices) {
55+
path = path.map(p => isNumber(p) ? null : p);
56+
} else {
57+
path = path.map(p => isNumber(p) ? parseInt(p, 10) : p);
58+
}
59+
60+
const key = jsonpointer.compile(path.map(p => p === null ? 'null' : p));
61+
62+
// create entry for path and combining candidate matching values
63+
let entry = pathsMap.get(key);
64+
if(!entry) {
65+
entry = {path, valueSet: new Set()};
66+
pathsMap.set(key, entry);
67+
}
68+
69+
// add any non-QueryByExample-wildcard as a DCQL match value
70+
if(!(value === '' || value instanceof Map || Array.isArray(value))) {
71+
entry.valueSet.add(value);
72+
}
73+
}
74+
75+
// produce DCQL `claims`
76+
const claims = [...pathsMap.values()].map(({path, valueSet}) => {
77+
const entry = {path};
78+
if(valueSet.size > 0) {
79+
entry.values = [...valueSet];
80+
}
81+
return entry;
82+
});
83+
84+
if(claims.length > 0) {
85+
result.claims = claims;
86+
}
87+
88+
return result;
889
}

lib/util.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ export function fetchJSON({url, agent} = {}) {
5959
return httpClient.get(url, fetchOptions);
6060
}
6161

62+
export function isNumber(x) {
63+
return !isNaN(parseInt(x, 10));
64+
}
65+
6266
export function parseJSON(x, name) {
6367
try {
6468
return JSON.parse(x);
@@ -175,9 +179,9 @@ function _nextPointers({
175179
if(cursor !== null && typeof cursor === 'object') {
176180
map = map ?? new Map();
177181
const entries = Object.entries(cursor);
178-
if(!flat && entries.length === 0) {
179-
// ensure empty object case is represented
180-
map.set(pointer, new Map());
182+
if(entries.length === 0) {
183+
// ensure empty object / array case is represented
184+
map.set(pointer, Array.isArray(cursor) ? new Set() : new Map());
181185
}
182186
for(const [token, value] of entries) {
183187
tokens.push(String(token));

tests/unit/query.dcql.spec.js

Lines changed: 120 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,126 @@ const {expect} = chai;
1111

1212
describe('query.dcql', () => {
1313
describe('QueryByExample => DCQL', () => {
14-
it('should pass', async () => {
15-
const result = _fromQueryByExampleQuery({});
16-
expect(result).to.exist;
14+
it('should process deep query', async () => {
15+
const dcqlCredentialQuery = _fromQueryByExampleQuery({
16+
credentialQuery: {
17+
reason: `Please present your child's birth certificate to complete ` +
18+
'the verification process.',
19+
example: {
20+
'@context': [
21+
'https://www.w3.org/ns/credentials/v2',
22+
'https://w3id.org/vital-records/v1rc4'
23+
],
24+
type: [
25+
'BirthCertificateCredential'
26+
],
27+
credentialSubject: {
28+
type: 'BirthCertificate',
29+
certifier: {},
30+
newborn: {
31+
name: '',
32+
birthDate: '',
33+
parent: [{
34+
name: 'John Doe'
35+
}]
36+
}
37+
}
38+
}
39+
}
40+
});
41+
expect(dcqlCredentialQuery.id).to.exist;
42+
expect(dcqlCredentialQuery.format).to.eql('ldp_vc');
43+
expect(dcqlCredentialQuery.meta.type_values).to.deep.equal([
44+
'https://www.w3.org/2018/credentials#VerifiableCredential'
45+
]);
46+
expect(dcqlCredentialQuery.claims).to.deep.equal([
47+
{
48+
path: ['@context', 0],
49+
values: ['https://www.w3.org/ns/credentials/v2']
50+
},
51+
{
52+
path: ['@context', 1],
53+
values: ['https://w3id.org/vital-records/v1rc4']
54+
},
55+
{
56+
path: ['type'],
57+
values: ['BirthCertificateCredential']},
58+
{
59+
path: ['credentialSubject', 'type'],
60+
values: ['BirthCertificate']
61+
},
62+
{
63+
path: ['credentialSubject', 'certifier' ]},
64+
{
65+
path: ['credentialSubject', 'newborn', 'name']},
66+
{
67+
path: ['credentialSubject', 'newborn', 'birthDate']},
68+
{
69+
path: ['credentialSubject', 'newborn', 'parent', 0, 'name'],
70+
values: ['John Doe']
71+
}
72+
]);
73+
});
74+
it('should process deep query and nullyify indices', async () => {
75+
const dcqlCredentialQuery = _fromQueryByExampleQuery({
76+
nullyifyArrayIndices: true,
77+
credentialQuery: {
78+
reason: `Please present your child's birth certificate to complete ` +
79+
'the verification process.',
80+
example: {
81+
'@context': [
82+
'https://www.w3.org/ns/credentials/v2',
83+
'https://w3id.org/vital-records/v1rc4'
84+
],
85+
type: [
86+
'BirthCertificateCredential'
87+
],
88+
credentialSubject: {
89+
type: 'BirthCertificate',
90+
certifier: {},
91+
newborn: {
92+
name: '',
93+
birthDate: '',
94+
parent: [{
95+
name: 'John Doe'
96+
}]
97+
}
98+
}
99+
}
100+
}
101+
});
102+
expect(dcqlCredentialQuery.id).to.exist;
103+
expect(dcqlCredentialQuery.format).to.eql('ldp_vc');
104+
expect(dcqlCredentialQuery.meta.type_values).to.deep.equal([
105+
'https://www.w3.org/2018/credentials#VerifiableCredential'
106+
]);
107+
expect(dcqlCredentialQuery.claims).to.deep.equal([
108+
{
109+
path: ['@context', 0],
110+
values: ['https://www.w3.org/ns/credentials/v2']
111+
},
112+
{
113+
path: ['@context', 1],
114+
values: ['https://w3id.org/vital-records/v1rc4']
115+
},
116+
{
117+
path: ['type'],
118+
values: ['BirthCertificateCredential']},
119+
{
120+
path: ['credentialSubject', 'type'],
121+
values: ['BirthCertificate']
122+
},
123+
{
124+
path: ['credentialSubject', 'certifier' ]},
125+
{
126+
path: ['credentialSubject', 'newborn', 'name']},
127+
{
128+
path: ['credentialSubject', 'newborn', 'birthDate']},
129+
{
130+
path: ['credentialSubject', 'newborn', 'parent', null, 'name'],
131+
values: ['John Doe']
132+
}
133+
]);
17134
});
18135
});
19136
});

0 commit comments

Comments
 (0)