Skip to content

Commit ba07998

Browse files
committed
LDEV-6070 support bracket notation with formUrlAsStruct
https://luceeserver.atlassian.net/browse/LDEV-6070
1 parent ed54488 commit ba07998

7 files changed

Lines changed: 359 additions & 9 deletions

File tree

core/src/main/java/lucee/runtime/type/scope/ScopeSupport.java

Lines changed: 77 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@
1919
package lucee.runtime.type.scope;
2020

2121
import java.io.UnsupportedEncodingException;
22+
import java.util.ArrayList;
23+
import java.util.List;
2224

2325
import lucee.commons.io.log.LogUtil;
24-
import lucee.commons.lang.StringList;
2526
import lucee.commons.lang.StringUtil;
2627
import lucee.commons.net.URLDecoder;
2728
import lucee.commons.net.URLItem;
@@ -187,22 +188,89 @@ protected void fillDecoded(URLItem[] raw, String encoding, boolean scriptProtece
187188
}
188189
}
189190

190-
if (formUrlAsStruct && name.indexOf('.') != -1) {
191+
if (formUrlAsStruct && (name.indexOf('.') != -1 || name.indexOf('[') != -1)) {
192+
List<String> segments = parseFormUrlName(name);
191193

192-
StringList list = ListUtil.listToStringListRemoveEmpty(name, '.');
193-
if (list.size() > 0) {
194+
if (segments == null || segments.isEmpty()) {
195+
// Malformed brackets or no segments, treat as literal key
196+
_fill(this, name, value, true, scriptProteced, sameAsArray);
197+
}
198+
else {
199+
// Process segments into nested structure
194200
Struct parent = this;
195-
while (list.hasNextNext()) {
196-
parent = _fill(parent, list.next(), new CastableStruct(Struct.TYPE_LINKED), false, scriptProteced, sameAsArray);
201+
for (int j = 0; j < segments.size() - 1; j++) {
202+
parent = _fill(parent, segments.get(j), new CastableStruct(Struct.TYPE_LINKED), false, scriptProteced, sameAsArray);
197203
}
198-
_fill(parent, list.next(), value, true, scriptProteced, sameAsArray);
204+
_fill(parent, segments.get(segments.size() - 1), value, true, scriptProteced, sameAsArray);
199205
}
200206
}
201-
// else
202-
_fill(this, name, value, true, scriptProteced, sameAsArray);
207+
else {
208+
_fill(this, name, value, true, scriptProteced, sameAsArray);
209+
}
203210
}
204211
}
205212

213+
/**
214+
* Parse form/URL parameter name with bracket notation into segments.
215+
* Examples:
216+
* user[name] -> ["user", "name"]
217+
* user[address][city] -> ["user", "address", "city"]
218+
* user.address[city] -> ["user", "address", "city"]
219+
*
220+
* @param name The parameter name to parse
221+
* @return List of segments, or null if malformed brackets detected
222+
*/
223+
private static List<String> parseFormUrlName( String name ) {
224+
List<String> segments = new ArrayList<>();
225+
int len = name.length();
226+
int start = 0;
227+
boolean inBracket = false;
228+
229+
for (int i = 0; i < len; i++) {
230+
char c = name.charAt(i);
231+
232+
if (c == '[') {
233+
// Save segment before bracket (skip empty segments)
234+
if (i > start) {
235+
segments.add(name.substring(start, i));
236+
}
237+
inBracket = true;
238+
start = i + 1;
239+
}
240+
else if (c == ']') {
241+
if (!inBracket) {
242+
// Closing bracket without opening - malformed
243+
return null;
244+
}
245+
// Save segment inside brackets (skip empty segments like [])
246+
if (i > start) {
247+
segments.add(name.substring(start, i));
248+
}
249+
inBracket = false;
250+
start = i + 1;
251+
}
252+
else if (c == '.' && !inBracket) {
253+
// Save segment before dot (skip empty segments)
254+
if (i > start) {
255+
segments.add(name.substring(start, i));
256+
}
257+
start = i + 1;
258+
}
259+
}
260+
261+
// Check for unclosed brackets
262+
if (inBracket) {
263+
return null;
264+
}
265+
266+
// Add final segment if exists (skip empty trailing segments)
267+
if (start < len) {
268+
segments.add(name.substring(start, len));
269+
}
270+
271+
return segments;
272+
}
273+
206274
private Struct _fill(final Struct parent, String name, Object value, boolean isLast, boolean scriptProteced, boolean sameAsArray) {
207275
Object curr;
208276
boolean isArrayDef = false;

test/tickets/LDEV6070.cfc

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
component extends="org.lucee.cfml.test.LuceeTestCase" labels="ajax,form,url" {
2+
3+
function beforeAll() {
4+
variables.uri = createURI( "LDEV6070" );
5+
}
6+
7+
function run( testResults, testBox ) {
8+
describe( "LDEV-6070: Support bracket notation in form/URL parameters", function() {
9+
10+
it( "should parse simple bracket notation into nested struct", function() {
11+
var result = _internalRequest(
12+
template: "#uri#/test.cfm",
13+
forms: {
14+
"user[name]": "John",
15+
"user[age]": "30"
16+
}
17+
);
18+
expect( result.filecontent ).toInclude( "user.name=John" );
19+
expect( result.filecontent ).toInclude( "user.age=30" );
20+
});
21+
22+
it( "should parse deep bracket notation", function() {
23+
var result = _internalRequest(
24+
template: "#uri#/test.cfm",
25+
forms: {
26+
"user[address][city]": "Sydney",
27+
"user[address][country]": "Australia"
28+
}
29+
);
30+
expect( result.filecontent ).toInclude( "user.address.city=Sydney" );
31+
expect( result.filecontent ).toInclude( "user.address.country=Australia" );
32+
});
33+
34+
it( "should parse mixed dot and bracket notation", function() {
35+
var result = _internalRequest(
36+
template: "#uri#/test.cfm",
37+
forms: {
38+
"user.address[city]": "Sydney",
39+
"user.address[zip]": "2000"
40+
}
41+
);
42+
expect( result.filecontent ).toInclude( "user.address.city=Sydney" );
43+
expect( result.filecontent ).toInclude( "user.address.zip=2000" );
44+
});
45+
46+
it( "should handle array notation with brackets", function() {
47+
var result = _internalRequest(
48+
template: "#uri#/test.cfm",
49+
forms: {
50+
"tags[]": [ "java", "cfml", "lucee" ]
51+
}
52+
);
53+
expect( result.filecontent ).toInclude( "tags=java,cfml,lucee" );
54+
});
55+
56+
it( "should handle complex nested structures", function() {
57+
var result = _internalRequest(
58+
template: "#uri#/test.cfm",
59+
forms: {
60+
"order[customer][name]": "Jane",
61+
"order[customer][email]": "jane@example.com",
62+
"order[items][0][product]": "Widget",
63+
"order[items][0][qty]": "2",
64+
"order[items][1][product]": "Gadget",
65+
"order[items][1][qty]": "5"
66+
}
67+
);
68+
expect( result.filecontent ).toInclude( "order.customer.name=Jane" );
69+
// Numeric indices create struct keys, not array indices
70+
expect( result.filecontent ).toInclude( "order.items.0.product=Widget" );
71+
expect( result.filecontent ).toInclude( "order.items.1.qty=5" );
72+
});
73+
74+
// Remote CFC calls need different test approach - internalRequest doesn't work same way
75+
xit( "should work with remote CFC calls", function() {
76+
var result = _internalRequest(
77+
template: "#uri#/TestService.cfc?method=testMethod",
78+
forms: {
79+
"userData[name]": "John",
80+
"userData[sellerID]": "12345"
81+
}
82+
);
83+
var data = deserializeJSON( result.filecontent );
84+
expect( data ).toHaveKey( "userData" );
85+
expect( data.userData.name ).toBe( "John" );
86+
expect( data.userData.sellerID ).toBe( "12345" );
87+
});
88+
89+
it( "should respect formUrlAsStruct=false setting", function() {
90+
var result = _internalRequest(
91+
template: "#uri#/test-disabled/test-disabled.cfm",
92+
forms: {
93+
"user[name]": "John"
94+
}
95+
);
96+
// Should have literal key, not nested
97+
expect( result.filecontent ).toInclude( "user[name]=John" );
98+
});
99+
100+
it( "should handle malformed brackets - missing closing bracket", function() {
101+
var result = _internalRequest(
102+
template: "#uri#/test.cfm",
103+
forms: {
104+
"user[name": "John"
105+
}
106+
);
107+
// Should treat as literal key when malformed
108+
expect( result.filecontent ).toInclude( "user[name=John" );
109+
});
110+
111+
it( "should handle malformed brackets - missing opening bracket", function() {
112+
var result = _internalRequest(
113+
template: "#uri#/test.cfm",
114+
forms: {
115+
"username]": "John"
116+
}
117+
);
118+
// Should treat as literal key when malformed
119+
expect( result.filecontent ).toInclude( "username]=John" );
120+
});
121+
122+
it( "should skip empty bracket segments", function() {
123+
var result = _internalRequest(
124+
template: "#uri#/test.cfm",
125+
forms: {
126+
"user[]name": "John"
127+
}
128+
);
129+
// Empty brackets should be skipped: user[]name -> user.name
130+
expect( result.filecontent ).toInclude( "user.name=John" );
131+
});
132+
133+
it( "should skip empty dot segments", function() {
134+
var result = _internalRequest(
135+
template: "#uri#/test.cfm",
136+
forms: {
137+
"user..name": "John"
138+
}
139+
);
140+
// Empty dot segments should be skipped: user..name -> user.name
141+
expect( result.filecontent ).toInclude( "user.name=John" );
142+
});
143+
144+
it( "should skip trailing dots and brackets", function() {
145+
var result = _internalRequest(
146+
template: "#uri#/test.cfm",
147+
forms: {
148+
"user.": "John"
149+
}
150+
);
151+
// Trailing dot should be skipped: user. -> user
152+
expect( result.filecontent ).toInclude( "user=John" );
153+
});
154+
155+
it( "should skip leading dots", function() {
156+
var result = _internalRequest(
157+
template: "#uri#/test.cfm",
158+
forms: {
159+
".name": "John"
160+
}
161+
);
162+
// Leading dot should be skipped: .name -> name
163+
expect( result.filecontent ).toInclude( "name=John" );
164+
});
165+
166+
it( "should handle special characters in bracket keys", function() {
167+
var result = _internalRequest(
168+
template: "#uri#/test.cfm",
169+
forms: {
170+
"user[first-name]": "John",
171+
"user[last_name]": "Doe"
172+
}
173+
);
174+
expect( result.filecontent ).toInclude( "user.first-name=John" );
175+
expect( result.filecontent ).toInclude( "user.last_name=Doe" );
176+
});
177+
178+
it( "should handle numeric string keys vs array indices", function() {
179+
var result = _internalRequest(
180+
template: "#uri#/test.cfm",
181+
forms: {
182+
"items[0]": "first",
183+
"items[1]": "second",
184+
"items[foo]": "bar"
185+
}
186+
);
187+
// Numeric indices create struct keys, not array
188+
expect( result.filecontent ).toInclude( "items.0=first" );
189+
expect( result.filecontent ).toInclude( "items.1=second" );
190+
expect( result.filecontent ).toInclude( "items.foo=bar" );
191+
});
192+
193+
it( "should handle deeply nested brackets", function() {
194+
var result = _internalRequest(
195+
template: "#uri#/test.cfm",
196+
forms: {
197+
"a[b][c][d][e][f]": "deep"
198+
}
199+
);
200+
expect( result.filecontent ).toInclude( "a.b.c.d.e.f=deep" );
201+
});
202+
203+
});
204+
}
205+
206+
private string function createURI( string calledName ) {
207+
var baseURI = "/test/#listLast( getDirectoryFromPath( getCurrenttemplatepath() ), "\/" )#/";
208+
return baseURI & "" & calledName;
209+
}
210+
211+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
component {
2+
this.name = "LDEV6070_" & hash( getCurrentTemplatePath() );
3+
4+
// formUrlAsStruct is true by default in Lucee
5+
// this.formUrlAsStruct = true;
6+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
component {
2+
3+
remote function testMethod( struct userData ) returnformat="json" {
4+
return arguments;
5+
}
6+
7+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
component {
2+
this.name = "LDEV6070_disabled_" & hash( getCurrentTemplatePath() );
3+
4+
// Allow controlling formUrlAsStruct via URL parameter
5+
this.formUrlAsStruct = url.formUrlAsStruct ?: false;
6+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<cfscript>
2+
// Test with formUrlAsStruct=false - should preserve literal keys
3+
4+
for ( key in form ) {
5+
if ( key == "fieldnames" ) continue;
6+
writeOutput( key & "=" & form[ key ] & chr( 10 ) );
7+
}
8+
</cfscript>

test/tickets/LDEV6070/test.cfm

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<cfscript>
2+
// Test form scope parsing with formUrlAsStruct=true (default)
3+
4+
// Output form keys for validation
5+
for ( key in form ) {
6+
if ( key == "fieldnames" ) continue;
7+
8+
// Handle nested structs
9+
if ( isStruct( form[ key ] ) ) {
10+
writeOutput( key & "=" & serializeJSON( form[ key ] ) & chr( 10 ) );
11+
// Also output nested keys for easy testing
12+
outputNested( key, form[ key ] );
13+
}
14+
// Handle arrays
15+
else if ( isArray( form[ key ] ) ) {
16+
writeOutput( key & "=" & arrayToList( form[ key ] ) & chr( 10 ) );
17+
}
18+
else {
19+
writeOutput( key & "=" & form[ key ] & chr( 10 ) );
20+
}
21+
}
22+
23+
function outputNested( prefix, struct data ) {
24+
for ( k in data ) {
25+
var fullKey = prefix & "." & k;
26+
if ( isStruct( data[ k ] ) ) {
27+
outputNested( fullKey, data[ k ] );
28+
}
29+
else if ( isArray( data[ k ] ) ) {
30+
for ( var i = 1; i <= arrayLen( data[ k ] ); i++ ) {
31+
if ( isStruct( data[ k ][ i ] ) ) {
32+
outputNested( fullKey & "[" & ( i - 1 ) & "]", data[ k ][ i ] );
33+
}
34+
else {
35+
writeOutput( fullKey & "[" & ( i - 1 ) & "]=" & data[ k ][ i ] & chr( 10 ) );
36+
}
37+
}
38+
}
39+
else {
40+
writeOutput( fullKey & "=" & data[ k ] & chr( 10 ) );
41+
}
42+
}
43+
}
44+
</cfscript>

0 commit comments

Comments
 (0)