@lde/search 0.1.2 → 0.2.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.
@@ -0,0 +1,106 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { filterOperatorFor, } from './query.js';
3
+ import { 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 — for
11
+ * 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('answers a browse query with hits, a total and facets', async () => {
79
+ for (const searchType of types()) {
80
+ const result = await engine().search(searchType, browse(searchType));
81
+ expect(Array.isArray(result.hits)).toBe(true);
82
+ expect(typeof result.total).toBe('number');
83
+ expect(result.facets).toBeTypeOf('object');
84
+ for (const hit of result.hits) {
85
+ expect(typeof hit.id).toBe('string');
86
+ expect(hit.document).toBeTypeOf('object');
87
+ }
88
+ }
89
+ });
90
+ });
91
+ }
92
+ /** A filter whose operator deliberately mismatches the field’s kind. */
93
+ function mismatchedFilter(field, operator) {
94
+ return operator === 'in'
95
+ ? { field, range: { min: 0 } }
96
+ : { field, in: ['x'] };
97
+ }
98
+ /** The locale a query against this type may select (any is contract-valid). */
99
+ function firstLocale(searchType) {
100
+ for (const field of searchType.fields) {
101
+ if (field.kind === 'text' && field.locales.length > 0) {
102
+ return field.locales[0];
103
+ }
104
+ }
105
+ return 'und';
106
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lde/search",
3
- "version": "0.1.2",
4
- "description": "Engine-agnostic search projection for RDF-backed pipelines: frame CONSTRUCT quads into a JSON-LD IR, then project that IR into flat search documents from a declarative field spec (the artifact a SHACL generator would emit)",
3
+ "version": "0.2.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
  }