Skip to content

Commit 09fff65

Browse files
rkaabecheseboo
authored andcommitted
LUT-32413 : Working String sort for form responses list
indexation sort
1 parent 011c80d commit 09fff65

6 files changed

Lines changed: 952 additions & 15 deletions

File tree

src/java/fr/paris/lutece/plugins/forms/service/search/LuceneFormSearchEngine.java

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@
6666
import org.apache.lucene.search.Sort;
6767
import org.apache.lucene.search.SortField;
6868
import org.apache.lucene.search.SortedNumericSortField;
69+
import org.apache.lucene.search.SortedSetSelector;
70+
import org.apache.lucene.search.SortedSetSortField;
6971
import org.apache.lucene.search.TopDocs;
7072
import org.apache.lucene.store.Directory;
7173

@@ -254,18 +256,20 @@ private Sort buildLuceneSort( FormItemSortConfig sortConfig )
254256
{
255257
if ( strAttributeName.endsWith( FormResponseSearchItem.FIELD_DATE_SUFFIX ) )
256258
{
257-
return new Sort( new SortedNumericSortField( sortConfig.getSortAttributeName( ), SortField.Type.LONG, sortConfig.isAscSort( ) ) );
259+
return new Sort( new SortedNumericSortField( strAttributeName, SortField.Type.LONG, sortConfig.isAscSort( ) ) );
258260
}
259261
if ( strAttributeName.endsWith( FormResponseSearchItem.FIELD_INT_SUFFIX ) )
260262
{
261-
return new Sort( new SortedNumericSortField( sortConfig.getSortAttributeName( ), SortField.Type.LONG, sortConfig.isAscSort( ) ) );
262-
263+
return new Sort( new SortedNumericSortField( strAttributeName, SortField.Type.INT, sortConfig.isAscSort( ) ) );
263264
}
264-
return new Sort( new SortField( sortConfig.getSortAttributeName( ), SortField.Type.STRING, sortConfig.isAscSort( ) ) );
265+
266+
return new Sort(
267+
new SortedSetSortField( strAttributeName, sortConfig.isAscSort( ), SortedSetSelector.Type.MIN ),
268+
new SortField( FormResponseSearchItem.FIELD_ID_FORM_RESPONSE, SortField.Type.INT ) );
265269
}
266270
}
267271

268272
return null;
269273
}
270274

271-
}
275+
}

src/java/fr/paris/lutece/plugins/forms/service/search/LuceneFormSearchIndexer.java

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2002-2025, City of Paris
2+
* Copyright (c) 2002-2026, City of Paris
33
* All rights reserved.
44
*
55
* Redistribution and use in source and binary forms, with or without
@@ -44,15 +44,23 @@
4444
import javax.inject.Inject;
4545

4646
import fr.paris.lutece.plugins.forms.exception.LockException;
47+
import fr.paris.lutece.plugins.forms.service.entrytype.EntryTypeCheckBox;
48+
import fr.paris.lutece.plugins.forms.service.entrytype.EntryTypeDate;
49+
import fr.paris.lutece.plugins.forms.service.entrytype.EntryTypeNumber;
50+
import fr.paris.lutece.plugins.forms.service.entrytype.EntryTypeNumbering;
51+
import fr.paris.lutece.plugins.forms.service.entrytype.EntryTypeRadioButton;
52+
import fr.paris.lutece.plugins.forms.service.entrytype.EntryTypeSelect;
4753
import fr.paris.lutece.plugins.forms.service.lock.LockResult;
4854
import fr.paris.lutece.plugins.forms.service.lock.LuceneLockManager;
55+
import fr.paris.lutece.plugins.forms.util.NaturalSortKeyBuilder;
4956
import org.apache.commons.lang3.StringUtils;
5057
import org.apache.lucene.document.Document;
5158
import org.apache.lucene.document.Field;
5259
import org.apache.lucene.document.IntPoint;
5360
import org.apache.lucene.document.LongPoint;
5461
import org.apache.lucene.document.NumericDocValuesField;
5562
import org.apache.lucene.document.SortedDocValuesField;
63+
import org.apache.lucene.document.SortedSetDocValuesField;
5664
import org.apache.lucene.document.StoredField;
5765
import org.apache.lucene.document.StringField;
5866
import org.apache.lucene.document.TextField;
@@ -72,11 +80,6 @@
7280
import fr.paris.lutece.plugins.forms.business.form.search.IndexerAction;
7381
import fr.paris.lutece.plugins.forms.business.form.search.IndexerActionHome;
7482
import fr.paris.lutece.plugins.forms.service.FormsPlugin;
75-
import fr.paris.lutece.plugins.forms.service.entrytype.EntryTypeCheckBox;
76-
import fr.paris.lutece.plugins.forms.service.entrytype.EntryTypeDate;
77-
import fr.paris.lutece.plugins.forms.service.entrytype.EntryTypeNumbering;
78-
import fr.paris.lutece.plugins.forms.service.entrytype.EntryTypeRadioButton;
79-
import fr.paris.lutece.plugins.forms.service.entrytype.EntryTypeSelect;
8083
import fr.paris.lutece.plugins.forms.util.LuceneUtils;
8184
import fr.paris.lutece.plugins.genericattributes.business.Entry;
8285
import fr.paris.lutece.plugins.genericattributes.business.Response;
@@ -110,6 +113,13 @@ public class LuceneFormSearchIndexer implements IFormSearchIndexer
110113
private StateService _stateService;
111114
private LuceneLockManager _lockManager;
112115

116+
/**
117+
* {@link java.text.Collator}, used internally by {@link NaturalSortKeyBuilder}, is not thread-safe. A ThreadLocal
118+
* gives each indexing thread its own builder instance without re-creating the underlying collators for every
119+
* document.
120+
*/
121+
private static final ThreadLocal<NaturalSortKeyBuilder> SORT_KEY_BUILDER = ThreadLocal.withInitial( NaturalSortKeyBuilder::new );
122+
113123
/**
114124
* Constructor
115125
*
@@ -774,7 +784,7 @@ private Document getDocument( FormResponse formResponse, Form form, State formRe
774784
}
775785
}
776786
else
777-
if ( entryTypeService instanceof EntryTypeNumbering )
787+
if ( entryTypeService instanceof EntryTypeNumbering || entryTypeService instanceof EntryTypeNumber)
778788
{
779789
try
780790
{
@@ -792,7 +802,11 @@ private Document getDocument( FormResponse formResponse, Form form, State formRe
792802
if ( entryTypeService instanceof EntryTypeSelect || entryTypeService instanceof EntryTypeRadioButton || entryTypeService instanceof EntryTypeCheckBox )
793803
{
794804
doc.add( new StringField( fieldNameBuilder.toString( ) + FormResponseSearchItem.FIELD_SELECT_SUFFIX, response.getResponseValue( ), Field.Store.YES ) );
795-
doc.add( new SortedDocValuesField( fieldNameBuilder.toString( ) + FormResponseSearchItem.FIELD_SELECT_SUFFIX, new BytesRef( response.getResponseValue( ) ) ) );
805+
String sortIndexList = LuceneUtils.createLuceneEntryKey( strQuestionCode, response.getIterationNumber( ) );
806+
807+
808+
doc.add( new SortedSetDocValuesField( sortIndexList,
809+
new BytesRef( SORT_KEY_BUILDER.get( ).build( response.getResponseValue( ) ) ) ) );
796810
if ( responseField != null && StringUtils.isNotEmpty( responseField.getTitle( ) ) )
797811
{
798812
doc.add( new StringField( fieldNameBuilder.toString( ) + FormResponseSearchItem.FIELD_SELECT_TITLE , responseField.getTitle( ), Field.Store.YES ) );
@@ -801,7 +815,10 @@ private Document getDocument( FormResponse formResponse, Form form, State formRe
801815
else
802816
{
803817
doc.add( new StringField( fieldNameBuilder.toString( ), response.getResponseValue( ), Field.Store.YES ) );
804-
doc.add( new SortedDocValuesField( fieldNameBuilder.toString( ), new BytesRef( response.getResponseValue( ) ) ) );
818+
819+
820+
doc.add( new SortedSetDocValuesField( fieldNameBuilder.toString( ),
821+
new BytesRef( SORT_KEY_BUILDER.get( ).build( response.getResponseValue( ) ) ) ) );
805822
}
806823

807824
}
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
/*
2+
* Copyright (c) 2002-2026, City of Paris
3+
* All rights reserved.
4+
*
5+
* Redistribution and use in source and binary forms, with or without
6+
* modification, are permitted provided that the following conditions
7+
* are met:
8+
*
9+
* 1. Redistributions of source code must retain the above copyright notice
10+
* and the following disclaimer.
11+
*
12+
* 2. Redistributions in binary form must reproduce the above copyright notice
13+
* and the following disclaimer in the documentation and/or other materials
14+
* provided with the distribution.
15+
*
16+
* 3. Neither the name of 'Mairie de Paris' nor 'Lutece' nor the names of its
17+
* contributors may be used to endorse or promote products derived from
18+
* this software without specific prior written permission.
19+
*
20+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21+
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22+
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
23+
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
24+
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
25+
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
26+
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
27+
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
28+
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
29+
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
30+
* POSSIBILITY OF SUCH DAMAGE.
31+
*
32+
* License 1.0
33+
*/
34+
package fr.paris.lutece.plugins.forms.util;
35+
36+
import java.io.ByteArrayOutputStream;
37+
import java.text.Collator;
38+
import java.util.Locale;
39+
40+
public final class NaturalSortKeyBuilder
41+
{
42+
/**
43+
* Version of the binary key format. Bump on any change to {@link #build(String)} : previously indexed keys are then
44+
* no longer comparable with newly produced ones and a full reindexation is required.
45+
*/
46+
public static final int FORMAT_VERSION = 1;
47+
48+
/** Reserved delimiter. Escaped out of all content, so it always compares lower than any encoded byte. */
49+
private static final byte SEP = 0x00;
50+
/** Escape marker for the two reserved bytes. */
51+
private static final byte ESC = 0x01;
52+
/** Segment type markers : numeric segments sort before textual ones. */
53+
private static final byte TYPE_NUM = 0x02;
54+
private static final byte TYPE_TXT = 0x03;
55+
56+
/**
57+
* Beyond this length a sort key carries no further discriminating power, and an unbounded key could exceed Lucene's
58+
* 32766-byte limit for a docvalues term. Longer values are truncated for sorting only ; the stored field keeps the
59+
* full value for display.
60+
*/
61+
private static final int MAX_INPUT_CHARS = 256;
62+
63+
/** Guards the two-byte length prefix against a pathological run of digits. */
64+
private static final int MAX_DIGITS = 0xFFFF;
65+
66+
private final Collator collatorPrimary;
67+
private final Collator collatorTertiary;
68+
69+
/**
70+
* Creates a builder using the collation order shared by {@code fr-FR} and {@code en} under CLDR.
71+
*/
72+
public NaturalSortKeyBuilder( )
73+
{
74+
this( Locale.ENGLISH );
75+
}
76+
77+
/**
78+
* Creates a builder for an explicit collation locale.
79+
*
80+
* @param locale
81+
* the collation locale, {@code null} meaning {@link Locale#ENGLISH}
82+
*/
83+
public NaturalSortKeyBuilder( Locale locale )
84+
{
85+
Locale effective = locale == null ? Locale.ENGLISH : locale;
86+
87+
collatorPrimary = Collator.getInstance( effective );
88+
collatorPrimary.setStrength( Collator.PRIMARY );
89+
collatorPrimary.setDecomposition( Collator.CANONICAL_DECOMPOSITION );
90+
91+
collatorTertiary = Collator.getInstance( effective );
92+
collatorTertiary.setStrength( Collator.TERTIARY );
93+
collatorTertiary.setDecomposition( Collator.CANONICAL_DECOMPOSITION );
94+
}
95+
96+
/**
97+
* Builds the sort key for a value.
98+
*
99+
* @param strValue
100+
* the raw response value, may be {@code null}
101+
* @return the binary sort key, never {@code null} ; empty for a {@code null} or empty input
102+
*/
103+
public byte [ ] build( String strValue )
104+
{
105+
ByteArrayOutputStream out = new ByteArrayOutputStream( 96 );
106+
107+
if ( strValue == null || strValue.isEmpty( ) )
108+
{
109+
return out.toByteArray( );
110+
}
111+
112+
String strInput = strValue.length( ) > MAX_INPUT_CHARS ? strValue.substring( 0, MAX_INPUT_CHARS ) : strValue;
113+
114+
writeLevelA( out, strInput );
115+
116+
// Level separator : three reserved bytes, strictly lower than any level A content,
117+
// so a value whose segments are a prefix of another's still sorts first.
118+
out.write( SEP );
119+
out.write( SEP );
120+
out.write( SEP );
121+
122+
writeEscapedAll( out, collatorTertiary.getCollationKey( strInput ).toByteArray( ) );
123+
124+
return out.toByteArray( );
125+
}
126+
127+
/**
128+
* Writes level A : alternating numeric and textual segments, numbers compared by value and text by primary
129+
* collation (case and accents ignored).
130+
*
131+
* @param out
132+
* the target stream
133+
* @param strInput
134+
* the truncated input value
135+
*/
136+
private void writeLevelA( ByteArrayOutputStream out, String strInput )
137+
{
138+
int nLength = strInput.length( );
139+
int nPos = 0;
140+
boolean bFirst = true;
141+
142+
while ( nPos < nLength )
143+
{
144+
if ( !bFirst )
145+
{
146+
out.write( SEP );
147+
out.write( SEP );
148+
}
149+
bFirst = false;
150+
151+
boolean bNumeric = Character.isDigit( strInput.codePointAt( nPos ) );
152+
int nStart = nPos;
153+
StringBuilder sbDigits = bNumeric ? new StringBuilder( ) : null;
154+
155+
while ( nPos < nLength )
156+
{
157+
int nCodePoint = strInput.codePointAt( nPos );
158+
159+
if ( Character.isDigit( nCodePoint ) != bNumeric )
160+
{
161+
break;
162+
}
163+
if ( bNumeric )
164+
{
165+
// normalises non-ASCII digits to their ASCII counterpart
166+
sbDigits.append( (char) ( '0' + Character.digit( nCodePoint, 10 ) ) );
167+
}
168+
nPos += Character.charCount( nCodePoint );
169+
}
170+
171+
if ( bNumeric )
172+
{
173+
writeNumericSegment( out, stripLeadingZeros( sbDigits.toString( ) ) );
174+
}
175+
else
176+
{
177+
out.write( TYPE_TXT );
178+
writeEscapedAll( out, collatorPrimary.getCollationKey( strInput.substring( nStart, nPos ) ).toByteArray( ) );
179+
}
180+
}
181+
}
182+
183+
/**
184+
* Writes a numeric segment as a two-byte digit-count prefix followed by the digits. Putting the count first makes
185+
* "more digits means greater" hold under plain byte comparison, so values of any magnitude are supported without
186+
* parsing and without overflow.
187+
*
188+
* @param out
189+
* the target stream
190+
* @param strDigits
191+
* the digits, leading zeros already removed
192+
*/
193+
private static void writeNumericSegment( ByteArrayOutputStream out, String strDigits )
194+
{
195+
out.write( TYPE_NUM );
196+
197+
int nCount = Math.min( strDigits.length( ), MAX_DIGITS );
198+
writeEscaped( out, (byte) ( nCount >>> 8 ) );
199+
writeEscaped( out, (byte) nCount );
200+
201+
for ( int i = 0; i < nCount; i++ )
202+
{
203+
writeEscaped( out, (byte) strDigits.charAt( i ) );
204+
}
205+
}
206+
207+
/**
208+
* Writes a collation key, escaping the reserved bytes.
209+
*
210+
* @param out
211+
* the target stream
212+
* @param bytes
213+
* the raw bytes to escape
214+
*/
215+
private static void writeEscapedAll( ByteArrayOutputStream out, byte [ ] bytes )
216+
{
217+
for ( byte b : bytes )
218+
{
219+
writeEscaped( out, b );
220+
}
221+
}
222+
223+
/**
224+
* Escapes the two reserved bytes, preserving the relative order of the original bytes so that byte-wise comparison
225+
* of escaped sequences matches byte-wise comparison of the originals.
226+
*
227+
* @param out
228+
* the target stream
229+
* @param b
230+
* the byte to write
231+
*/
232+
private static void writeEscaped( ByteArrayOutputStream out, byte b )
233+
{
234+
if ( b == SEP )
235+
{
236+
out.write( ESC );
237+
out.write( 0x01 );
238+
}
239+
else
240+
if ( b == ESC )
241+
{
242+
out.write( ESC );
243+
out.write( 0x02 );
244+
}
245+
else
246+
{
247+
out.write( b );
248+
}
249+
}
250+
251+
/**
252+
* Removes leading zeros, keeping at least one digit so that {@code "000"} reduces to {@code "0"}.
253+
*
254+
* @param strDigits
255+
* the digit run
256+
* @return the digit run without leading zeros
257+
*/
258+
private static String stripLeadingZeros( String strDigits )
259+
{
260+
int i = 0;
261+
262+
while ( i < strDigits.length( ) - 1 && strDigits.charAt( i ) == '0' )
263+
{
264+
i++;
265+
}
266+
267+
return strDigits.substring( i );
268+
}
269+
}

0 commit comments

Comments
 (0)