@lde/search 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +257 -119
- package/dist/adapter.d.ts +13 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +10 -0
- package/dist/engine.d.ts +154 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -0
- package/dist/project.d.ts +12 -84
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js +91 -53
- package/dist/query.d.ts +87 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +89 -0
- package/dist/schema.d.ts +343 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +250 -0
- package/dist/testing.d.ts +19 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +159 -0
- package/package.json +22 -2
package/dist/testing.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { filterOperatorFor, } from './query.js';
|
|
3
|
+
import { facetableFields, filterableFields, } from './schema.js';
|
|
4
|
+
/**
|
|
5
|
+
* The executable {@link SearchEngine} port contract (import from
|
|
6
|
+
* `@lde/search/testing`): a vitest suite every engine adapter runs against a
|
|
7
|
+
* live instance of itself, so the port rules hold by test rather than by
|
|
8
|
+
* prose. Covers schema binding (a type outside the bound schema is rejected),
|
|
9
|
+
* the always-on query validation (a structurally invalid query is rejected
|
|
10
|
+
* before it reaches the engine) and the result shape of a browse query and a
|
|
11
|
+
* `searchFacets` batch – for every type in the schema.
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* describeSearchEngineContract('TypesenseSearchEngine', () => engine);
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* `engine` is a thunk so the instance may be created in a `beforeAll` (e.g.
|
|
18
|
+
* against a testcontainer); it is called inside each test.
|
|
19
|
+
*/
|
|
20
|
+
export function describeSearchEngineContract(name, engine) {
|
|
21
|
+
describe(`SearchEngine port contract: ${name}`, () => {
|
|
22
|
+
const types = () => [...engine().schema.values()];
|
|
23
|
+
const browse = (searchType) => ({
|
|
24
|
+
where: [],
|
|
25
|
+
orderBy: [],
|
|
26
|
+
limit: 1,
|
|
27
|
+
offset: 0,
|
|
28
|
+
facets: [],
|
|
29
|
+
locale: firstLocale(searchType),
|
|
30
|
+
});
|
|
31
|
+
it('exposes the schema it is bound to, with at least one type', () => {
|
|
32
|
+
expect(types().length).toBeGreaterThan(0);
|
|
33
|
+
for (const searchType of types()) {
|
|
34
|
+
expect(typeof searchType.name).toBe('string');
|
|
35
|
+
expect(typeof searchType.type).toBe('string');
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
it('rejects a search type outside its schema', async () => {
|
|
39
|
+
const foreign = {
|
|
40
|
+
name: 'NotInSchema',
|
|
41
|
+
type: 'urn:test:not-in-schema',
|
|
42
|
+
fields: [],
|
|
43
|
+
};
|
|
44
|
+
await expect(engine().search(foreign, browse(foreign))).rejects.toThrow(/not in this engine/);
|
|
45
|
+
});
|
|
46
|
+
it('rejects a where clause naming an unknown field', async () => {
|
|
47
|
+
for (const searchType of types()) {
|
|
48
|
+
const query = {
|
|
49
|
+
...browse(searchType),
|
|
50
|
+
where: [{ field: 'fieldThatDoesNotExist', in: ['x'] }],
|
|
51
|
+
};
|
|
52
|
+
await expect(engine().search(searchType, query)).rejects.toThrow(/unknown-field/);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
it('rejects a facet request for an unknown field', async () => {
|
|
56
|
+
for (const searchType of types()) {
|
|
57
|
+
const query = {
|
|
58
|
+
...browse(searchType),
|
|
59
|
+
facets: ['fieldThatDoesNotExist'],
|
|
60
|
+
};
|
|
61
|
+
await expect(engine().search(searchType, query)).rejects.toThrow(/unknown-field/);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
it('rejects an operator that does not match the field kind', async () => {
|
|
65
|
+
for (const searchType of types()) {
|
|
66
|
+
const [filterable] = filterableFields(searchType);
|
|
67
|
+
if (filterable === undefined) {
|
|
68
|
+
continue; // Nothing filterable to mismatch against.
|
|
69
|
+
}
|
|
70
|
+
const operator = filterOperatorFor(filterable.kind);
|
|
71
|
+
const query = {
|
|
72
|
+
...browse(searchType),
|
|
73
|
+
where: [mismatchedFilter(filterable.name, operator)],
|
|
74
|
+
};
|
|
75
|
+
await expect(engine().search(searchType, query)).rejects.toThrow(/operator-mismatch/);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
it('rejects a searchFacets batch for a type outside its schema', async () => {
|
|
79
|
+
const foreign = {
|
|
80
|
+
name: 'NotInSchema',
|
|
81
|
+
type: 'urn:test:not-in-schema',
|
|
82
|
+
fields: [],
|
|
83
|
+
};
|
|
84
|
+
await expect(engine().searchFacets(foreign, [browse(foreign)])).rejects.toThrow(/not in this engine/);
|
|
85
|
+
});
|
|
86
|
+
it('rejects a structurally invalid query anywhere in a searchFacets batch', async () => {
|
|
87
|
+
for (const searchType of types()) {
|
|
88
|
+
const queries = [
|
|
89
|
+
{ ...browse(searchType), limit: 0 },
|
|
90
|
+
{
|
|
91
|
+
...browse(searchType),
|
|
92
|
+
limit: 0,
|
|
93
|
+
facets: ['fieldThatDoesNotExist'],
|
|
94
|
+
},
|
|
95
|
+
];
|
|
96
|
+
await expect(engine().searchFacets(searchType, queries)).rejects.toThrow(/unknown-field/);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
it('answers a searchFacets batch with one facets outcome per query, positionally', async () => {
|
|
100
|
+
for (const searchType of types()) {
|
|
101
|
+
const facets = facetableFields(searchType).map((field) => field.name);
|
|
102
|
+
const queries = [
|
|
103
|
+
{ ...browse(searchType), limit: 0, facets },
|
|
104
|
+
// Facet-only regardless of the limit the query carries: this one
|
|
105
|
+
// keeps its non-zero browse limit and must be answered the same.
|
|
106
|
+
{ ...browse(searchType), facets: [] },
|
|
107
|
+
];
|
|
108
|
+
const outcomes = await engine().searchFacets(searchType, queries);
|
|
109
|
+
expect(outcomes).toHaveLength(queries.length);
|
|
110
|
+
for (const outcome of outcomes) {
|
|
111
|
+
// A valid query in a healthy engine yields facets, not an error.
|
|
112
|
+
expect('error' in outcome ? outcome.error : undefined).toBe(undefined);
|
|
113
|
+
if ('error' in outcome) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
expect(outcome.facets).toBeTypeOf('object');
|
|
117
|
+
for (const buckets of Object.values(outcome.facets)) {
|
|
118
|
+
for (const bucket of buckets ?? []) {
|
|
119
|
+
expect(typeof bucket.value).toBe('string');
|
|
120
|
+
expect(typeof bucket.count).toBe('number');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
it('resolves an empty searchFacets batch to an empty list', async () => {
|
|
127
|
+
for (const searchType of types()) {
|
|
128
|
+
await expect(engine().searchFacets(searchType, [])).resolves.toEqual([]);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
it('answers a browse query with hits, a total and facets', async () => {
|
|
132
|
+
for (const searchType of types()) {
|
|
133
|
+
const result = await engine().search(searchType, browse(searchType));
|
|
134
|
+
expect(Array.isArray(result.hits)).toBe(true);
|
|
135
|
+
expect(typeof result.total).toBe('number');
|
|
136
|
+
expect(result.facets).toBeTypeOf('object');
|
|
137
|
+
for (const hit of result.hits) {
|
|
138
|
+
expect(typeof hit.id).toBe('string');
|
|
139
|
+
expect(hit.document).toBeTypeOf('object');
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
/** A filter whose operator deliberately mismatches the field’s kind. */
|
|
146
|
+
function mismatchedFilter(field, operator) {
|
|
147
|
+
return operator === 'in'
|
|
148
|
+
? { field, range: { min: 0 } }
|
|
149
|
+
: { field, in: ['x'] };
|
|
150
|
+
}
|
|
151
|
+
/** The locale a query against this type may select (any is contract-valid). */
|
|
152
|
+
function firstLocale(searchType) {
|
|
153
|
+
for (const field of searchType.fields) {
|
|
154
|
+
if (field.kind === 'text' && field.locales.length > 0) {
|
|
155
|
+
return field.locales[0];
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return 'und';
|
|
159
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lde/search",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Engine-agnostic
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Engine- and domain-agnostic core for RDF-backed search: a unified declarative field model (SearchField/SearchType/SearchSchema), a neutral query IR, the SearchEngine port with logical result types, and a streaming CONSTRUCT-to-document projection. Bakes in no engine, protocol, or domain.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"url": "git+https://github.com/ldelements/lde.git",
|
|
7
7
|
"directory": "packages/search"
|
|
@@ -15,6 +15,18 @@
|
|
|
15
15
|
"import": "./dist/index.js",
|
|
16
16
|
"development": "./src/index.ts",
|
|
17
17
|
"default": "./dist/index.js"
|
|
18
|
+
},
|
|
19
|
+
"./adapter": {
|
|
20
|
+
"types": "./dist/adapter.d.ts",
|
|
21
|
+
"import": "./dist/adapter.js",
|
|
22
|
+
"development": "./src/adapter.ts",
|
|
23
|
+
"default": "./dist/adapter.js"
|
|
24
|
+
},
|
|
25
|
+
"./testing": {
|
|
26
|
+
"types": "./dist/testing.d.ts",
|
|
27
|
+
"import": "./dist/testing.js",
|
|
28
|
+
"development": "./src/testing.ts",
|
|
29
|
+
"default": "./dist/testing.js"
|
|
18
30
|
}
|
|
19
31
|
},
|
|
20
32
|
"main": "./dist/index.js",
|
|
@@ -34,5 +46,13 @@
|
|
|
34
46
|
"devDependencies": {
|
|
35
47
|
"@types/jsonld": "^1.5.15",
|
|
36
48
|
"n3": "^2.1.0"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"vitest": "^4.0.0"
|
|
52
|
+
},
|
|
53
|
+
"peerDependenciesMeta": {
|
|
54
|
+
"vitest": {
|
|
55
|
+
"optional": true
|
|
56
|
+
}
|
|
37
57
|
}
|
|
38
58
|
}
|