Skip to content

Commit 32f0583

Browse files
author
Vojtěch Václav Porteš
committed
fix: Additional protected ranges fixes
1 parent 282acf6 commit 32f0583

11 files changed

Lines changed: 997 additions & 229 deletions

src/builder/text-mode/types/text-mode-editor-props.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,6 @@ export interface ITextModeEditorProps {
99
protectedRangeHoverMessage?: string | null;
1010
errorMessage: string | null;
1111
readOnly?: boolean;
12+
allowProtectedRangeDeletion?: boolean;
1213
onChange: (value: string) => void;
1314
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { ITextModeDiagnostic } from '../types/text-mode-diagnostic';
2+
import { DenormalizedNode, DenormalizedQuery } from '../../../utils/query-tree';
3+
import {
4+
collectReadOnlyNodeCandidates,
5+
collectReadOnlyNodeDescriptors,
6+
matchReadOnlyDescriptorToCandidate,
7+
} from './match-read-only-nodes.util';
8+
9+
const hasOnlyNegationTarget = (readOnly: DenormalizedNode['readOnly']): boolean =>
10+
Boolean(
11+
readOnly &&
12+
typeof readOnly === 'object' &&
13+
readOnly.enabled &&
14+
readOnly.targets &&
15+
readOnly.targets.length === 1 &&
16+
readOnly.targets[0] === 'negation'
17+
);
18+
19+
export const findReadOnlyTargetDiagnostic = (
20+
previousQuery: DenormalizedQuery,
21+
nextQuery: DenormalizedQuery,
22+
options: {
23+
allowProtectedClauseRemoval?: boolean;
24+
} = {}
25+
): ITextModeDiagnostic | null => {
26+
const descriptors = collectReadOnlyNodeDescriptors(previousQuery).filter(
27+
descriptor => !hasOnlyNegationTarget(descriptor.readOnly)
28+
);
29+
30+
if (descriptors.length === 0) {
31+
return null;
32+
}
33+
34+
const candidates = collectReadOnlyNodeCandidates(nextQuery);
35+
const usedCandidates = new WeakSet<DenormalizedNode>();
36+
37+
const hasViolation = descriptors.some((descriptor) => {
38+
const candidate = matchReadOnlyDescriptorToCandidate(
39+
descriptor,
40+
candidates[descriptor.kind],
41+
usedCandidates
42+
);
43+
44+
if (!candidate) {
45+
return !options.allowProtectedClauseRemoval;
46+
}
47+
48+
usedCandidates.add(candidate.node);
49+
return false;
50+
});
51+
52+
if (!hasViolation) {
53+
return null;
54+
}
55+
56+
return {
57+
code: 'readonly_target',
58+
message:
59+
'One or more read-only clauses cannot be changed or removed in text mode.',
60+
start: 0,
61+
end: 0,
62+
};
63+
};
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { formatBuilderSqlState } from './format-builder-sql-state';
2+
import { IBuilderFieldProps } from '../../types';
3+
import { DenormalizedQuery } from '../../../utils/query-tree';
4+
5+
const fields: IBuilderFieldProps[] = [
6+
{
7+
field: 'MOCK_FIELD',
8+
label: 'Mock Field',
9+
type: 'TEXT',
10+
operators: ['EQUAL'],
11+
},
12+
{
13+
field: 'MOCK_NUMBER',
14+
label: 'Mock Number',
15+
type: 'NUMBER',
16+
operators: ['NOT_EQUAL'],
17+
},
18+
];
19+
20+
const getProtectedSegments = (
21+
value: string,
22+
protectedRanges: Array<{ start: number; end: number }>
23+
) => protectedRanges.map((range) => value.slice(range.start, range.end));
24+
25+
describe('formatBuilderSqlState', () => {
26+
it('protects only non-root group brackets plus targeted rule fragments for groups with protected descendants', () => {
27+
const query: DenormalizedQuery = [
28+
{
29+
type: 'GROUP',
30+
value: 'AND',
31+
isNegated: false,
32+
children: [
33+
{
34+
type: 'GROUP',
35+
value: 'OR',
36+
isNegated: false,
37+
children: [
38+
{
39+
field: 'MOCK_FIELD',
40+
value: 'alpha',
41+
operator: 'EQUAL',
42+
readOnly: {
43+
enabled: true,
44+
targets: ['field', 'operator'],
45+
},
46+
},
47+
{
48+
field: 'MOCK_NUMBER',
49+
value: 5,
50+
operator: 'NOT_EQUAL',
51+
},
52+
],
53+
},
54+
],
55+
},
56+
];
57+
58+
const textState = formatBuilderSqlState(query, fields);
59+
const protectedSegments = getProtectedSegments(
60+
textState.value,
61+
textState.protectedRanges
62+
);
63+
64+
expect(textState.value).toContain("(MOCK_FIELD = 'alpha' OR MOCK_NUMBER <> 5)");
65+
expect(protectedSegments).toEqual(
66+
expect.arrayContaining(['(', ')', 'MOCK_FIELD', '='])
67+
);
68+
expect(textState.protectedRanges).not.toContainEqual({
69+
start: 0,
70+
end: textState.value.length,
71+
});
72+
});
73+
74+
it('does not protect non-root group brackets when delete protection is disabled', () => {
75+
const query: DenormalizedQuery = [
76+
{
77+
type: 'GROUP',
78+
value: 'AND',
79+
isNegated: false,
80+
children: [
81+
{
82+
type: 'GROUP',
83+
value: 'OR',
84+
isNegated: false,
85+
children: [
86+
{
87+
field: 'MOCK_FIELD',
88+
value: 'alpha',
89+
operator: 'EQUAL',
90+
readOnly: {
91+
enabled: true,
92+
targets: ['field', 'operator'],
93+
},
94+
},
95+
{
96+
field: 'MOCK_NUMBER',
97+
value: 5,
98+
operator: 'NOT_EQUAL',
99+
},
100+
],
101+
},
102+
],
103+
},
104+
];
105+
106+
const textState = formatBuilderSqlState(query, fields, {
107+
protectGroupDeletionBoundaries: false,
108+
});
109+
const protectedSegments = getProtectedSegments(
110+
textState.value,
111+
textState.protectedRanges
112+
);
113+
114+
expect(protectedSegments).toEqual(
115+
expect.arrayContaining(['MOCK_FIELD', '='])
116+
);
117+
expect(protectedSegments).not.toEqual(expect.arrayContaining(['(', ')']));
118+
});
119+
120+
it('protects the whole group when inherited full read-only is enabled', () => {
121+
const query: DenormalizedQuery = [
122+
{
123+
type: 'GROUP',
124+
value: 'AND',
125+
isNegated: false,
126+
children: [
127+
{
128+
type: 'GROUP',
129+
value: 'OR',
130+
isNegated: false,
131+
readOnly: {
132+
enabled: true,
133+
inheritToChildren: true,
134+
},
135+
children: [
136+
{
137+
field: 'MOCK_FIELD',
138+
value: 'alpha',
139+
operator: 'EQUAL',
140+
},
141+
{
142+
field: 'MOCK_NUMBER',
143+
value: 5,
144+
operator: 'NOT_EQUAL',
145+
},
146+
],
147+
},
148+
],
149+
},
150+
];
151+
152+
const textState = formatBuilderSqlState(query, fields);
153+
154+
expect(textState.protectedRanges).toContainEqual({
155+
start: 0,
156+
end: textState.value.length,
157+
});
158+
});
159+
});

src/builder/text-mode/utils/format-builder-sql-state.ts

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ interface IFragmentResult {
2323
protectedRanges: ILocalProtectedRange[];
2424
}
2525

26+
interface IFormatBuilderSqlStateOptions {
27+
protectGroupDeletionBoundaries?: boolean;
28+
}
29+
2630
const shiftRanges = (
2731
ranges: ILocalProtectedRange[],
2832
offset: number
@@ -32,6 +36,34 @@ const shiftRanges = (
3236
end: range.end + offset,
3337
}));
3438

39+
const addProtectedGroupBracketRanges = (
40+
text: string,
41+
ranges: ILocalProtectedRange[]
42+
): ILocalProtectedRange[] => {
43+
if (text.length < 2) {
44+
return ranges;
45+
}
46+
47+
const openingIndex = text.indexOf('(');
48+
const closingIndex = text.lastIndexOf(')');
49+
50+
if (openingIndex === -1 || closingIndex === -1 || closingIndex <= openingIndex) {
51+
return ranges;
52+
}
53+
54+
return [
55+
...ranges,
56+
{
57+
start: openingIndex,
58+
end: openingIndex + 1,
59+
},
60+
{
61+
start: closingIndex,
62+
end: closingIndex + 1,
63+
},
64+
];
65+
};
66+
3567
const formatRuleFragment = (
3668
rule: IDenormalizedRuleNode,
3769
fields: IBuilderFieldProps[]
@@ -40,12 +72,13 @@ const formatRuleFragment = (
4072
const formatGroupFragment = (
4173
group: DenormalizedGroupNode,
4274
fields: IBuilderFieldProps[],
43-
isRoot: boolean
75+
isRoot: boolean,
76+
options: IFormatBuilderSqlStateOptions
4477
): IFragmentResult => {
4578
const combinator =
4679
'value' in group && group.value ? group.value : ('AND' as QueryGroupValue);
4780
const childFragments = group.children
48-
.map(child => formatNodeFragment(child, fields, false))
81+
.map(child => formatNodeFragment(child, fields, false, options))
4982
.filter(fragment => fragment.text.trim().length > 0);
5083
const groupReadOnly = resolveGroupReadOnly(group.readOnly);
5184
const groupReadOnlyTargets = getGroupReadOnlyTargets(group.readOnly);
@@ -115,6 +148,10 @@ const formatGroupFragment = (
115148
}
116149
}
117150

151+
if (options.protectGroupDeletionBoundaries !== false && protectedRanges.length > 0) {
152+
protectedRanges = addProtectedGroupBracketRanges(text, protectedRanges);
153+
}
154+
118155
if (groupReadOnly.inheritToChildren && isGroupFullyReadOnly(group.readOnly)) {
119156
protectedRanges = [
120157
{
@@ -140,18 +177,20 @@ const formatGroupFragment = (
140177
const formatNodeFragment = (
141178
node: DenormalizedNode,
142179
fields: IBuilderFieldProps[],
143-
isRoot: boolean
180+
isRoot: boolean,
181+
options: IFormatBuilderSqlStateOptions
144182
): IFragmentResult =>
145183
isGroupNode(node)
146-
? formatGroupFragment(node, fields, isRoot)
184+
? formatGroupFragment(node, fields, isRoot, options)
147185
: formatRuleFragment(node, fields);
148186

149187
export const formatBuilderSqlState = (
150188
data: DenormalizedQuery,
151-
fields: IBuilderFieldProps[]
189+
fields: IBuilderFieldProps[],
190+
options: IFormatBuilderSqlStateOptions = {}
152191
): IBuilderTextModeSqlState => {
153192
const fragments = data
154-
.map(node => formatNodeFragment(node, fields, true))
193+
.map(node => formatNodeFragment(node, fields, true, options))
155194
.filter(fragment => fragment.text.trim().length > 0);
156195

157196
if (fragments.length === 0) {

0 commit comments

Comments
 (0)