Skip to content

Commit d723662

Browse files
Write interpreter for CQL -> Grid structured query
to avoid a case where the grid string parser was turning dot-separated keys, e.g. leases.leases.access:deny-use, into space-separated string tokens
1 parent b19a4ff commit d723662

6 files changed

Lines changed: 216 additions & 20 deletions

File tree

kahuna/public/js/components/gr-cql-input/gr-cql-input.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
QuerySuggestionsService
1111
} from "../../search/structured-query/query-suggestions";
1212
import { theme } from "./theme";
13+
import { cqlParserSettings } from "./syntax";
1314

1415
export const grCqlInput = angular.module("gr.cqlInput", [
1516
querySuggestions.name
@@ -55,14 +56,7 @@ grCqlInput.directive<
5556

5657
const CqlInput = createCqlInput(typeahead, {
5758
theme,
58-
lang: {
59-
operators: false,
60-
groups: false,
61-
shortcuts: {
62-
"#": "label",
63-
"~": "collection"
64-
}
65-
}
59+
lang: cqlParserSettings
6660
});
6761

6862
customElements.define("cql-input", CqlInput);
@@ -119,7 +113,7 @@ grCqlInput.directive<
119113
"queryChange",
120114
(event: QueryChangeEventDetail) => {
121115
if (event.detail?.queryAst) {
122-
scope.onChange()(event.detail.queryStr);
116+
scope.onChange()(event.detail?.queryAst);
123117
}
124118
},
125119
);
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { createParser } from "@guardian/cql";
2+
import { describe, it, expect } from "@jest/globals";
3+
import { cqlParserSettings, structureCqlQuery } from "./syntax";
4+
5+
const parser = createParser(cqlParserSettings);
6+
7+
const queries = [
8+
{
9+
name: "plain text",
10+
cql: "text",
11+
structuredQuery: [{ type: "text", value: "text" }]
12+
},
13+
{
14+
name: "a single chip",
15+
cql: "has:chip",
16+
structuredQuery: [
17+
{
18+
filterType: "inclusion",
19+
key: "has",
20+
type: "filter",
21+
value: "chip"
22+
}
23+
]
24+
},
25+
{
26+
name: "multiple chips",
27+
cql: "has:chip another:chip",
28+
structuredQuery: [
29+
{
30+
filterType: "inclusion",
31+
key: "has",
32+
type: "filter",
33+
value: "chip"
34+
},
35+
{
36+
filterType: "inclusion",
37+
key: "another",
38+
type: "filter",
39+
value: "chip"
40+
}
41+
]
42+
},
43+
{
44+
name: "consecutive text",
45+
cql: "this should be a single text field",
46+
structuredQuery: [
47+
{ type: "text", value: "this should be a single text field" }
48+
]
49+
},
50+
{
51+
name: "chips and text in combination",
52+
cql: "text has:chip another:chip more text",
53+
structuredQuery: [
54+
{ type: "text", value: "text" },
55+
{
56+
filterType: "inclusion",
57+
key: "has",
58+
type: "filter",
59+
value: "chip"
60+
},
61+
{
62+
filterType: "inclusion",
63+
key: "another",
64+
type: "filter",
65+
value: "chip"
66+
},
67+
{ type: "text", value: "more text" }
68+
]
69+
},
70+
{
71+
name: "chips with unusual keys",
72+
cql: "text leases.leases.access:deny-use text usages@platform:print more text",
73+
structuredQuery: [
74+
{ type: "text", value: "text" },
75+
{
76+
filterType: "inclusion",
77+
key: "leases.leases.access",
78+
type: "filter",
79+
value: "deny-use"
80+
},
81+
{ type: "text", value: "text" },
82+
{
83+
filterType: "inclusion",
84+
key: "usages@platform",
85+
type: "filter",
86+
value: "print"
87+
},
88+
{ type: "text", value: "more text" }
89+
]
90+
}
91+
];
92+
93+
describe("", () => {
94+
queries.forEach((query) => {
95+
it(`should parse a query of '${query.name}' into a Grid structured query`, () => {
96+
const cqlAst = parser(query.cql).queryAst;
97+
98+
const structuredQuery = structureCqlQuery(cqlAst);
99+
100+
expect(structuredQuery).toEqual(query.structuredQuery);
101+
});
102+
});
103+
});
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { CqlBinary, CqlExpr, CqlField, CqlQuery, CqlStr } from "@guardian/cql";
2+
import { StructuredQuery } from "../../search/structured-query/syntax";
3+
4+
export const cqlParserSettings = {
5+
operators: false,
6+
groups: false,
7+
shortcuts: {
8+
"#": "label",
9+
"~": "collection"
10+
}
11+
};
12+
13+
export const structureCqlQuery = (query: CqlQuery): StructuredQuery => {
14+
if (!query.content) {
15+
return [];
16+
}
17+
18+
return combineConsecutiveTextFields(structuredQueryFromBinary(query.content));
19+
};
20+
21+
const structuredQueryFromExpr = (expr: CqlExpr): StructuredQuery => {
22+
switch (expr.content.type) {
23+
case "CqlStr":
24+
return structuredQueryFromStr(expr.content);
25+
case "CqlBinary":
26+
return structuredQueryFromBinary(expr.content);
27+
case "CqlGroup":
28+
return []; // No groups in Grid queries ... yet
29+
case "CqlField":
30+
return structuredQueryFromField(expr.content);
31+
}
32+
};
33+
34+
const structuredQueryFromBinary = (binary: CqlBinary): StructuredQuery => {
35+
const left = structuredQueryFromExpr(binary.left);
36+
const right = binary.right
37+
? structuredQueryFromBinary(binary.right.binary)
38+
: [];
39+
40+
return left.concat(right);
41+
};
42+
43+
const structuredQueryFromStr = (str: CqlStr): StructuredQuery => {
44+
return [
45+
{
46+
type: "text",
47+
value: str.searchExpr
48+
}
49+
];
50+
};
51+
52+
const structuredQueryFromField = ({
53+
key: { literal, tokenType },
54+
value
55+
}: CqlField): StructuredQuery => {
56+
const type = literal === "collection" ? "static-filter" : "filter";
57+
const filterType =
58+
tokenType === "CHIP_KEY_NEGATIVE" ? "exclusion" : "inclusion";
59+
60+
return [
61+
{
62+
type,
63+
filterType,
64+
key: literal,
65+
value: value.literal ?? ""
66+
}
67+
];
68+
};
69+
70+
const combineConsecutiveTextFields = (
71+
query: StructuredQuery,
72+
): StructuredQuery =>
73+
query.reduce((previousFields, structuredField) => {
74+
const previousField = previousFields.at(-1);
75+
if (structuredField.type !== "text" || previousField?.type !== "text") {
76+
return previousFields.concat(structuredField);
77+
}
78+
79+
return previousFields.slice(0, previousFields.length - 1).concat({
80+
...structuredField,
81+
value: `${previousField.value} ${structuredField.value}`
82+
});
83+
}, [] as StructuredQuery);

kahuna/public/js/search/structured-query/structured-query.js

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { grChips } from "../../components/gr-chips/gr-chips";
88
import { rxUtil } from "../../util/rx";
99

1010
import { querySuggestions } from "./query-suggestions";
11-
import { renderQuery, structureQuery } from "./syntax";
11+
import { renderQuery, structureCqlQuery, structureQuery } from "./syntax";
1212
import { getFeatureSwitchActive } from "../../components/gr-feature-switch-panel/gr-feature-switch-panel";
1313
import { grCqlInput } from "../../components/gr-cql-input/gr-cql-input";
1414

@@ -89,9 +89,8 @@ grStructuredQuery.directive("grStructuredQuery", [
8989

9090
if (ctrl.useCql) {
9191
ctrl.handleChange = (value) => {
92-
ctrl.structuredQuery = structureQuery(value);
92+
ctrl.structuredQuery = structureCqlQuery(value);
9393
ctrl.structuredQueryChanged(ctrl.structuredQuery);
94-
ctrl.value = value;
9594
};
9695
ngModelCtrl.$render = function () {
9796
ctrl.value = ngModelCtrl.$viewValue || "";

kahuna/public/js/search/structured-query/syntax.js renamed to kahuna/public/js/search/structured-query/syntax.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,36 @@ import {getLabel, getCollection} from '../../search-query/query-syntax';
44
// Line too long for eslint, but can't break it down..
55
/*eslint-disable max-len */
66
const parserRe = /(-?)(?:(?:([\p{L}@><]+):|"([^"]+)":|'([^']+)':|(#)|(~))(?:([^ "']+)|"([^"]+)"|'([^']+)')|([\p{L}0-9:]+)|"([^"]*)"|'([^']*)')/gu;
7+
78
/*eslint-enable max-len */
8-
const falsyValuesToEmptyString = (value) => {
9+
const falsyValuesToEmptyString = (value: string | null | undefined) => {
910
if (!value){
1011
return '';
1112
} else {
1213
return value.toString();
1314
}
1415
};
16+
17+
type StructuredQueryFilter = {
18+
type: 'filter' | 'static-filter' | 'collection'
19+
filterType?: 'inclusion' | 'exclusion',
20+
key: string,
21+
value: string
22+
}
23+
24+
type StructuredQueryText = {
25+
type: 'text'
26+
filterType?: 'inclusion' | 'exclusion',
27+
value: string
28+
}
29+
30+
export type StructuredQuery = (StructuredQueryFilter | StructuredQueryText)[];
31+
1532
// TODO: expose the server-side query parser via an API instead of
1633
// replicating it poorly here
17-
export function structureQuery(query) {
34+
export function structureQuery(query: string) {
1835

19-
const struct = [];
36+
const struct: StructuredQuery = [];
2037
let m;
2138
if (query === undefined) {
2239
return struct;
@@ -54,7 +71,7 @@ export function structureQuery(query) {
5471
return orderChips(struct);
5572
}
5673

57-
function orderChips(structuredQuery){
74+
function orderChips(structuredQuery: StructuredQuery){
5875
const filterChips = structuredQuery.filter(e => e.type !== 'text');
5976
const textChipsValues = mergeTextValues(structuredQuery);
6077
const textChip = {
@@ -64,7 +81,7 @@ function orderChips(structuredQuery){
6481

6582
const cleanStruct = [textChip].concat(filterChips);
6683

67-
function mergeTextValues(chips){
84+
function mergeTextValues(chips: StructuredQuery){
6885
return chips.filter(e => e.type === 'text')
6986
.map(e => e.value)
7087
.join(' ');
@@ -73,7 +90,7 @@ function orderChips(structuredQuery){
7390
return cleanStruct;
7491
}
7592

76-
function renderFilter(field, value) {
93+
function renderFilter(field: string, value: string) {
7794
switch (field) {
7895
case 'label': return getLabel(value);
7996
case 'collection': return getCollection(value);
@@ -83,7 +100,7 @@ function renderFilter(field, value) {
83100
}
84101

85102
// Serialise a structured query into a plain string query
86-
export function renderQuery(structuredQuery) {
103+
export function renderQuery(structuredQuery: StructuredQuery) {
87104
return structuredQuery.filter(item => item.value).map(item => {
88105
switch (item.type) {
89106
// Match both filters

kahuna/tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"compilerOptions": {
33
"noImplicitAny": true,
44
"module": "es6",
5-
"target": "es5",
5+
"target": "es6",
66
"lib": ["es6", "dom"],
77
"jsx": "react",
88
"alwaysStrict": true,

0 commit comments

Comments
 (0)