@speedkit/cli 4.26.0 → 4.27.1
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 +17 -0
- package/README.md +203 -15
- package/dist/commands/auto-prewarm.d.ts +12 -1
- package/dist/commands/auto-prewarm.js +26 -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 +27 -0
- package/dist/commands/query/parameter.js +28 -0
- package/dist/commands/query/prewarm.d.ts +32 -0
- package/dist/commands/query/prewarm.js +32 -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 +106 -0
- package/dist/models/cli-parameters.js +183 -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-factory.d.ts +4 -1
- package/dist/services/athena/athena-service-factory.js +9 -3
- package/dist/services/athena/athena-service.d.ts +13 -1
- package/dist/services/athena/athena-service.js +27 -5
- package/dist/services/athena/athena-service.spec.d.ts +1 -0
- package/dist/services/athena/athena-service.spec.js +93 -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 +20 -2
- package/dist/services/prewarm/auto-pre-warm-factory.js +24 -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 +36 -8
- package/dist/services/query-builder/query-builder-model.d.ts +33 -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 +27 -0
- package/dist/services/query-builder/query-command.js +36 -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 +401 -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,55 @@
|
|
|
1
|
+
import { PathnameRule, testCondition } from "@speedkit/rules";
|
|
2
|
+
import { expect } from "chai";
|
|
3
|
+
import { before, describe, it } from "mocha";
|
|
4
|
+
import { matchesExactly } from "../rule-model.js";
|
|
5
|
+
/**
|
|
6
|
+
* The query builder mirrors decisions that `@speedkit/rules` makes in the
|
|
7
|
+
* browser. It cannot call into the package to build SQL, so these tests pin
|
|
8
|
+
* the mirrored behaviour against the package itself — if the runtime ever
|
|
9
|
+
* changes how a condition or a subject is read, they fail here rather than in
|
|
10
|
+
* a query nobody re-reads.
|
|
11
|
+
*/
|
|
12
|
+
const VERSIONS = [1, 2, 3];
|
|
13
|
+
describe("string conditions follow the runtime", () => {
|
|
14
|
+
for (const version of VERSIONS) {
|
|
15
|
+
it(`should match exactly only where the runtime does, version ${version}`, () => {
|
|
16
|
+
// A subject the condition is a prefix of, but not equal to.
|
|
17
|
+
const runtimeMatchesPrefix = testCondition("/shoes/nike", "/shoes", version);
|
|
18
|
+
const runtimeMatchesExact = testCondition("/shoes", "/shoes", version);
|
|
19
|
+
expect(runtimeMatchesExact).to.equal(true);
|
|
20
|
+
expect(runtimeMatchesPrefix).to.equal(!matchesExactly(version));
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
it("should ignore case on both sides", () => {
|
|
24
|
+
expect(testCondition("/Shoes", "/shOES", 1)).to.equal(true);
|
|
25
|
+
expect(testCondition("/Shoes", "/shOES", 2)).to.equal(true);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
describe("the pathname subject follows the runtime", () => {
|
|
29
|
+
// The package resolves a request URL against `location`, which only exists
|
|
30
|
+
// in a browser. Anything that evaluates a rule outside one has to provide it.
|
|
31
|
+
before(() => {
|
|
32
|
+
const global = globalThis;
|
|
33
|
+
global.location ??= new URL("https://www.shop.de/");
|
|
34
|
+
});
|
|
35
|
+
function matchesPathname(pattern, ruleSetVersion) {
|
|
36
|
+
return new PathnameRule(pattern).matches(new Request("https://www.shop.de/shoes/?page=2"), { ruleSetVersion });
|
|
37
|
+
}
|
|
38
|
+
it("should test a version 1 pattern against path and query", () => {
|
|
39
|
+
// The subject ends in `2`, not in the slash, so the anchored pattern
|
|
40
|
+
// misses a URL a reader would expect it to cover.
|
|
41
|
+
expect(matchesPathname(/\/$/, 1)).to.equal(false);
|
|
42
|
+
});
|
|
43
|
+
it("should test a version 2 pattern against the path alone", () => {
|
|
44
|
+
expect(matchesPathname(/\/$/, 2)).to.equal(true);
|
|
45
|
+
});
|
|
46
|
+
it("should fall back to the version 1 subject for any other version", () => {
|
|
47
|
+
expect(matchesPathname(/\/$/, 3)).to.equal(false);
|
|
48
|
+
expect(matchesExactly(3)).to.equal(false);
|
|
49
|
+
});
|
|
50
|
+
it("should match a pattern that survives both versions", () => {
|
|
51
|
+
const bothVersions = /^[^?]*\/(\?|$)/;
|
|
52
|
+
expect(matchesPathname(bothVersions, 1)).to.equal(true);
|
|
53
|
+
expect(matchesPathname(bothVersions, 2)).to.equal(true);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { expect } from "chai";
|
|
2
|
+
import { describe, it } from "mocha";
|
|
3
|
+
import { column, formatPredicate } from "../../sql/index.js";
|
|
4
|
+
import { parseRuleSets } from "../rule-parser.js";
|
|
5
|
+
import { urlSubjects } from "../rule-to-sql.js";
|
|
6
|
+
import { notStrippedParameter, prepareStripParameters, strippedUrlExpression, } from "../strip-parameters.js";
|
|
7
|
+
const subjects = urlSubjects(column("url"));
|
|
8
|
+
function prepare(stripQueryParams, version = 2) {
|
|
9
|
+
return prepareStripParameters(parseRuleSets({ ruleSetConfig: { version }, stripQueryParams })
|
|
10
|
+
.stripQueryParams, subjects);
|
|
11
|
+
}
|
|
12
|
+
function strippedUrl(stripQueryParams, version = 2) {
|
|
13
|
+
return strippedUrlExpression(prepare(stripQueryParams, version), "url");
|
|
14
|
+
}
|
|
15
|
+
function keptParameter(stripQueryParams, version = 2) {
|
|
16
|
+
return formatPredicate(notStrippedParameter(prepare(stripQueryParams, version), "key"));
|
|
17
|
+
}
|
|
18
|
+
describe("strippedUrlExpression", () => {
|
|
19
|
+
it("should always remove the fragment", () => {
|
|
20
|
+
expect(strippedUrl([])).to.equal("regexp_replace(url, '#.*')");
|
|
21
|
+
});
|
|
22
|
+
it("should match a name exactly under version 2", () => {
|
|
23
|
+
expect(strippedUrl(["gclid"])).to.contain("(?i)[?&](gclid)(=[^&]*)?(?=&|$)");
|
|
24
|
+
});
|
|
25
|
+
it("should match a name as a prefix under version 1", () => {
|
|
26
|
+
expect(strippedUrl(["utm"], 1)).to.contain("(?i)[?&](utm)[^&]*");
|
|
27
|
+
});
|
|
28
|
+
it("should escape a name that reads as a pattern", () => {
|
|
29
|
+
expect(strippedUrl(["a.b"])).to.contain(String.raw `(a\.b)`);
|
|
30
|
+
});
|
|
31
|
+
it("should use the prefix form for a pattern anchored at the start", () => {
|
|
32
|
+
expect(strippedUrl([/^utm[_-]/i])).to.contain("(?i)[?&](utm[_-])[^&]*");
|
|
33
|
+
});
|
|
34
|
+
it("should use the suffix form for a pattern anchored at the end", () => {
|
|
35
|
+
expect(strippedUrl([/id$/i])).to.contain("(?i)[?&][^&]*(id)(=[^&]*)?(?=&|$)");
|
|
36
|
+
});
|
|
37
|
+
it("should keep case-sensitive patterns in their own expression", () => {
|
|
38
|
+
const expression = strippedUrl([/^utm/, /^UTM/i]);
|
|
39
|
+
expect(expression).to.contain("'[?&](utm)[^&]*'");
|
|
40
|
+
expect(expression).to.contain("'(?i)[?&](UTM)[^&]*'");
|
|
41
|
+
});
|
|
42
|
+
it("should merge rules that strip everywhere into one expression", () => {
|
|
43
|
+
const merged = strippedUrl([{ params: ["a"] }, { params: ["b"] }]);
|
|
44
|
+
expect(merged.match(/regexp_replace/g)).to.have.lengthOf(2);
|
|
45
|
+
expect(merged).to.contain("(a|b)");
|
|
46
|
+
});
|
|
47
|
+
it("should apply a scoped rule only on the pages it names", () => {
|
|
48
|
+
const expression = strippedUrl([
|
|
49
|
+
{ enabledSites: [{ pathname: [/^\/p\//i] }], params: ["size"] },
|
|
50
|
+
]);
|
|
51
|
+
expect(expression).to.contain("if(regexp_like(");
|
|
52
|
+
expect(expression).to.contain("(?i)^/p/");
|
|
53
|
+
});
|
|
54
|
+
it("should leave out a rule scoped to something that is never a document", () => {
|
|
55
|
+
const prepared = prepare([
|
|
56
|
+
{ params: ["size"], rules: [{ contentType: ["image"] }] },
|
|
57
|
+
]);
|
|
58
|
+
expect(prepared.groups).to.deep.equal([]);
|
|
59
|
+
expect(prepared.dropped[0].source).to.equal("stripQueryParams[0]");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
describe("notStrippedParameter", () => {
|
|
63
|
+
it("should keep every parameter when nothing is stripped", () => {
|
|
64
|
+
expect(keptParameter([])).to.equal("true");
|
|
65
|
+
});
|
|
66
|
+
it("should reject a stripped name exactly under version 2", () => {
|
|
67
|
+
expect(keptParameter(["gclid"])).to.equal("not (lower(key) = 'gclid')");
|
|
68
|
+
});
|
|
69
|
+
it("should reject a stripped name as a prefix under version 1", () => {
|
|
70
|
+
expect(keptParameter(["utm"], 1)).to.equal("not (lower(key) like 'utm%')");
|
|
71
|
+
});
|
|
72
|
+
it("should reject every stripped name", () => {
|
|
73
|
+
expect(keptParameter(["a", "b"])).to.equal("not (lower(key) = 'a')\nand not (lower(key) = 'b')");
|
|
74
|
+
});
|
|
75
|
+
it("should reject a scoped name only on its own pages", () => {
|
|
76
|
+
expect(keptParameter([
|
|
77
|
+
{ enabledSites: [{ pathname: [/^\/p\//i] }], params: ["size"] },
|
|
78
|
+
])).to.equal([
|
|
79
|
+
"not (",
|
|
80
|
+
" regexp_like(coalesce(nullif(url_extract_path(url), ''), '/'), '(?i)^/p/')",
|
|
81
|
+
" and lower(key) = 'size'",
|
|
82
|
+
")",
|
|
83
|
+
].join("\n"));
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Predicate, SqlExpression } from "../sql/index.js";
|
|
2
|
+
import { ConditionPattern, StripParameterRule } from "./rule-model.js";
|
|
3
|
+
import { RelevanceContext, RuleNote } from "./rule-relevance.js";
|
|
4
|
+
import { RuleSubjectExpressions } from "./rule-to-sql.js";
|
|
5
|
+
/** Parameters that are stripped on the pages a scope predicate selects. */
|
|
6
|
+
export interface StripParameterGroup {
|
|
7
|
+
readonly params: readonly ConditionPattern[];
|
|
8
|
+
readonly ruleSetVersion: number;
|
|
9
|
+
/** `TRUE` when the parameters are stripped everywhere. */
|
|
10
|
+
readonly scope: Predicate;
|
|
11
|
+
readonly sources: string[];
|
|
12
|
+
}
|
|
13
|
+
export interface PreparedStripParameters {
|
|
14
|
+
/** Strip rules that cannot apply to a page impression. */
|
|
15
|
+
readonly dropped: RuleNote[];
|
|
16
|
+
readonly groups: StripParameterGroup[];
|
|
17
|
+
readonly warnings: string[];
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Turns the `stripQueryParams` rules into the groups the query needs.
|
|
21
|
+
*
|
|
22
|
+
* Rules that strip everywhere are merged into a single group so their
|
|
23
|
+
* parameters share one set of expressions, which is what the emitted query
|
|
24
|
+
* looked like before scoping was understood. A rule limited to certain pages
|
|
25
|
+
* or resources keeps its own group and carries the predicate that selects
|
|
26
|
+
* them; one limited to resources that are never documents is dropped.
|
|
27
|
+
*/
|
|
28
|
+
export declare function prepareStripParameters(rules: readonly StripParameterRule[], subjects: RuleSubjectExpressions, context?: RelevanceContext): PreparedStripParameters;
|
|
29
|
+
/**
|
|
30
|
+
* The URL with every stripped parameter and the fragment removed — the cache
|
|
31
|
+
* key Speed Kit would use. Built as nested `regexp_replace` calls, one per
|
|
32
|
+
* kind of parameter pattern.
|
|
33
|
+
*/
|
|
34
|
+
export declare function strippedUrlExpression(prepared: PreparedStripParameters, url: SqlExpression, warnings?: string[]): SqlExpression;
|
|
35
|
+
/**
|
|
36
|
+
* Holds for a parameter name that Speed Kit keeps — the filter the parameter
|
|
37
|
+
* analysis applies to every key of a URL.
|
|
38
|
+
*/
|
|
39
|
+
export declare function notStrippedParameter(prepared: PreparedStripParameters, key: SqlExpression, warnings?: string[]): Predicate;
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { and, call, equalsIgnoreCase, escapeRegexLiteral, formatPredicate, isTrue, literal, not, or, regexpLike, startsWith, toAthenaRegex, TRUE, } from "../sql/index.js";
|
|
2
|
+
import { matchesExactly, } from "./rule-model.js";
|
|
3
|
+
import { keepRelevantRules, } from "./rule-relevance.js";
|
|
4
|
+
import { translateRules } from "./rule-to-sql.js";
|
|
5
|
+
/**
|
|
6
|
+
* Turns the `stripQueryParams` rules into the groups the query needs.
|
|
7
|
+
*
|
|
8
|
+
* Rules that strip everywhere are merged into a single group so their
|
|
9
|
+
* parameters share one set of expressions, which is what the emitted query
|
|
10
|
+
* looked like before scoping was understood. A rule limited to certain pages
|
|
11
|
+
* or resources keeps its own group and carries the predicate that selects
|
|
12
|
+
* them; one limited to resources that are never documents is dropped.
|
|
13
|
+
*/
|
|
14
|
+
export function prepareStripParameters(rules, subjects, context = {}) {
|
|
15
|
+
const dropped = [];
|
|
16
|
+
const warnings = [];
|
|
17
|
+
const groups = [];
|
|
18
|
+
// Groups the rules stripping everywhere are merged into, one per version.
|
|
19
|
+
const unscoped = new Map();
|
|
20
|
+
for (const rule of rules) {
|
|
21
|
+
const page = scopeOf(rule.enabledSites, subjects, context, warnings);
|
|
22
|
+
const resource = scopeOf(rule.rules, subjects, context, warnings);
|
|
23
|
+
if (!page || !resource) {
|
|
24
|
+
dropped.push({
|
|
25
|
+
reason: "strips parameters only on pages or resources a page impression can never be",
|
|
26
|
+
source: rule.source,
|
|
27
|
+
});
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const scope = and(page, resource);
|
|
31
|
+
const merged = isTrue(scope)
|
|
32
|
+
? unscoped.get(rule.ruleSetVersion)
|
|
33
|
+
: undefined;
|
|
34
|
+
if (merged) {
|
|
35
|
+
merged.params.push(...rule.params);
|
|
36
|
+
merged.sources.push(rule.source);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
// The group holds the very arrays the map keeps, so a later rule that
|
|
40
|
+
// strips everywhere too extends this group instead of adding another.
|
|
41
|
+
const group = {
|
|
42
|
+
params: [...rule.params],
|
|
43
|
+
ruleSetVersion: rule.ruleSetVersion,
|
|
44
|
+
scope,
|
|
45
|
+
sources: [rule.source],
|
|
46
|
+
};
|
|
47
|
+
groups.push(group);
|
|
48
|
+
if (isTrue(scope)) {
|
|
49
|
+
unscoped.set(rule.ruleSetVersion, group);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { dropped, groups, warnings };
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The URL with every stripped parameter and the fragment removed — the cache
|
|
56
|
+
* key Speed Kit would use. Built as nested `regexp_replace` calls, one per
|
|
57
|
+
* kind of parameter pattern.
|
|
58
|
+
*/
|
|
59
|
+
export function strippedUrlExpression(prepared, url, warnings = []) {
|
|
60
|
+
let expression = call("regexp_replace", url, literal("#.*"));
|
|
61
|
+
for (const group of prepared.groups) {
|
|
62
|
+
const replaced = replaceParameters(group, expression, warnings);
|
|
63
|
+
expression = isTrue(group.scope)
|
|
64
|
+
? replaced
|
|
65
|
+
: // Commas separate the arguments, and `and` binds tighter, so the
|
|
66
|
+
// predicate needs no parentheses of its own here.
|
|
67
|
+
call("if", formatPredicate(group.scope), replaced, expression);
|
|
68
|
+
}
|
|
69
|
+
return expression;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Holds for a parameter name that Speed Kit keeps — the filter the parameter
|
|
73
|
+
* analysis applies to every key of a URL.
|
|
74
|
+
*/
|
|
75
|
+
export function notStrippedParameter(prepared, key, warnings = []) {
|
|
76
|
+
return and(...prepared.groups.map((group) => {
|
|
77
|
+
const matches = group.params.map((pattern) => matchesParameter(pattern, key, group.ruleSetVersion, warnings));
|
|
78
|
+
return isTrue(group.scope)
|
|
79
|
+
? and(...matches.map((match) => not(match)))
|
|
80
|
+
: not(and(group.scope, or(...matches)));
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The predicate selecting the pages or resources a strip rule is scoped to,
|
|
85
|
+
* or `undefined` when the scope was given but can never hold for a document.
|
|
86
|
+
*/
|
|
87
|
+
function scopeOf(rules, subjects, context, warnings) {
|
|
88
|
+
if (!rules || rules.length === 0) {
|
|
89
|
+
return TRUE;
|
|
90
|
+
}
|
|
91
|
+
const relevant = keepRelevantRules(rules, context);
|
|
92
|
+
if (relevant.rules.length === 0) {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
const { predicate, warnings: ruleWarnings } = translateRules(relevant.rules, subjects);
|
|
96
|
+
warnings.push(...ruleWarnings);
|
|
97
|
+
return predicate;
|
|
98
|
+
}
|
|
99
|
+
function matchesParameter(pattern, key, ruleSetVersion, warnings) {
|
|
100
|
+
if (pattern.kind === "regexp") {
|
|
101
|
+
const athenaRegex = toAthenaRegex(pattern.value);
|
|
102
|
+
warnings.push(...athenaRegex.warnings.map((warning) => `stripQueryParams: ${warning}`));
|
|
103
|
+
return regexpLike(key, athenaRegex.pattern);
|
|
104
|
+
}
|
|
105
|
+
// A parameter name is a string condition: case-insensitive, and a prefix
|
|
106
|
+
// until the config moves to rule-set version 2.
|
|
107
|
+
return matchesExactly(ruleSetVersion)
|
|
108
|
+
? equalsIgnoreCase(key, pattern.value)
|
|
109
|
+
: startsWith(key, pattern.value);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Nests one `regexp_replace` per pattern kind around the given expression.
|
|
113
|
+
*
|
|
114
|
+
* A parameter is removed together with its value, and where in the name the
|
|
115
|
+
* pattern has to match depends on how it is anchored: `/^utm/` covers every
|
|
116
|
+
* name starting with `utm`, `/id$/` every name ending in it, a plain name
|
|
117
|
+
* matches the whole key. Case-insensitive patterns need their own expression
|
|
118
|
+
* because the flag applies to the whole alternation.
|
|
119
|
+
*/
|
|
120
|
+
function replaceParameters(group, expression, warnings) {
|
|
121
|
+
const buckets = bucketParameters(group, warnings);
|
|
122
|
+
const patterns = [
|
|
123
|
+
prefixPattern(buckets.prefix, false),
|
|
124
|
+
prefixPattern(buckets.prefixIgnoreCase, true),
|
|
125
|
+
suffixPattern(buckets.suffix, false),
|
|
126
|
+
suffixPattern(buckets.suffixIgnoreCase, true),
|
|
127
|
+
fullPattern(buckets.full, false),
|
|
128
|
+
fullPattern(buckets.fullIgnoreCase, true),
|
|
129
|
+
];
|
|
130
|
+
let replaced = expression;
|
|
131
|
+
for (const pattern of patterns) {
|
|
132
|
+
if (pattern) {
|
|
133
|
+
replaced = call("regexp_replace", replaced, literal(pattern), literal(""));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return replaced;
|
|
137
|
+
}
|
|
138
|
+
function bucketParameters(group, warnings) {
|
|
139
|
+
const buckets = {
|
|
140
|
+
full: [],
|
|
141
|
+
fullIgnoreCase: [],
|
|
142
|
+
prefix: [],
|
|
143
|
+
prefixIgnoreCase: [],
|
|
144
|
+
suffix: [],
|
|
145
|
+
suffixIgnoreCase: [],
|
|
146
|
+
};
|
|
147
|
+
for (const pattern of group.params) {
|
|
148
|
+
if (pattern.kind === "string") {
|
|
149
|
+
// A string is matched case-insensitively, so it belongs to the
|
|
150
|
+
// ignore-case bucket, and version 1 treats it as a prefix.
|
|
151
|
+
const name = escapeRegexLiteral(pattern.value);
|
|
152
|
+
if (matchesExactly(group.ruleSetVersion)) {
|
|
153
|
+
buckets.fullIgnoreCase.push(name);
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
buckets.prefixIgnoreCase.push(name);
|
|
157
|
+
}
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const { pattern: athenaPattern, warnings: patternWarnings } = toAthenaRegex(stripFlags(pattern.value));
|
|
161
|
+
warnings.push(...patternWarnings.map((warning) => `stripQueryParams: ${warning}`));
|
|
162
|
+
const ignoreCase = pattern.value.ignoreCase;
|
|
163
|
+
const anchoredStart = athenaPattern.startsWith("^");
|
|
164
|
+
const anchoredEnd = /(?<!\\)\$$/.test(athenaPattern);
|
|
165
|
+
const inner = athenaPattern.slice(anchoredStart ? 1 : 0, anchoredEnd ? -1 : undefined);
|
|
166
|
+
if (anchoredStart && !anchoredEnd) {
|
|
167
|
+
(ignoreCase ? buckets.prefixIgnoreCase : buckets.prefix).push(inner);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (anchoredEnd && !anchoredStart) {
|
|
171
|
+
(ignoreCase ? buckets.suffixIgnoreCase : buckets.suffix).push(inner);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
// Anchored on both sides, or on neither: both mean the whole name.
|
|
175
|
+
(ignoreCase ? buckets.fullIgnoreCase : buckets.full).push(inner);
|
|
176
|
+
}
|
|
177
|
+
return buckets;
|
|
178
|
+
}
|
|
179
|
+
/** The pattern without its flags — they are applied to the whole alternation. */
|
|
180
|
+
function stripFlags(regExp) {
|
|
181
|
+
return new RegExp(regExp.source);
|
|
182
|
+
}
|
|
183
|
+
function prefixPattern(names, ignoreCase) {
|
|
184
|
+
return names.length > 0
|
|
185
|
+
? `${ignoreCase ? "(?i)" : ""}[?&](${names.join("|")})[^&]*`
|
|
186
|
+
: null;
|
|
187
|
+
}
|
|
188
|
+
function suffixPattern(names, ignoreCase) {
|
|
189
|
+
return names.length > 0
|
|
190
|
+
? `${ignoreCase ? "(?i)" : ""}[?&][^&]*(${names.join("|")})(=[^&]*)?(?=&|$)`
|
|
191
|
+
: null;
|
|
192
|
+
}
|
|
193
|
+
function fullPattern(names, ignoreCase) {
|
|
194
|
+
return names.length > 0
|
|
195
|
+
? `${ignoreCase ? "(?i)" : ""}[?&](${names.join("|")})(=[^&]*)?(?=&|$)`
|
|
196
|
+
: null;
|
|
197
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Predicate } from "./predicate.js";
|
|
2
|
+
/**
|
|
3
|
+
* A scalar SQL expression. Always build one through the functions in this
|
|
4
|
+
* module: they escape their operands, which is what keeps a config pattern
|
|
5
|
+
* containing a quote from producing broken SQL.
|
|
6
|
+
*/
|
|
7
|
+
export type SqlExpression = string;
|
|
8
|
+
/**
|
|
9
|
+
* A string, number or boolean as a SQL literal. Quotes inside a string are
|
|
10
|
+
* doubled, so any config-provided value is safe to embed.
|
|
11
|
+
*/
|
|
12
|
+
export declare function literal(value: boolean | number | string): SqlExpression;
|
|
13
|
+
/**
|
|
14
|
+
* A column reference, optionally qualified (`cdnclient.bot`). Rejects anything
|
|
15
|
+
* that is not an identifier, so a column name can never smuggle in SQL.
|
|
16
|
+
*/
|
|
17
|
+
export declare function column(name: string): SqlExpression;
|
|
18
|
+
/** A function call with the given arguments, e.g. `url_extract_path(url)`. */
|
|
19
|
+
export declare function call(name: string, ...args: SqlExpression[]): SqlExpression;
|
|
20
|
+
/** `lower(expression)`, used to match the case-insensitive string conditions. */
|
|
21
|
+
export declare function lower(expression: SqlExpression): SqlExpression;
|
|
22
|
+
/** Concatenation of all expressions, parenthesised so it can be nested. */
|
|
23
|
+
export declare function concat(...expressions: SqlExpression[]): SqlExpression;
|
|
24
|
+
/**
|
|
25
|
+
* Wraps a hand-written expression. The caller is responsible for escaping its
|
|
26
|
+
* operands and for parenthesising anything that binds loosely.
|
|
27
|
+
*/
|
|
28
|
+
export declare function raw(sql: string): SqlExpression;
|
|
29
|
+
/** `left = right`. */
|
|
30
|
+
export declare function equals(left: SqlExpression, right: SqlExpression): Predicate;
|
|
31
|
+
/** `left <> right`. */
|
|
32
|
+
export declare function notEquals(left: SqlExpression, right: SqlExpression): Predicate;
|
|
33
|
+
/**
|
|
34
|
+
* A placeholder for a value passed to Athena alongside the query.
|
|
35
|
+
*
|
|
36
|
+
* Placeholders are bound by position, in the order they appear in the printed
|
|
37
|
+
* SQL — so a predicate must not contain two identical comparisons over
|
|
38
|
+
* placeholders: {@link and} and {@link or} drop a duplicate operand, which
|
|
39
|
+
* would leave one value too many.
|
|
40
|
+
*/
|
|
41
|
+
export declare function parameter(): SqlExpression;
|
|
42
|
+
/** `left >= right`. */
|
|
43
|
+
export declare function greaterOrEqual(left: SqlExpression, right: SqlExpression): Predicate;
|
|
44
|
+
/** `left > right`. */
|
|
45
|
+
export declare function greaterThan(left: SqlExpression, right: SqlExpression): Predicate;
|
|
46
|
+
/**
|
|
47
|
+
* `subject = 'value'`, ignoring case on both sides — the semantics of a string
|
|
48
|
+
* condition in a Speed Kit rule set of version 2.
|
|
49
|
+
*/
|
|
50
|
+
export declare function equalsIgnoreCase(subject: SqlExpression, value: string): Predicate;
|
|
51
|
+
/**
|
|
52
|
+
* `subject like 'value%'` — a prefix match, which is what a string condition
|
|
53
|
+
* means in a rule set of version 1. Case is ignored by default, matching the
|
|
54
|
+
* runtime, which lower-cases both sides.
|
|
55
|
+
*/
|
|
56
|
+
export declare function startsWith(subject: SqlExpression, value: string, options?: {
|
|
57
|
+
ignoreCase?: boolean;
|
|
58
|
+
}): Predicate;
|
|
59
|
+
/** `subject between low and high`. */
|
|
60
|
+
export declare function between(subject: SqlExpression, low: SqlExpression, high: SqlExpression): Predicate;
|
|
61
|
+
/** `expression is null`. */
|
|
62
|
+
export declare function isNull(expression: SqlExpression): Predicate;
|
|
63
|
+
/** `subject not in ('a', 'b')`. */
|
|
64
|
+
export declare function notInValues(subject: SqlExpression, values: readonly (number | string)[]): Predicate;
|
|
65
|
+
/** `subject in ('a', 'b')`. */
|
|
66
|
+
export declare function inValues(subject: SqlExpression, values: readonly (number | string)[]): Predicate;
|
|
67
|
+
/**
|
|
68
|
+
* `regexp_like(subject, 'pattern')`. The pattern is expected to already be in
|
|
69
|
+
* Athena's dialect — see {@link toAthenaRegex}.
|
|
70
|
+
*/
|
|
71
|
+
export declare function regexpLike(subject: SqlExpression, pattern: string): Predicate;
|
|
72
|
+
/** A boolean column used as a predicate on its own, e.g. `suspicious`. */
|
|
73
|
+
export declare function booleanColumn(name: string): Predicate;
|
|
@@ -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
|
+
}
|