@speedkit/cli 4.26.0 → 4.27.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/CHANGELOG.md +10 -0
- package/README.md +186 -15
- package/dist/commands/auto-prewarm.d.ts +8 -1
- package/dist/commands/auto-prewarm.js +18 -7
- package/dist/commands/generate-pop-config.d.ts +1 -0
- package/dist/commands/generate-pop-config.js +9 -3
- package/dist/commands/prewarm.js +3 -1
- package/dist/commands/query/parameter.d.ts +26 -0
- package/dist/commands/query/parameter.js +27 -0
- package/dist/commands/query/prewarm.d.ts +31 -0
- package/dist/commands/query/prewarm.js +31 -0
- package/dist/helpers/clipboard.d.ts +19 -0
- package/dist/helpers/clipboard.js +70 -0
- package/dist/helpers/evaluate-speed-kit-config.d.ts +8 -18
- package/dist/helpers/evaluate-speed-kit-config.js +8 -6
- package/dist/helpers/evaluate-speed-kit-config.spec.d.ts +1 -0
- package/dist/helpers/evaluate-speed-kit-config.spec.js +78 -0
- package/dist/models/cli-parameters.d.ts +73 -0
- package/dist/models/cli-parameters.js +151 -0
- package/dist/models/cli-parameters.spec.d.ts +1 -0
- package/dist/models/cli-parameters.spec.js +47 -0
- package/dist/services/athena/athena-service.d.ts +2 -0
- package/dist/services/athena/athena-service.js +15 -4
- package/dist/services/athena/athena-service.spec.d.ts +1 -0
- package/dist/services/athena/athena-service.spec.js +74 -0
- package/dist/services/deploy/handler/install-resource-handler.js +3 -4
- package/dist/services/onboarding/onboarding-service-factory.js +2 -2
- package/dist/services/pop-config/pop-config-service.d.ts +8 -1
- package/dist/services/pop-config/pop-config-service.js +16 -28
- package/dist/services/prewarm/assets/asset-api-client.d.ts +1 -1
- package/dist/services/prewarm/assets/asset-api-client.js +5 -4
- package/dist/services/prewarm/auto-pre-warm-factory.d.ts +12 -2
- package/dist/services/prewarm/auto-pre-warm-factory.js +20 -6
- package/dist/services/prewarm/csv-reader.d.ts +21 -2
- package/dist/services/prewarm/csv-reader.js +71 -9
- package/dist/services/prewarm/csv-reader.spec.d.ts +1 -0
- package/dist/services/prewarm/csv-reader.spec.js +75 -0
- package/dist/services/prewarm/index.d.ts +1 -0
- package/dist/services/prewarm/index.js +1 -0
- package/dist/services/prewarm/pre-warm-factory.d.ts +0 -2
- package/dist/services/prewarm/pre-warm-factory.js +8 -11
- package/dist/services/prewarm/pre-warm-model.d.ts +14 -1
- package/dist/services/prewarm/pre-warm-model.js +0 -1
- package/dist/services/prewarm/pre-warm-service.d.ts +9 -4
- package/dist/services/prewarm/pre-warm-service.js +18 -14
- package/dist/services/prewarm/prewarm-targets.d.ts +12 -0
- package/dist/services/prewarm/prewarm-targets.js +18 -0
- package/dist/services/prewarm/prewarm-targets.spec.d.ts +1 -0
- package/dist/services/prewarm/prewarm-targets.spec.js +29 -0
- package/dist/services/query-builder/error/match-matches-nothing-error.d.ts +11 -0
- package/dist/services/query-builder/error/match-matches-nothing-error.js +17 -0
- package/dist/services/query-builder/queries/device.d.ts +21 -0
- package/dist/services/query-builder/queries/device.js +26 -0
- package/dist/services/query-builder/queries/index.d.ts +6 -0
- package/dist/services/query-builder/queries/index.js +6 -0
- package/dist/services/query-builder/queries/page-filter.d.ts +39 -0
- package/dist/services/query-builder/queries/page-filter.js +135 -0
- package/dist/services/query-builder/queries/page-source.d.ts +30 -0
- package/dist/services/query-builder/queries/page-source.js +79 -0
- package/dist/services/query-builder/queries/parameter-query.d.ts +14 -0
- package/dist/services/query-builder/queries/parameter-query.js +142 -0
- package/dist/services/query-builder/queries/pops-query.d.ts +27 -0
- package/dist/services/query-builder/queries/pops-query.js +65 -0
- package/dist/services/query-builder/queries/prewarm-query.d.ts +18 -0
- package/dist/services/query-builder/queries/prewarm-query.js +227 -0
- package/dist/services/query-builder/queries/query-model.d.ts +97 -0
- package/dist/services/query-builder/queries/query-model.js +19 -0
- package/dist/services/query-builder/queries/spec/golden-configs.d.ts +18 -0
- package/dist/services/query-builder/queries/spec/golden-configs.js +116 -0
- package/dist/services/query-builder/queries/spec/golden.spec.d.ts +1 -0
- package/dist/services/query-builder/queries/spec/golden.spec.js +161 -0
- package/dist/services/query-builder/queries/spec/pops-query.spec.d.ts +1 -0
- package/dist/services/query-builder/queries/spec/pops-query.spec.js +58 -0
- package/dist/services/query-builder/queries/spec/queries.spec.d.ts +1 -0
- package/dist/services/query-builder/queries/spec/queries.spec.js +312 -0
- package/dist/services/query-builder/queries/spec/reported-bugs.spec.d.ts +1 -0
- package/dist/services/query-builder/queries/spec/reported-bugs.spec.js +86 -0
- package/dist/services/query-builder/query-builder-factory.d.ts +2 -2
- package/dist/services/query-builder/query-builder-factory.js +35 -7
- package/dist/services/query-builder/query-builder-model.d.ts +31 -19
- package/dist/services/query-builder/query-builder-model.js +5 -5
- package/dist/services/query-builder/query-builder-service.d.ts +26 -5
- package/dist/services/query-builder/query-builder-service.js +157 -44
- package/dist/services/query-builder/query-command.d.ts +26 -0
- package/dist/services/query-builder/query-command.js +35 -0
- package/dist/services/query-builder/rules/index.d.ts +8 -0
- package/dist/services/query-builder/rules/index.js +8 -0
- package/dist/services/query-builder/rules/match-pattern.d.ts +51 -0
- package/dist/services/query-builder/rules/match-pattern.js +141 -0
- package/dist/services/query-builder/rules/pattern-language.d.ts +59 -0
- package/dist/services/query-builder/rules/pattern-language.js +159 -0
- package/dist/services/query-builder/rules/rule-model.d.ts +107 -0
- package/dist/services/query-builder/rules/rule-model.js +54 -0
- package/dist/services/query-builder/rules/rule-parser.d.ts +33 -0
- package/dist/services/query-builder/rules/rule-parser.js +189 -0
- package/dist/services/query-builder/rules/rule-reducer.d.ts +71 -0
- package/dist/services/query-builder/rules/rule-reducer.js +383 -0
- package/dist/services/query-builder/rules/rule-relevance.d.ts +43 -0
- package/dist/services/query-builder/rules/rule-relevance.js +69 -0
- package/dist/services/query-builder/rules/rule-to-sql.d.ts +51 -0
- package/dist/services/query-builder/rules/rule-to-sql.js +75 -0
- package/dist/services/query-builder/rules/spec/pattern-language.spec.d.ts +1 -0
- package/dist/services/query-builder/rules/spec/pattern-language.spec.js +90 -0
- package/dist/services/query-builder/rules/spec/rule-parser.spec.d.ts +1 -0
- package/dist/services/query-builder/rules/spec/rule-parser.spec.js +150 -0
- package/dist/services/query-builder/rules/spec/rule-reducer.spec.d.ts +1 -0
- package/dist/services/query-builder/rules/spec/rule-reducer.spec.js +169 -0
- package/dist/services/query-builder/rules/spec/rule-relevance.spec.d.ts +1 -0
- package/dist/services/query-builder/rules/spec/rule-relevance.spec.js +88 -0
- package/dist/services/query-builder/rules/spec/rule-to-sql.spec.d.ts +1 -0
- package/dist/services/query-builder/rules/spec/rule-to-sql.spec.js +110 -0
- package/dist/services/query-builder/rules/spec/runtime-semantics.spec.d.ts +1 -0
- package/dist/services/query-builder/rules/spec/runtime-semantics.spec.js +55 -0
- package/dist/services/query-builder/rules/spec/strip-parameters.spec.d.ts +1 -0
- package/dist/services/query-builder/rules/spec/strip-parameters.spec.js +85 -0
- package/dist/services/query-builder/rules/strip-parameters.d.ts +39 -0
- package/dist/services/query-builder/rules/strip-parameters.js +197 -0
- package/dist/services/query-builder/sql/expression.d.ts +73 -0
- package/dist/services/query-builder/sql/expression.js +133 -0
- package/dist/services/query-builder/sql/format.d.ts +14 -0
- package/dist/services/query-builder/sql/format.js +48 -0
- package/dist/services/query-builder/sql/index.d.ts +4 -0
- package/dist/services/query-builder/sql/index.js +4 -0
- package/dist/services/query-builder/sql/predicate.d.ts +66 -0
- package/dist/services/query-builder/sql/predicate.js +124 -0
- package/dist/services/query-builder/sql/regex.d.ts +28 -0
- package/dist/services/query-builder/sql/regex.js +81 -0
- package/dist/services/query-builder/sql/spec/expression.spec.d.ts +1 -0
- package/dist/services/query-builder/sql/spec/expression.spec.js +75 -0
- package/dist/services/query-builder/sql/spec/format.spec.d.ts +1 -0
- package/dist/services/query-builder/sql/spec/format.spec.js +37 -0
- package/dist/services/query-builder/sql/spec/predicate.spec.d.ts +1 -0
- package/dist/services/query-builder/sql/spec/predicate.spec.js +78 -0
- package/dist/services/query-builder/sql/spec/regex.spec.d.ts +1 -0
- package/dist/services/query-builder/sql/spec/regex.spec.js +62 -0
- package/oclif.manifest.json +350 -100
- package/package.json +11 -2
- package/dist/commands/build-parameter-query.d.ts +0 -14
- package/dist/commands/build-parameter-query.js +0 -25
- package/dist/commands/build-prewarm-query.d.ts +0 -16
- package/dist/commands/build-prewarm-query.js +0 -37
- package/dist/helpers/build-query-helper.d.ts +0 -32
- package/dist/helpers/build-query-helper.js +0 -223
- package/dist/helpers/get-parsed-config.d.ts +0 -15
- package/dist/helpers/get-parsed-config.js +0 -62
- package/dist/services/onboarding/dashboard/parameter-query-builder.d.ts +0 -8
- package/dist/services/onboarding/dashboard/parameter-query-builder.js +0 -63
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { leaf } from "./predicate.js";
|
|
2
|
+
const IDENTIFIER = /^[a-z_][\w$]*(\.[a-z_][\w$]*)*$/i;
|
|
3
|
+
/** Characters that carry a meaning inside a `like` pattern. */
|
|
4
|
+
const LIKE_METACHARACTERS = /[%_\\]/;
|
|
5
|
+
/**
|
|
6
|
+
* A string, number or boolean as a SQL literal. Quotes inside a string are
|
|
7
|
+
* doubled, so any config-provided value is safe to embed.
|
|
8
|
+
*/
|
|
9
|
+
export function literal(value) {
|
|
10
|
+
if (typeof value === "boolean") {
|
|
11
|
+
return value ? "true" : "false";
|
|
12
|
+
}
|
|
13
|
+
if (typeof value === "number") {
|
|
14
|
+
if (!Number.isFinite(value)) {
|
|
15
|
+
throw new Error(`Cannot use ${value} as a SQL literal`);
|
|
16
|
+
}
|
|
17
|
+
return String(value);
|
|
18
|
+
}
|
|
19
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A column reference, optionally qualified (`cdnclient.bot`). Rejects anything
|
|
23
|
+
* that is not an identifier, so a column name can never smuggle in SQL.
|
|
24
|
+
*/
|
|
25
|
+
export function column(name) {
|
|
26
|
+
if (!IDENTIFIER.test(name)) {
|
|
27
|
+
throw new Error(`Not a valid column name: ${name}`);
|
|
28
|
+
}
|
|
29
|
+
return name;
|
|
30
|
+
}
|
|
31
|
+
/** A function call with the given arguments, e.g. `url_extract_path(url)`. */
|
|
32
|
+
export function call(name, ...args) {
|
|
33
|
+
if (!IDENTIFIER.test(name)) {
|
|
34
|
+
throw new Error(`Not a valid function name: ${name}`);
|
|
35
|
+
}
|
|
36
|
+
return `${name}(${args.join(", ")})`;
|
|
37
|
+
}
|
|
38
|
+
/** `lower(expression)`, used to match the case-insensitive string conditions. */
|
|
39
|
+
export function lower(expression) {
|
|
40
|
+
return call("lower", expression);
|
|
41
|
+
}
|
|
42
|
+
/** Concatenation of all expressions, parenthesised so it can be nested. */
|
|
43
|
+
export function concat(...expressions) {
|
|
44
|
+
return `(${expressions.join(" || ")})`;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Wraps a hand-written expression. The caller is responsible for escaping its
|
|
48
|
+
* operands and for parenthesising anything that binds loosely.
|
|
49
|
+
*/
|
|
50
|
+
export function raw(sql) {
|
|
51
|
+
return sql;
|
|
52
|
+
}
|
|
53
|
+
/** `left = right`. */
|
|
54
|
+
export function equals(left, right) {
|
|
55
|
+
return leaf(`${left} = ${right}`);
|
|
56
|
+
}
|
|
57
|
+
/** `left <> right`. */
|
|
58
|
+
export function notEquals(left, right) {
|
|
59
|
+
return leaf(`${left} <> ${right}`);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* A placeholder for a value passed to Athena alongside the query.
|
|
63
|
+
*
|
|
64
|
+
* Placeholders are bound by position, in the order they appear in the printed
|
|
65
|
+
* SQL — so a predicate must not contain two identical comparisons over
|
|
66
|
+
* placeholders: {@link and} and {@link or} drop a duplicate operand, which
|
|
67
|
+
* would leave one value too many.
|
|
68
|
+
*/
|
|
69
|
+
export function parameter() {
|
|
70
|
+
return "?";
|
|
71
|
+
}
|
|
72
|
+
/** `left >= right`. */
|
|
73
|
+
export function greaterOrEqual(left, right) {
|
|
74
|
+
return leaf(`${left} >= ${right}`);
|
|
75
|
+
}
|
|
76
|
+
/** `left > right`. */
|
|
77
|
+
export function greaterThan(left, right) {
|
|
78
|
+
return leaf(`${left} > ${right}`);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* `subject = 'value'`, ignoring case on both sides — the semantics of a string
|
|
82
|
+
* condition in a Speed Kit rule set of version 2.
|
|
83
|
+
*/
|
|
84
|
+
export function equalsIgnoreCase(subject, value) {
|
|
85
|
+
return equals(lower(subject), literal(value.toLowerCase()));
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* `subject like 'value%'` — a prefix match, which is what a string condition
|
|
89
|
+
* means in a rule set of version 1. Case is ignored by default, matching the
|
|
90
|
+
* runtime, which lower-cases both sides.
|
|
91
|
+
*/
|
|
92
|
+
export function startsWith(subject, value, options = {}) {
|
|
93
|
+
const ignoreCase = options.ignoreCase ?? true;
|
|
94
|
+
const prefix = ignoreCase ? value.toLowerCase() : value;
|
|
95
|
+
const left = ignoreCase ? lower(subject) : subject;
|
|
96
|
+
if (!LIKE_METACHARACTERS.test(prefix)) {
|
|
97
|
+
return leaf(`${left} like ${literal(`${prefix}%`)}`);
|
|
98
|
+
}
|
|
99
|
+
// `like` has no escape character unless one is declared, so a value
|
|
100
|
+
// containing `%`, `_` or `\` needs both the escaping and the declaration.
|
|
101
|
+
const escaped = prefix.replaceAll(/[%_\\]/g, (match) => `\\${match}`);
|
|
102
|
+
return leaf(`${left} like ${literal(`${escaped}%`)} escape '\\'`);
|
|
103
|
+
}
|
|
104
|
+
/** `subject between low and high`. */
|
|
105
|
+
export function between(subject, low, high) {
|
|
106
|
+
return leaf(`${subject} between ${low} and ${high}`);
|
|
107
|
+
}
|
|
108
|
+
/** `expression is null`. */
|
|
109
|
+
export function isNull(expression) {
|
|
110
|
+
return leaf(`${expression} is null`);
|
|
111
|
+
}
|
|
112
|
+
/** `subject not in ('a', 'b')`. */
|
|
113
|
+
export function notInValues(subject, values) {
|
|
114
|
+
return leaf(`${subject} not in (${valueList(values)})`);
|
|
115
|
+
}
|
|
116
|
+
/** `subject in ('a', 'b')`. */
|
|
117
|
+
export function inValues(subject, values) {
|
|
118
|
+
return leaf(`${subject} in (${valueList(values)})`);
|
|
119
|
+
}
|
|
120
|
+
function valueList(values) {
|
|
121
|
+
return values.map((value) => literal(value)).join(", ");
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* `regexp_like(subject, 'pattern')`. The pattern is expected to already be in
|
|
125
|
+
* Athena's dialect — see {@link toAthenaRegex}.
|
|
126
|
+
*/
|
|
127
|
+
export function regexpLike(subject, pattern) {
|
|
128
|
+
return leaf(call("regexp_like", subject, literal(pattern)), true);
|
|
129
|
+
}
|
|
130
|
+
/** A boolean column used as a predicate on its own, e.g. `suspicious`. */
|
|
131
|
+
export function booleanColumn(name) {
|
|
132
|
+
return leaf(column(name), true);
|
|
133
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Predicate } from "./predicate.js";
|
|
2
|
+
export interface FormatPredicateOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Indentation level the predicate starts at. The first line is *not*
|
|
5
|
+
* indented — the caller has already positioned it (e.g. after `where `) —
|
|
6
|
+
* every following line is.
|
|
7
|
+
*/
|
|
8
|
+
readonly level?: number;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Renders a predicate as SQL that a human can read in the Athena console: one
|
|
12
|
+
* operand per line, the operator leading the line, nested groups indented.
|
|
13
|
+
*/
|
|
14
|
+
export declare function formatPredicate(predicate: Predicate, options?: FormatPredicateOptions): string;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** Two spaces per level, matching the indentation of the emitted queries. */
|
|
2
|
+
const INDENT = " ";
|
|
3
|
+
/**
|
|
4
|
+
* Renders a predicate as SQL that a human can read in the Athena console: one
|
|
5
|
+
* operand per line, the operator leading the line, nested groups indented.
|
|
6
|
+
*/
|
|
7
|
+
export function formatPredicate(predicate, options = {}) {
|
|
8
|
+
return print(predicate, options.level ?? 0);
|
|
9
|
+
}
|
|
10
|
+
function print(predicate, level) {
|
|
11
|
+
switch (predicate.kind) {
|
|
12
|
+
case "and":
|
|
13
|
+
case "or": {
|
|
14
|
+
const separator = `\n${INDENT.repeat(level)}${predicate.kind} `;
|
|
15
|
+
return predicate.children
|
|
16
|
+
.map((child) => printOperand(child, level, false))
|
|
17
|
+
.join(separator);
|
|
18
|
+
}
|
|
19
|
+
case "false": {
|
|
20
|
+
return "false";
|
|
21
|
+
}
|
|
22
|
+
case "leaf": {
|
|
23
|
+
return predicate.sql;
|
|
24
|
+
}
|
|
25
|
+
case "not": {
|
|
26
|
+
return `not ${printOperand(predicate.child, level, true)}`;
|
|
27
|
+
}
|
|
28
|
+
case "true": {
|
|
29
|
+
return "true";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Prints an operand of `and`, `or` or `not`. Groups get parentheses and their
|
|
35
|
+
* own indentation level. A leaf goes inline; a negated comparison is
|
|
36
|
+
* parenthesised, because `not` binding looser than `=` is a detail a reader
|
|
37
|
+
* should not have to remember.
|
|
38
|
+
*/
|
|
39
|
+
function printOperand(predicate, level, negated) {
|
|
40
|
+
if (predicate.kind === "leaf") {
|
|
41
|
+
return negated && !predicate.atomic ? `(${predicate.sql})` : predicate.sql;
|
|
42
|
+
}
|
|
43
|
+
if (predicate.kind !== "and" && predicate.kind !== "or") {
|
|
44
|
+
return print(predicate, level);
|
|
45
|
+
}
|
|
46
|
+
const inner = INDENT.repeat(level + 1);
|
|
47
|
+
return `(\n${inner}${print(predicate, level + 1)}\n${INDENT.repeat(level)})`;
|
|
48
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A boolean SQL expression represented as a small tree instead of a string.
|
|
3
|
+
*
|
|
4
|
+
* The queries this builder emits are assembled from Speed Kit rules, and rules
|
|
5
|
+
* cancel each other out: a blacklist entry that can never match, an
|
|
6
|
+
* `enabledSites` rule already covered by another one. Folding those away is
|
|
7
|
+
* only possible while the predicate is still structured, which is what this
|
|
8
|
+
* type is for. {@link formatPredicate} turns it back into SQL.
|
|
9
|
+
*
|
|
10
|
+
* Leaves carry raw SQL. A leaf that is a single column or function call is
|
|
11
|
+
* atomic and needs no parentheses; a comparison is not, and gets them when it
|
|
12
|
+
* is negated. A leaf built by hand from a low-precedence expression (`a or b`)
|
|
13
|
+
* must bring its own.
|
|
14
|
+
*/
|
|
15
|
+
export type Predicate = {
|
|
16
|
+
readonly children: readonly Predicate[];
|
|
17
|
+
readonly kind: "and" | "or";
|
|
18
|
+
} | {
|
|
19
|
+
readonly child: Predicate;
|
|
20
|
+
readonly kind: "not";
|
|
21
|
+
} | {
|
|
22
|
+
readonly kind: "false";
|
|
23
|
+
} | {
|
|
24
|
+
readonly atomic?: boolean;
|
|
25
|
+
readonly kind: "leaf";
|
|
26
|
+
readonly sql: string;
|
|
27
|
+
} | {
|
|
28
|
+
readonly kind: "true";
|
|
29
|
+
};
|
|
30
|
+
/** The always-true predicate. Neutral in `and`, absorbing in `or`. */
|
|
31
|
+
export declare const TRUE: Predicate;
|
|
32
|
+
/** The always-false predicate. Absorbing in `and`, neutral in `or`. */
|
|
33
|
+
export declare const FALSE: Predicate;
|
|
34
|
+
/**
|
|
35
|
+
* Wraps a piece of SQL as a predicate. Prefer the constructors in
|
|
36
|
+
* `expression.ts`, which escape their operands.
|
|
37
|
+
*
|
|
38
|
+
* @param atomic - True for a single column or function call, which never
|
|
39
|
+
* needs parentheses around it.
|
|
40
|
+
*/
|
|
41
|
+
export declare function leaf(sql: string, atomic?: boolean): Predicate;
|
|
42
|
+
/**
|
|
43
|
+
* Conjunction of all given predicates, simplified:
|
|
44
|
+
* nested `and`s are flattened, `TRUE` operands drop out, a single `FALSE`
|
|
45
|
+
* operand or a pair of `x` / `not x` collapses the whole conjunction, and
|
|
46
|
+
* duplicate operands are removed. An empty conjunction is `TRUE`.
|
|
47
|
+
*/
|
|
48
|
+
export declare function and(...predicates: Predicate[]): Predicate;
|
|
49
|
+
/**
|
|
50
|
+
* Disjunction of all given predicates, simplified the same way {@link and} is
|
|
51
|
+
* — with the roles of `TRUE` and `FALSE` swapped. An empty disjunction is
|
|
52
|
+
* `FALSE`.
|
|
53
|
+
*/
|
|
54
|
+
export declare function or(...predicates: Predicate[]): Predicate;
|
|
55
|
+
/** Negation, collapsing `not not x`, `not TRUE` and `not FALSE`. */
|
|
56
|
+
export declare function not(predicate: Predicate): Predicate;
|
|
57
|
+
/** True when the predicate is known to hold for every row. */
|
|
58
|
+
export declare function isTrue(predicate: Predicate): boolean;
|
|
59
|
+
/** True when the predicate is known to hold for no row. */
|
|
60
|
+
export declare function isFalse(predicate: Predicate): boolean;
|
|
61
|
+
/**
|
|
62
|
+
* A canonical string identifying the predicate, used to recognise duplicate
|
|
63
|
+
* and contradicting operands. Operands of `and` / `or` are sorted, so the key
|
|
64
|
+
* does not depend on the order they were passed in.
|
|
65
|
+
*/
|
|
66
|
+
export declare function predicateKey(predicate: Predicate): string;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/** The always-true predicate. Neutral in `and`, absorbing in `or`. */
|
|
2
|
+
export const TRUE = { kind: "true" };
|
|
3
|
+
/** The always-false predicate. Absorbing in `and`, neutral in `or`. */
|
|
4
|
+
export const FALSE = { kind: "false" };
|
|
5
|
+
/**
|
|
6
|
+
* Wraps a piece of SQL as a predicate. Prefer the constructors in
|
|
7
|
+
* `expression.ts`, which escape their operands.
|
|
8
|
+
*
|
|
9
|
+
* @param atomic - True for a single column or function call, which never
|
|
10
|
+
* needs parentheses around it.
|
|
11
|
+
*/
|
|
12
|
+
export function leaf(sql, atomic = false) {
|
|
13
|
+
return { atomic, kind: "leaf", sql };
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Conjunction of all given predicates, simplified:
|
|
17
|
+
* nested `and`s are flattened, `TRUE` operands drop out, a single `FALSE`
|
|
18
|
+
* operand or a pair of `x` / `not x` collapses the whole conjunction, and
|
|
19
|
+
* duplicate operands are removed. An empty conjunction is `TRUE`.
|
|
20
|
+
*/
|
|
21
|
+
export function and(...predicates) {
|
|
22
|
+
return combine("and", predicates);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Disjunction of all given predicates, simplified the same way {@link and} is
|
|
26
|
+
* — with the roles of `TRUE` and `FALSE` swapped. An empty disjunction is
|
|
27
|
+
* `FALSE`.
|
|
28
|
+
*/
|
|
29
|
+
export function or(...predicates) {
|
|
30
|
+
return combine("or", predicates);
|
|
31
|
+
}
|
|
32
|
+
/** Negation, collapsing `not not x`, `not TRUE` and `not FALSE`. */
|
|
33
|
+
export function not(predicate) {
|
|
34
|
+
if (predicate.kind === "true") {
|
|
35
|
+
return FALSE;
|
|
36
|
+
}
|
|
37
|
+
if (predicate.kind === "false") {
|
|
38
|
+
return TRUE;
|
|
39
|
+
}
|
|
40
|
+
if (predicate.kind === "not") {
|
|
41
|
+
return predicate.child;
|
|
42
|
+
}
|
|
43
|
+
return { child: predicate, kind: "not" };
|
|
44
|
+
}
|
|
45
|
+
/** True when the predicate is known to hold for every row. */
|
|
46
|
+
export function isTrue(predicate) {
|
|
47
|
+
return predicate.kind === "true";
|
|
48
|
+
}
|
|
49
|
+
/** True when the predicate is known to hold for no row. */
|
|
50
|
+
export function isFalse(predicate) {
|
|
51
|
+
return predicate.kind === "false";
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* A canonical string identifying the predicate, used to recognise duplicate
|
|
55
|
+
* and contradicting operands. Operands of `and` / `or` are sorted, so the key
|
|
56
|
+
* does not depend on the order they were passed in.
|
|
57
|
+
*/
|
|
58
|
+
export function predicateKey(predicate) {
|
|
59
|
+
switch (predicate.kind) {
|
|
60
|
+
case "and":
|
|
61
|
+
case "or": {
|
|
62
|
+
const keys = predicate.children.map((child) => predicateKey(child));
|
|
63
|
+
return `${predicate.kind}(${[...keys].sort().join(",")})`;
|
|
64
|
+
}
|
|
65
|
+
case "false": {
|
|
66
|
+
return "false";
|
|
67
|
+
}
|
|
68
|
+
case "leaf": {
|
|
69
|
+
return `leaf(${predicate.sql})`;
|
|
70
|
+
}
|
|
71
|
+
case "not": {
|
|
72
|
+
return `not(${predicateKey(predicate.child)})`;
|
|
73
|
+
}
|
|
74
|
+
case "true": {
|
|
75
|
+
return "true";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function combine(kind, predicates) {
|
|
80
|
+
// The operand that swallows the whole expression: `false` in a conjunction,
|
|
81
|
+
// `true` in a disjunction. The other one is neutral and can be dropped.
|
|
82
|
+
const absorbing = kind === "and" ? "false" : "true";
|
|
83
|
+
const neutral = kind === "and" ? "true" : "false";
|
|
84
|
+
const children = [];
|
|
85
|
+
const keys = new Set();
|
|
86
|
+
for (const predicate of flatten(kind, predicates)) {
|
|
87
|
+
if (predicate.kind === absorbing) {
|
|
88
|
+
return absorbing === "false" ? FALSE : TRUE;
|
|
89
|
+
}
|
|
90
|
+
if (predicate.kind === neutral) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const key = predicateKey(predicate);
|
|
94
|
+
if (keys.has(key)) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
keys.add(key);
|
|
98
|
+
children.push(predicate);
|
|
99
|
+
}
|
|
100
|
+
// `x and not x` is never true, `x or not x` is always true.
|
|
101
|
+
for (const child of children) {
|
|
102
|
+
if (child.kind === "not" && keys.has(predicateKey(child.child))) {
|
|
103
|
+
return absorbing === "false" ? FALSE : TRUE;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (children.length === 0) {
|
|
107
|
+
return neutral === "true" ? TRUE : FALSE;
|
|
108
|
+
}
|
|
109
|
+
if (children.length === 1) {
|
|
110
|
+
return children[0];
|
|
111
|
+
}
|
|
112
|
+
return { children, kind };
|
|
113
|
+
}
|
|
114
|
+
function flatten(kind, predicates) {
|
|
115
|
+
const flattened = [];
|
|
116
|
+
for (const predicate of predicates) {
|
|
117
|
+
if (predicate.kind === kind) {
|
|
118
|
+
flattened.push(...flatten(kind, [...predicate.children]));
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
flattened.push(predicate);
|
|
122
|
+
}
|
|
123
|
+
return flattened;
|
|
124
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface AthenaRegex {
|
|
2
|
+
/** The pattern to hand to `regexp_like`, without delimiters. */
|
|
3
|
+
readonly pattern: string;
|
|
4
|
+
/**
|
|
5
|
+
* Constructs whose meaning may differ between the browser and Athena. They
|
|
6
|
+
* do not stop the translation — the filter is emitted and the warning is
|
|
7
|
+
* reported, so a reviewer can check that one rule instead of the query.
|
|
8
|
+
*/
|
|
9
|
+
readonly warnings: readonly string[];
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Translates a JavaScript regular expression from a Speed Kit config into a
|
|
13
|
+
* pattern for Athena's `regexp_like`.
|
|
14
|
+
*
|
|
15
|
+
* Athena evaluates regular expressions with Joni in its Java syntax, which
|
|
16
|
+
* agrees with JavaScript on everything these configs use — character classes,
|
|
17
|
+
* groups, quantifiers, anchors, lookahead. It differs in three ways that
|
|
18
|
+
* matter here: flags have to travel inside the pattern as an inline group,
|
|
19
|
+
* `\/` is a JavaScript-source artefact that Joni would reject, and a handful
|
|
20
|
+
* of constructs are unsupported or subtly different. The first two are fixed,
|
|
21
|
+
* the third is reported.
|
|
22
|
+
*/
|
|
23
|
+
export declare function toAthenaRegex(regExp: RegExp): AthenaRegex;
|
|
24
|
+
/**
|
|
25
|
+
* Escapes a plain string so it can be embedded in a regular expression as a
|
|
26
|
+
* literal, e.g. a query parameter name inside an alternation.
|
|
27
|
+
*/
|
|
28
|
+
export declare function escapeRegexLiteral(value: string): string;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Translates a JavaScript regular expression from a Speed Kit config into a
|
|
3
|
+
* pattern for Athena's `regexp_like`.
|
|
4
|
+
*
|
|
5
|
+
* Athena evaluates regular expressions with Joni in its Java syntax, which
|
|
6
|
+
* agrees with JavaScript on everything these configs use — character classes,
|
|
7
|
+
* groups, quantifiers, anchors, lookahead. It differs in three ways that
|
|
8
|
+
* matter here: flags have to travel inside the pattern as an inline group,
|
|
9
|
+
* `\/` is a JavaScript-source artefact that Joni would reject, and a handful
|
|
10
|
+
* of constructs are unsupported or subtly different. The first two are fixed,
|
|
11
|
+
* the third is reported.
|
|
12
|
+
*/
|
|
13
|
+
export function toAthenaRegex(regExp) {
|
|
14
|
+
const { pattern, warnings } = translate(regExp.source, String(regExp));
|
|
15
|
+
const flags = [
|
|
16
|
+
regExp.ignoreCase ? "i" : "",
|
|
17
|
+
regExp.multiline ? "m" : "",
|
|
18
|
+
regExp.dotAll ? "s" : "",
|
|
19
|
+
].join("");
|
|
20
|
+
// The v flag predates the lib this project compiles against, so read it
|
|
21
|
+
// off the flag string rather than the RegExp instance.
|
|
22
|
+
if (regExp.flags.includes("v")) {
|
|
23
|
+
warnings.push(`${regExp}: the v flag has no equivalent in Athena — class set operations will not be applied`);
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
pattern: flags ? `(?${flags})${pattern}` : pattern,
|
|
27
|
+
warnings,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Escapes a plain string so it can be embedded in a regular expression as a
|
|
32
|
+
* literal, e.g. a query parameter name inside an alternation.
|
|
33
|
+
*/
|
|
34
|
+
export function escapeRegexLiteral(value) {
|
|
35
|
+
return value.replaceAll(/[$()*+.?[\\\]^{|}]/g, "\\$&");
|
|
36
|
+
}
|
|
37
|
+
function translate(source, label) {
|
|
38
|
+
const characters = [];
|
|
39
|
+
const warnings = new Set();
|
|
40
|
+
let inCharacterClass = false;
|
|
41
|
+
for (let index = 0; index < source.length; index++) {
|
|
42
|
+
const character = source[index];
|
|
43
|
+
if (character === "\\" && index + 1 < source.length) {
|
|
44
|
+
const escaped = source[index + 1];
|
|
45
|
+
// `\/` only exists because the pattern was written as a literal between
|
|
46
|
+
// slashes. Joni treats an unknown escape as an error, so unescape it.
|
|
47
|
+
if (escaped === "/") {
|
|
48
|
+
characters.push("/");
|
|
49
|
+
index++;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (!inCharacterClass && /[1-9]/.test(escaped)) {
|
|
53
|
+
warnings.add(`${label}: back references are not supported by Athena`);
|
|
54
|
+
}
|
|
55
|
+
if (escaped === "k") {
|
|
56
|
+
warnings.add(`${label}: back references are not supported by Athena`);
|
|
57
|
+
}
|
|
58
|
+
if (escaped === "u" && source[index + 2] === "{") {
|
|
59
|
+
warnings.add(`${label}: code point escapes (\\u{…}) are not supported by Athena`);
|
|
60
|
+
}
|
|
61
|
+
if (escaped === "p" || escaped === "P") {
|
|
62
|
+
warnings.add(`${label}: unicode property escapes use different property names in Athena`);
|
|
63
|
+
}
|
|
64
|
+
characters.push(character, escaped);
|
|
65
|
+
index++;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (character === "[" && !inCharacterClass) {
|
|
69
|
+
inCharacterClass = true;
|
|
70
|
+
}
|
|
71
|
+
else if (character === "]" && inCharacterClass) {
|
|
72
|
+
inCharacterClass = false;
|
|
73
|
+
}
|
|
74
|
+
else if (!inCharacterClass &&
|
|
75
|
+
(source.startsWith("(?<=", index) || source.startsWith("(?<!", index))) {
|
|
76
|
+
warnings.add(`${label}: lookbehind is only partially supported by Athena — verify this filter`);
|
|
77
|
+
}
|
|
78
|
+
characters.push(character);
|
|
79
|
+
}
|
|
80
|
+
return { pattern: characters.join(""), warnings: [...warnings] };
|
|
81
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { expect } from "chai";
|
|
2
|
+
import { describe, it } from "mocha";
|
|
3
|
+
import { booleanColumn, call, column, concat, equals, equalsIgnoreCase, inValues, literal, lower, notEquals, regexpLike, startsWith, } from "../expression.js";
|
|
4
|
+
import { formatPredicate } from "../format.js";
|
|
5
|
+
describe("literal", () => {
|
|
6
|
+
it("should quote a string", () => {
|
|
7
|
+
expect(literal("abc")).to.equal("'abc'");
|
|
8
|
+
});
|
|
9
|
+
it("should double a quote inside a string", () => {
|
|
10
|
+
expect(literal("it's")).to.equal("'it''s'");
|
|
11
|
+
});
|
|
12
|
+
it("should print numbers and booleans unquoted", () => {
|
|
13
|
+
expect(literal(42)).to.equal("42");
|
|
14
|
+
expect(literal(true)).to.equal("true");
|
|
15
|
+
});
|
|
16
|
+
it("should reject a non-finite number", () => {
|
|
17
|
+
expect(() => literal(Number.NaN)).to.throw();
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
describe("column", () => {
|
|
21
|
+
it("should accept a qualified name", () => {
|
|
22
|
+
expect(column("cdnclient.bot")).to.equal("cdnclient.bot");
|
|
23
|
+
});
|
|
24
|
+
it("should reject anything that is not an identifier", () => {
|
|
25
|
+
expect(() => column("url'; drop")).to.throw();
|
|
26
|
+
expect(() => column("1url")).to.throw();
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
describe("call", () => {
|
|
30
|
+
it("should join the arguments", () => {
|
|
31
|
+
expect(call("url_extract_path", column("url"))).to.equal("url_extract_path(url)");
|
|
32
|
+
});
|
|
33
|
+
it("should reject an invalid function name", () => {
|
|
34
|
+
expect(() => call("drop table x --")).to.throw();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
describe("expression comparisons", () => {
|
|
38
|
+
it("should build an equality", () => {
|
|
39
|
+
expect(formatPredicate(equals(column("app"), literal("shop-de")))).to.equal("app = 'shop-de'");
|
|
40
|
+
});
|
|
41
|
+
it("should build an inequality", () => {
|
|
42
|
+
expect(formatPredicate(notEquals(column("device"), literal("Robot")))).to.equal("device <> 'Robot'");
|
|
43
|
+
});
|
|
44
|
+
it("should compare case-insensitively through lower", () => {
|
|
45
|
+
expect(formatPredicate(equalsIgnoreCase(column("url"), "https://Example.com/"))).to.equal("lower(url) = 'https://example.com/'");
|
|
46
|
+
});
|
|
47
|
+
it("should build an in list", () => {
|
|
48
|
+
expect(formatPredicate(inValues(column("navigationtype"), ["NAVIGATE", "RELOAD"]))).to.equal("navigationtype in ('NAVIGATE', 'RELOAD')");
|
|
49
|
+
});
|
|
50
|
+
it("should build a regexp_like", () => {
|
|
51
|
+
expect(formatPredicate(regexpLike(column("url"), "(?i)^/products/"))).to.equal("regexp_like(url, '(?i)^/products/')");
|
|
52
|
+
});
|
|
53
|
+
it("should use a boolean column directly", () => {
|
|
54
|
+
expect(formatPredicate(booleanColumn("suspicious"))).to.equal("suspicious");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
describe("startsWith", () => {
|
|
58
|
+
it("should lower-case both sides by default", () => {
|
|
59
|
+
expect(formatPredicate(startsWith(column("url"), "/Products/"))).to.equal("lower(url) like '/products/%'");
|
|
60
|
+
});
|
|
61
|
+
it("should keep the case when asked to", () => {
|
|
62
|
+
expect(formatPredicate(startsWith(column("url"), "/Products/", { ignoreCase: false }))).to.equal("url like '/Products/%'");
|
|
63
|
+
});
|
|
64
|
+
it("should escape like metacharacters and declare the escape character", () => {
|
|
65
|
+
expect(formatPredicate(startsWith(column("url"), "/a_b"))).to.equal(String.raw `lower(url) like '/a\_b%' escape '\'`);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
describe("lower and concat", () => {
|
|
69
|
+
it("should wrap a concatenation in parentheses", () => {
|
|
70
|
+
expect(concat(column("a"), column("b"))).to.equal("(a || b)");
|
|
71
|
+
});
|
|
72
|
+
it("should lower an expression", () => {
|
|
73
|
+
expect(lower(column("url"))).to.equal("lower(url)");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { expect } from "chai";
|
|
2
|
+
import { describe, it } from "mocha";
|
|
3
|
+
import { formatPredicate } from "../format.js";
|
|
4
|
+
import { and, FALSE, leaf, not, or, TRUE } from "../predicate.js";
|
|
5
|
+
const a = leaf("a = 1");
|
|
6
|
+
const b = leaf("b = 2");
|
|
7
|
+
const c = leaf("c = 3");
|
|
8
|
+
describe("formatPredicate", () => {
|
|
9
|
+
it("should print a leaf as it is", () => {
|
|
10
|
+
expect(formatPredicate(a)).to.equal("a = 1");
|
|
11
|
+
});
|
|
12
|
+
it("should print the constants", () => {
|
|
13
|
+
expect(formatPredicate(TRUE)).to.equal("true");
|
|
14
|
+
expect(formatPredicate(FALSE)).to.equal("false");
|
|
15
|
+
});
|
|
16
|
+
it("should put every operand on its own line", () => {
|
|
17
|
+
expect(formatPredicate(and(a, b))).to.equal("a = 1\nand b = 2");
|
|
18
|
+
});
|
|
19
|
+
it("should parenthesise and indent a nested group", () => {
|
|
20
|
+
expect(formatPredicate(and(a, or(b, c)))).to.equal(["a = 1", "and (", " b = 2", " or c = 3", ")"].join("\n"));
|
|
21
|
+
});
|
|
22
|
+
it("should indent relative to the given level", () => {
|
|
23
|
+
expect(formatPredicate(and(a, b), { level: 1 })).to.equal("a = 1\n and b = 2");
|
|
24
|
+
});
|
|
25
|
+
it("should parenthesise a negated comparison", () => {
|
|
26
|
+
expect(formatPredicate(not(a))).to.equal("not (a = 1)");
|
|
27
|
+
});
|
|
28
|
+
it("should not parenthesise a negated call", () => {
|
|
29
|
+
expect(formatPredicate(not(leaf("suspicious", true)))).to.equal("not suspicious");
|
|
30
|
+
});
|
|
31
|
+
it("should not parenthesise a comparison that is not negated", () => {
|
|
32
|
+
expect(formatPredicate(and(a, b))).to.equal("a = 1\nand b = 2");
|
|
33
|
+
});
|
|
34
|
+
it("should parenthesise a negated group", () => {
|
|
35
|
+
expect(formatPredicate(not(or(a, b)))).to.equal(["not (", " a = 1", " or b = 2", ")"].join("\n"));
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|