Skip to content
This repository was archived by the owner on Jan 2, 2024. It is now read-only.

Commit ca1ef0e

Browse files
committed
feat: introduce SolrQueryFromElement which generates a Solr query string from QueryElements, with input validation
BREAKING CHANGE: `Q.toString` has been removed and replaced with `SolrQueryFromElement.decode` which validates its input
1 parent 023db16 commit ca1ef0e

File tree

7 files changed

+662
-576
lines changed

7 files changed

+662
-576
lines changed

Diff for: README.md

+13-15
Original file line numberDiff line numberDiff line change
@@ -15,25 +15,23 @@
1515
After `yarn add solr-query-maker`:
1616

1717
```typescript
18-
import { Q } from 'solr-query-maker';
18+
import { Q, SolrQueryFromElement } from 'solr-query-maker';
1919

20-
// Returns the value of the `q` URL parameter for searching.
2120
function makeQuery() {
2221
// (geo:"Intersects(POINT(-122.17381 37.426002))" OR "spicy" OR title:He??o OR product:([100 TO *] AND (NOT 600)))
23-
return Q.toString(
24-
Q.or(
25-
Q.term(
26-
'geo',
27-
Q.spatial.intersects({
28-
type: 'Point',
29-
coordinates: [-122.17381, 37.426002],
30-
})
31-
),
32-
Q.defaultTerm(Q.L('spicy')),
33-
Q.term('title', Q.glob('He??o')),
34-
Q.term('product', Q.and(Q.closedRange(100, undefined), Q.not(Q.L(600))))
35-
)
22+
const tree = Q.or(
23+
Q.term(
24+
'geo',
25+
Q.spatial.intersects({
26+
type: 'Point',
27+
coordinates: [-122.17381, 37.426002],
28+
})
29+
),
30+
Q.defaultTerm(Q.L('spicy')),
31+
Q.term('title', Q.glob('He??o')),
32+
Q.term('product', Q.and(Q.closedRange(100, undefined), Q.not(Q.L(600))))
3633
);
34+
return SolrQueryFromElement.decode(tree);
3735
}
3836

3937
console.log(makeQuery());

Diff for: examples/quickstart/index.ts

+14-16
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,20 @@
1-
import { Q } from 'solr-query-maker';
1+
import { Q, SolrQueryFromElement } from 'solr-query-maker';
22

3-
// Returns the value of the `q` URL parameter for searching.
43
function makeQuery() {
54
// (geo:"Intersects(POINT(-122.17381 37.426002))" OR "spicy" OR title:He??o OR product:([100 TO *] AND (NOT 600)))
6-
return Q.toString(
7-
Q.or(
8-
Q.term(
9-
'geo',
10-
Q.spatial.intersects({
11-
type: 'Point',
12-
coordinates: [-122.17381, 37.426002],
13-
})
14-
),
15-
Q.defaultTerm(Q.L('spicy')),
16-
Q.term('title', Q.glob('He??o')),
17-
Q.term('product', Q.and(Q.closedRange(100, undefined), Q.not(Q.L(600))))
18-
)
5+
const tree = Q.or(
6+
Q.term(
7+
'geo',
8+
Q.spatial.intersects({
9+
type: 'Point',
10+
coordinates: [-122.17381, 37.426002],
11+
})
12+
),
13+
Q.defaultTerm(Q.L('spicy')),
14+
Q.term('title', Q.glob('He??o')),
15+
Q.term('product', Q.and(Q.closedRange(100, undefined), Q.not(Q.L(600))))
1916
);
17+
return SolrQueryFromElement.decode(tree);
2018
}
2119

22-
console.log(makeQuery());
20+
console.log(makeQuery());

Diff for: src/Q.ts

-106
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,11 @@ import {
2828
LDate,
2929
LNumber,
3030
RangedPrimitive,
31-
QueryElement,
3231
} from './types';
3332

3433
import * as util from 'util';
3534

3635
import * as spatial from './spatial';
37-
import * as wktio from 'wkt-io-ts';
38-
import { WKT } from 'wkt-io-ts';
39-
import { isRight } from 'fp-ts/lib/Either';
40-
import { PathReporter } from 'io-ts/lib/PathReporter';
4136

4237
export { spatial };
4338

@@ -251,104 +246,3 @@ export function or<U>(...more: U[]): OrBase<U> {
251246
operands: more,
252247
};
253248
}
254-
255-
/* istanbul ignore next */
256-
function nope(): never {
257-
throw new Error('unsupported');
258-
}
259-
260-
function wkt(g: Spatial['value']['geom']): WKT {
261-
const d = wktio.WKTStringFromGeometry.decode(g);
262-
if (!isRight(d)) {
263-
throw new Error('Cannot parse: ' + PathReporter.report(d).join('; '));
264-
}
265-
return d.right;
266-
}
267-
268-
function toLiteralString<T extends Primitive>(
269-
ct: T['type'],
270-
c?: T['value']
271-
): string {
272-
if (c == null) {
273-
return '*';
274-
}
275-
switch (ct) {
276-
case 'string':
277-
return quoteString(c as string);
278-
case 'number':
279-
return `${c as number}`;
280-
case 'date':
281-
return (c as Date).toISOString();
282-
case 'spatial':
283-
return quoteString(
284-
`${(c as Spatial['value']).op}(${wkt((c as Spatial['value']).geom)})`
285-
);
286-
case 'glob':
287-
return escapedGlob(c as string);
288-
/* istanbul ignore next */
289-
default:
290-
/* istanbul ignore next */
291-
return nope();
292-
}
293-
}
294-
295-
function toRangeString(v: Range<RangedPrimitive>): string {
296-
return (
297-
(v.closedLower ? '[' : '{') +
298-
toLiteralString(v.valueType, v.lower) +
299-
' TO ' +
300-
toLiteralString(v.valueType, v.upper) +
301-
(v.closedUpper ? ']' : '}')
302-
);
303-
}
304-
305-
function escapedGlob(v: string) {
306-
// don't escape * or ?
307-
return v.replace(/[ +\-&|!(){}\[\]^"~:/\\]/g, '\\$&');
308-
}
309-
310-
function quoteString(v: string) {
311-
return `"${v.replace(/["\\]/g, '\\$&')}"`;
312-
}
313-
314-
function binaryOp(op: string, operands: QueryElement[]) {
315-
const elems: string[] = [];
316-
for (const v of operands) {
317-
elems.push(toStringImpl(v));
318-
}
319-
return `(${elems.join(` ${op} `)})`;
320-
}
321-
322-
function toStringImpl(c: QueryElement): string {
323-
switch (c.type) {
324-
case 'range':
325-
return toRangeString(c);
326-
case 'term':
327-
return toStringImpl(c.value);
328-
case 'namedterm':
329-
return c.field + ':' + toStringImpl(c.value);
330-
case 'and':
331-
return binaryOp('AND', c.operands);
332-
case 'constant':
333-
return `(${toStringImpl(c.lhs)})^=${c.rhs}`;
334-
case 'not':
335-
return `(NOT ${toStringImpl(c.rhs)})`;
336-
case 'or':
337-
return binaryOp('OR', c.operands);
338-
case 'prohibited':
339-
return `-${toStringImpl(c.rhs)}`;
340-
case 'required':
341-
return `+${toStringImpl(c.rhs)}`;
342-
case 'literal':
343-
return toLiteralString(c.value.type, c.value.value);
344-
/* istanbul ignore next */
345-
default:
346-
console.error(util.inspect(c, false, 1000, true));
347-
/* istanbul ignore next */
348-
return nope();
349-
}
350-
}
351-
352-
export function toString(c: QueryElement): string {
353-
return toStringImpl(c);
354-
}

Diff for: src/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,6 @@ Third-party dependencies may have their own licenses.
2424
import * as Q from './Q';
2525

2626
export * from './types';
27+
export * from './query';
28+
2729
export { Q };

Diff for: src/query.ts

+156
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import * as t from 'io-ts';
2+
import * as types from './types';
3+
import { either } from 'fp-ts/lib/Either';
4+
5+
import {
6+
Primitive,
7+
Range,
8+
Spatial,
9+
RangedPrimitive,
10+
QueryElement,
11+
} from './types';
12+
13+
import * as wktio from 'wkt-io-ts';
14+
import { WKT } from 'wkt-io-ts';
15+
import { isRight } from 'fp-ts/lib/Either';
16+
import { PathReporter } from 'io-ts/lib/PathReporter';
17+
18+
export interface SolrQueryBrand {
19+
readonly SolrQuery: unique symbol;
20+
}
21+
22+
// tslint:disable-next-line:variable-name
23+
const SolrQuery = t.brand(
24+
t.string,
25+
// this is used below, and internally only, where the result is
26+
// neccessarily the result of Q.toString.
27+
(_): _ is t.Branded<string, SolrQueryBrand> => true,
28+
'SolrQuery'
29+
);
30+
export type SolrQuery = t.Branded<string, SolrQueryBrand>;
31+
32+
/* istanbul ignore next */
33+
function nope(): never {
34+
throw new Error('unsupported');
35+
}
36+
37+
function wkt(g: Spatial['value']['geom']): WKT {
38+
const d = wktio.WKTStringFromGeometry.decode(g);
39+
/* istanbul ignore if */
40+
if (!isRight(d)) {
41+
// this shouldn't happen since we validated the geometry earlier
42+
throw new Error('Cannot parse: ' + PathReporter.report(d).join('; '));
43+
}
44+
return d.right;
45+
}
46+
47+
function toLiteralString<T extends Primitive>(
48+
ct: T['type'],
49+
c?: T['value']
50+
): string {
51+
if (c == null) {
52+
return '*';
53+
}
54+
switch (ct) {
55+
case 'string':
56+
return quoteString(c as string);
57+
case 'number':
58+
return `${c as number}`;
59+
case 'date':
60+
return (c as Date).toISOString();
61+
case 'spatial':
62+
return quoteString(
63+
`${(c as Spatial['value']).op}(${wkt((c as Spatial['value']).geom)})`
64+
);
65+
case 'glob':
66+
return escapedGlob(c as string);
67+
/* istanbul ignore next */
68+
default:
69+
/* istanbul ignore next */
70+
return nope();
71+
}
72+
}
73+
74+
function toRangeString(v: Range<RangedPrimitive>): string {
75+
return (
76+
(v.closedLower ? '[' : '{') +
77+
toLiteralString(v.valueType, v.lower) +
78+
' TO ' +
79+
toLiteralString(v.valueType, v.upper) +
80+
(v.closedUpper ? ']' : '}')
81+
);
82+
}
83+
84+
function escapedGlob(v: string) {
85+
// don't escape * or ?
86+
return v.replace(/[ +\-&|!(){}\[\]^"~:/\\]/g, '\\$&');
87+
}
88+
89+
function quoteString(v: string) {
90+
return `"${v.replace(/["\\]/g, '\\$&')}"`;
91+
}
92+
93+
function binaryOp(op: string, operands: QueryElement[]) {
94+
const elems: string[] = [];
95+
for (const v of operands) {
96+
elems.push(toStringImpl(v));
97+
}
98+
return `(${elems.join(` ${op} `)})`;
99+
}
100+
101+
function toStringImpl(c: QueryElement): string {
102+
switch (c.type) {
103+
case 'range':
104+
return toRangeString(c);
105+
case 'term':
106+
return toStringImpl(c.value);
107+
case 'namedterm':
108+
return c.field + ':' + toStringImpl(c.value);
109+
case 'and':
110+
return binaryOp('AND', c.operands);
111+
case 'constant':
112+
return `(${toStringImpl(c.lhs)})^=${c.rhs}`;
113+
case 'not':
114+
return `(NOT ${toStringImpl(c.rhs)})`;
115+
case 'or':
116+
return binaryOp('OR', c.operands);
117+
case 'prohibited':
118+
return `-${toStringImpl(c.rhs)}`;
119+
case 'required':
120+
return `+${toStringImpl(c.rhs)}`;
121+
case 'literal':
122+
return toLiteralString(c.value.type, c.value.value);
123+
/* istanbul ignore next */
124+
default:
125+
/* istanbul ignore next */
126+
return nope();
127+
}
128+
}
129+
130+
export interface SolrQueryFromElementC extends t.Decoder<unknown, SolrQuery> {}
131+
132+
/**
133+
* Converts (and validates) an input QueryElement to a string Solr query.
134+
*/
135+
// tslint:disable-next-line:variable-name
136+
export const SolrQueryFromElement: SolrQueryFromElementC = new t.Type<
137+
SolrQuery,
138+
SolrQuery,
139+
unknown
140+
>(
141+
'solr-query-maker:SolrQueryFromElement',
142+
/* istanbul ignore next */
143+
(_): _ is SolrQuery => {
144+
throw new Error();
145+
},
146+
(inp, ctx) =>
147+
either.chain(types.QueryElement.validate(inp, ctx), qe => {
148+
try {
149+
return SolrQuery.decode(toStringImpl(qe));
150+
} catch (err) {
151+
/* istanbul ignore next */
152+
return t.failure(inp, ctx);
153+
}
154+
}),
155+
t.identity
156+
).asDecoder();

0 commit comments

Comments
 (0)