Feature #9305
Replace begins with interval based conditional
100%
History
#1 Updated by Alexandru Lungu almost 2 years ago
This is deferred work from #8495 and refers to the replacement of expr1 begins expr2 with expr1 >= low_bound and expr1 < lowbound + 1 (pseudo-code used). There are some conditions that should be accounted for like expr1 should be indexed or stuff like that. In #8495, this optimization proved to drastically improve time, because it didn't use the slow begins UDF anymore. The customer applied the change manually to their legacy code, but the goal of this task is to improve FqlPreprocessor in FWD to automatically apply this optimization, when possible.
#3 Updated by Alexandru Lungu over 1 year ago
The conversion of a simple find first tt where tt.f1 begins tst yields:
new FindQuery(tt, "upper(tt.f1) like ?", null, "tt.f1 asc", new Object[]
{
toUpperCase(convertToSQLBegins(tst))
}, LockType.NONE).silentFirst();
The actual SQL has (upper(tt_1_1__im0_.f1) like ?) clause. At plan time, PG doesn't know this is a "prefix" and most probably it will plan it as a seq-scan. The parameter is indeed like "abc%", but PG finds out that too late. I wonder if we should rather inline the parameter if used for begins. I think it is always sub-optimal to favor parsing and planning time with a prepared statement at the cost of not knowing this is a "prefix" search.
- if the pattern is
tt.f1 like ?, then we should inline - if the pattern is
tt.f1 line <expr>then better solutions are needed (maybe the one from #8495).
#5 Updated by Eric Faulhaber over 1 year ago
Alexandru Lungu wrote:
The conversion of a simple
find first tt where tt.f1 begins tstyields:
[...]The actual SQL has
(upper(tt_1_1__im0_.f1) like ?)clause. At plan time, PG doesn't know this is a "prefix" and most probably it will plan it as a seq-scan. [...]
Don't we need this to be (upper(rtrim(tt_1_1__im0_.f1)) in order to give the database a chance to match an index? At least, for dialects which compose indices using upper(rtrim(...)).
#6 Updated by Ovidiu Maxiniuc over 1 year ago
I was a bit puzzled why isn't BEGIN implemented as an UDF?
I mean that was my first reaction, to see how udf.begins(a text, b text) (for PSQL) is implemented and whether it can be improved.
I am thinking also of constructs like
find first tt where tt.f1 begins tt.f2where both operands are on SQL server side, or even the other way around:
find first tt where tst begins tt.f2
So I assume the UDF performance has already been explored. Right?
#7 Updated by Alexandru Lungu over 1 year ago
Don't we need this to be (upper(rtrim(tt_1_1__im0_.f1)) in order to give the database a chance to match an index? At least, for dialects which compose indices using upper(rtrim(...)).
You are right. However, I tested now some SQLs in PgAdmin4 and both upper(f1) like 'ABC%' and upper(rtrim(f1)) like 'ABC%' use the scan index. upper(rtrim(f1)) like 'ABC' uses the scan index and upper(f1) like 'ABC' doesn't. I agree however that this is a conversion bug.
Interestingly is also that find first tt where tt.f1 = 'ABC' converts to new FindQuery(pt, "upper(pt.f1) = 'ABC'", null, "pt.f1 asc").first(); without rtrim. Something is not right .... Is the rtrim added at run-time?
So I assume the UDF performance has already been explored. Right?
Not yet, but I doubt the planner will resolve the UDF to an index scan. I think the UDF are quite opaque to the planner and if it simply plans where udf.begins(tt.f1, "abc"), then it will most probably say that this is a seq scan as it doesn't know the range properties of begins in regard to the b-tree. It seems it doesn't even know to resolve "like 'abc%'" for that matter :)
#8 Updated by Eric Faulhaber over 1 year ago
Alexandru Lungu wrote:
Don't we need this to be (upper(rtrim(tt_1_1__im0_.f1)) in order to give the database a chance to match an index? At least, for dialects which compose indices using upper(rtrim(...)).
You are right. However, I tested now some SQLs in PgAdmin4 and both
upper(f1) like 'ABC%'andupper(rtrim(f1)) like 'ABC%'use the scan index.upper(rtrim(f1)) like 'ABC'uses the scan index andupper(f1) like 'ABC'doesn't. I agree however that this is a conversion bug.
I assume from these mixed results that you are testing this on an analyzed table with a lot of data. The PostgreSQL query planner will sometimes decide a table scan is faster for a table with few rows, or if it decides the table statistics call for that plan. It also considers the various cost settings, which can be configured in postgresql.conf, though I've never changed these from the defaults myself. That being said, I don't know the details of this algorithm.
I don't know how other database implementations come up with their query plans, but I expect there are similar considerations in their algorithms.
All this is to say that we have to test with a typical (I know that's vague) data set to be able to safely draw conclusions about the query planner's decisions. By "typical", I suppose that means a lot of rows with a good distribution of values for the columns we are using.
Interestingly is also that
find first tt where tt.f1 = 'ABC'converts tonew FindQuery(pt, "upper(pt.f1) = 'ABC'", null, "pt.f1 asc").first();without rtrim. Something is not right .... Is the rtrim added at run-time?
Yes, it is injected by FQLPreprocessor if needed.
BTW, I've considered the idea of doing the same with upper, instead of emitting it during conversion, depending on whether the target field is case-insensitive or not. But, we would have to be consistent and do the same with the opposite operand, which often is a substitution parameter, but can be a more complex expression. In the end, it would have meant doing a lot of the same work at runtime we currently do during conversion. What prevented me from going down this path was a combination of inertia and a concern for hurting runtime performance.
So I assume the UDF performance has already been explored. Right?
Not yet, but I doubt the planner will resolve the UDF to an index scan. I think the UDF are quite opaque to the planner and if it simply plans
where udf.begins(tt.f1, "abc"), then it will most probably say that this is a seq scan as it doesn't know the range properties ofbeginsin regard to the b-tree. It seems it doesn't even know to resolve "like 'abc%'" for that matter :)
Whether or not the UDF would be opaque I believe depends on the language used and the complexity of the UDF implementation.
A simple UDF implemented with pure SQL (as opposed to a procedure language) has better odds of being properly analyzed by a query planner, but it depends on a database's query planner implementation.
I agree that the odds of a good query plan generally are better if the query can be expressed with simple operands and operators, instead of a UDF. If we do use a UDF, this will require a lot more testing across dialects to ensure we are getting good query plans. If we can avoid a UDF and get the correct functional behavior with native operators, I think that is the way to go.
#9 Updated by Alexandru Lungu over 1 year ago
- Priority changed from Normal to High
#10 Updated by Alexandru Lungu over 1 year ago
I assume from these mixed results that you are testing this on an analyzed table with a lot of data. The PostgreSQL query planner will sometimes decide a table scan is faster for a table with few rows, or if it decides the table statistics call for that plan. It also considers the various cost settings, which can be configured in postgresql.conf, though I've never changed these from the defaults myself. That being said, I don't know the details of this algorithm.
I was running on #9690 analyzed table with 3.5k rows. I can't say this is "a lot of data", but enough to separate a table scan from an index scan.
I can agree that planning can be different according to index selectivity and other statistics analyzed.
Yes, it is injected by FQLPreprocessor if needed.
Huh, dodged a bullet here. I really though we messed up with this. Just checked today morning and indeed, SQL queries end up having rtrim injected.
After a super-fast internet search, it seems like text datatype we choose for character legacy fields uses a default operator class that is limited in terms of planning with LIKE.
According to https://www.postgresql.org/docs/current/indexes-opclass.html
text_pattern_opsis an operator class that can be set per-field in index and so it organizes the data to satisfyLIKEqueries faster. I did such attempt and indeed,LIKE 'A%'is using that index withtext_pattern_opsfield.
However, there is the following note:
The difference from the default operator classes is that the values are compared strictly character by character rather than according to the locale-specific collation rules.
But at the same time:
If you do use the C locale, you do not need the xxx_pattern_ops operator classes, because an index with the default operator class is usable for pattern-matching queries in the C locale.
Finally, inside https://www.postgresql.org/docs/current/locale.html
The locale settings influence the following SQL features: * [...] * The ability to use indexes with LIKE clauses The drawback of using locales other than C or POSIX in PostgreSQL is its performance impact. It slows character handling and prevents ordinary indexes from being used by LIKE. For this reason use locales only if you actually need them.
This is getting messy. The database I was testing with used a custom fwd_basic locale flavor (installed using the FWD guide). With such, pattern-matching queries aren't using the indexes with default operator class.
I also just tested with an UTF-8 database and guess what: the default operator indeed supports pattern-matching queries!
So I would say that begins constructs that end up as field LIKE 'A%' are going to work fast by default on databases that have C locales (like UTF-8). The workaround exists as documented:
As a workaround to allow PostgreSQL to use indexes with LIKE clauses under a non-C locale, several custom operator classes exist. These allow the creation of an index that performs a strict character-by-character comparison, ignoring locale comparison rules. Refer to Section 11.10 for more information. Another approach is to create indexes using the C collation, as discussed in Section 23.2.
But it has the obvious limitations: the comparison will not be locale sensitive and extra indexes are required (as text_pattern_ops ones do not support =, <, <=, >, >=).
Therefore, I think this task #9305 still stands, but it should be toggled so that customers with DB that use C locale can use the native support for pattern matching.
#11 Updated by Alexandru Lungu over 1 year ago
- Status changed from New to Review
- % Done changed from 0 to 100
As a resolution for #9305, I updated 4GL_Database_Access_Performance_Tips. Please review.
#12 Updated by Alexandru Lungu over 1 year ago
- Assignee set to Alexandru Lungu
- reviewer Eric Faulhaber added
#13 Updated by Alexandru Lungu over 1 year ago
- % Done changed from 100 to 50
- Status changed from Review to WIP
- the indexes should be created with
varchar_pattern_opsdomain comparison, but this will limit usage of <, <=, >, >=. This forces us to duplicate indexes so that we have one with default text comparison and one withvarchar_pattern_opsthat can satisfy LIKE. I think the performance impact on index work will be significant, so I this is a bad lead.
Fixed 4GL_Database_Access_Performance_Tips.
Planning to assess #9305-1 considering Ovidiu's insight in #9305-6.
#15 Updated by Stefanel Pezamosca over 1 year ago
From #10165:
For temp-table (H2 database), LIKE 'prefix%' does not use an index at the moment. I suspect it is because the BEGINS is turned into something like (upper(tt_1_1__im0_.word) like 'PREFIX%') and H2 does not know how to handle upper(...) in an index.
In H2 the condition is (UPPER(TT_1_1__IM0_.WORD) LIKE CAST('PREFIX%' AS VARCHAR_IGNORECASE))
This case will be fully fixed if we remove the upper(...) call and send only (tt_1_1__im0_.word like 'PREFIX%') as it is already a VARCHAR_IGNORECASE. This way the H2 planner will use this:
/* PUBLIC.IDX__TT1_IDX_WORD__1: _MULTIPLEX = ?1
AND WORD >= 'PREFIX'
AND WORD < 'PREFIY'
*/
#17 Updated by Teodor Gorghe 11 months ago
Alexandru,
You told yesterday and in #9305-10 that the default database configuration, with en_us.UTF8 collation, the LIKE operator should work. I think the actual configuration that you tested was actually a C or pg_c_utf8 collation, which LIKE operator works by default with B-TREE indexes.
The thing is that there is a configuration on a customer, with en_us.UTF8 and the LIKE operator is slow. en_US.UTF8 is a libc collation, which the LIKE operator will use the sequential scan.
There is pg_unicode_fast, which was introduced in PSQL 18, where the range and LIKE operator will use the index.
#18 Updated by Eduard Soltan 11 months ago
Teodor Gorghe wrote:
You told yesterday and in #9305-10 that the default database configuration, with
en_us.UTF8collation, theLIKEoperator should work. I think the actual configuration that you tested was actually aCorpg_c_utf8collation, whichLIKEoperator works by default withB-TREEindexes.
The thing is that there is a configuration on a customer, withen_us.UTF8and theLIKEoperator is slow.en_US.UTF8is a libc collation, which theLIKEoperator will use the sequential scan.
Indeed with en_us.UTF8 locale on LIKE does not use index scan. I also created a separate index with text_pattern_ops operator class, and this actually helped index usage. Since the duplicating number of indices is not a viable solution, I guess we are left with the #9305-1.
#19 Updated by Teodor Gorghe 11 months ago
The range check follows the field collation or is byte-by-byte?
If it is after collation, there are some cases like foo-a, which goes before foo. Replacing LIKE with range check will skip foo-a. This is a specific en_US.UTF8 rule, but there are lots of rules in other languages in which the prefix is NOT an interval boundary.
#20 Updated by Alexandru Lungu 11 months ago
Please do the tests requested in #9305-25 in 4GL and PG with LIKE / < and > / with BETWEEN. We need to take a decision that is functionally sound.
#22 Updated by Teodor Gorghe 6 months ago
This is one difference between BEGINS and range check, on BASIC collation:
DEFINE TEMP-TABLE tt1 NO-UNDO
FIELD f1 AS CHARACTER
INDEX idx1 IS PRIMARY f1.
CREATE tt1. tt1.f1 = "ORD".
CREATE tt1. tt1.f1 = "ORD-123".
FOR EACH tt1 WHERE tt1.f1 BEGINS "ORD ":
DISPLAY tt1.f1 "BEGINS".
END.
FOR EACH tt1 WHERE tt1.f1 >= "ORD " AND tt1.f1 < "ORE":
DISPLAY tt1.f1 "RANGE CHECK".
END.
4GL output:
ORD BEGINS ORD RANGE CHECK ORD-123 RANGE CHECK
FWD output:
ORD BEGINS ORD-123 BEGINS ORD RANGE CHECK ORD-123 RANGE CHECK
Something might be wrong at conversion, BEGINS is converted into upper(tt1.f1) like 'ORD%'.
Also, it seems that when using BEGINS, 4GL takes the shorter operand in length and then it appends with spaces to match the length with the other operand. This is why the "ORD" is matched with BEGINS "ORD ".
I will find some other cases where BEGINS and range check is different.
#23 Updated by Teodor Gorghe 6 months ago
This is another example of RANGE CHECK vs BEGINS, using UTF-8 collation on 4GL:
DEFINE TEMP-TABLE tt1 NO-UNDO
FIELD f1 AS CHARACTER
INDEX idx1 IS PRIMARY f1.
CREATE tt1. tt1.f1 = "luna".
CREATE tt1. tt1.f1 = CHR(322, "UTF-8", "UTF-32") + "una".
CREATE tt1. tt1.f1 = "macaw".
DISPLAY
"Session Collation: " SESSION:CPCOLL FORMAT "x(20)" SKIP(1)
WITH FRAME fTop NO-BOX NO-LABELS WIDTH 60.
DISPLAY "--- WHERE f1 >= 'l' AND f1 < 'm' ---" FORMAT "x(40)"
WITH FRAME fRange NO-BOX NO-LABELS.
FOR EACH tt1 WHERE f1 >= "l" AND f1 < "m":
DISPLAY tt1.f1 FORMAT "x(15)" WITH FRAME fBody1 DOWN NO-LABELS.
END.
DISPLAY "--- WHERE f1 BEGINS 'l' ---" FORMAT "x(40)"
WITH FRAME fBegins NO-BOX NO-LABELS.
FOR EACH tt1 WHERE f1 BEGINS "l":
DISPLAY tt1.f1 FORMAT "x(15)" WITH FRAME fBody2 DOWN NO-LABELS.
END.
With UTF-8 basic collation:
RANGE CHECK: luna BEGINS: luna
With UTF-8 ICU collation:
RANGE CHECK: luna, łuna BEGINS: luna
Notes:
- if you want to execute this test on 4GL, you need to use the prowin. If you try it from Developer Studio, it will corrupt the łuna string.
#24 Updated by Teodor Gorghe 6 months ago
I have also noticed that for Western characters, there is no difference between BASIC and ICU-UCA on RANGE CHECK/BEGINS.
I think this is due to the fact that long ago, there were no ICU-UCA implementation. Since some of Progress 4GL users are from Western Europe, they have implemented some sort of support for western characters like ß in the BASIC collation.
#25 Updated by Teodor Gorghe 6 months ago
I have also tested the difference between LIKE and RANGE CHECK on PSQL.
When the libc collation provider is being used, there is no difference between LIKE and RANGE CHECK.
When the collation provider ICU is used, there are some differences, but it depends on the ICU rules.
I have tested with Polish rules and I see that the LIKE operator displays luna, but the range check displays both luna and łuna.
The surprise for me is that the English United States ICU rules displays the same results as when using the Polish ones.
#27 Updated by Alexandru Lungu about 1 month ago
- Assignee changed from Alexandru Lungu to Stefanel Pezamosca
From https://www.postgresql.org/docs/current/functions-string.html
text ^@ text → boolean Returns true if the first string starts with the second string (equivalent to the starts_with() function). 'alphabet' ^@ 'alph' → t
According to Gemini, this is an indexed operator under standard Btree with any collation. I can't find any official documentation about it on PG. We need experiments.
#28 Updated by Alexandru Lungu about 1 month ago
PS: Of course, this would be a PG only fix.
#29 Updated by Teodor Gorghe about 1 month ago
Using starts_with instead of like operator might improve the performance, but please mind that we need to fully support BEGINS operator in the future, which pinpoints directly to #9305-22. In short terms, 4GL extends each character field to the according format and then uses BEGINS operator.
#30 Updated by Alexandru Lungu about 1 month ago
I added Stefanel as assignee to relate with #5219 work and whether there are certain things we can do in PG to improve this BEGINS saga. We need to fully understand what 4GL does behind the scenes for BEGINS (collation-wise, formatting, case-insensitive, etc.) and try to match that in PG within the most performant solution. The urgency of this rises as this occurred already at 2 in-production customers and potentially in #11686. It is common to have a "search fill-in" that does a BEGINS to search for records that begin with the input.
However, please also consider that most customers ported their single-byte databases to UTF-8 collation databases and accepted mismatches that could occur functionally along the way (e.g. sorting of data). Thus, we also need to hunt a solution that makes "most sense" for UTF-8 migrated customers (is fast and it works). Greg/Eric, please correct me on this point otherwise.
In short terms, 4GL extends each character field to the according format and then uses BEGINS operator.
The range check / interval based conditional was an work-around. The more I think about it, the less confident I am that it is a good solution. I really can't belive that we can't make PG (at least) be fast with a BEGINS counter-part. I understand that LIKE "probe%" is not indexed under certain collations, but how does a modern application manages to implement such functionality fast. I can't take "it can't" as an answer. If it is about special indexes, then lets have special indexes emitted (e.g. detect at conversion time BEGINS clauses in reports, use schema hints, use run-time statistics to identify tables that are target to BEGINS, etc.). But if there is a quick win like starts_with or ^@ operator, then lets go for the quick win.
#31 Updated by Teodor Gorghe about 1 month ago
I have a doubt that starts_with works for simple collations like C, but it may not work for libc collations.
#32 Updated by Alexandru Lungu about 1 month ago
- Priority changed from High to Urgent
#33 Updated by Alexandru Lungu about 1 month ago
#11686 is confirmed to be related to this BEGINS task.
#34 Updated by Greg Shah about 1 month ago
However, please also consider that most customers ported their single-byte databases to UTF-8 collation databases
Some have but not more than half. When running with UTF-8, our objective is to be compatible with OE running UTF-8. I don't want us to add special cases or quick fixes that break full compatibility.
The customer expectation is that if they run on both OE and FWD with UTF-8, that it will work the same way.
#35 Updated by Greg Shah about 1 month ago
Let's focus on testcases. These need to be designed to test the results across multiple encodings.
#36 Updated by Stefanel Pezamosca about 1 month ago
As I found out in #5219 also, in an indexed field an accented character is considered equal to the normal one.
The below report is aggregated with AI assistance, but I took time to upgrade my posgresql to a newer version and to make sure the results are as accurate as possible, but still may contain wrong information.
| Form | SQL emitted | Index |
|---|---|---|
| FWD | upper(col) like 'ZZ%' |
(upper(rtrim(col)), recid) — what FWD builds today |
| range | upper(col) >= 'ZZ' and upper(col) < bound |
the same index |
| range + LIKE | both of the above | the same index |
| text_pattern_ops | upper(col) like 'ZZ%' |
a second index with the text_pattern_ops opclass |
^@ / starts_with() |
upper(col) ^ 'ZZ'@ |
the same second index, and a server that brackets it — 17 does, 14 does not |
OpenEdge does not agree with itself. The same BEGINS answers differently depending on whether an index serves it — on 17 of 34 patterns. There are two baselines, and a form can only imitate one.
| OpenEdge against itself | Result |
|---|---|
unindexed BEGINS vs the BEGINS operator |
34 of 34 — the same thing |
index-served BEGINS vs the operator |
17 of 34 |
index-served BEGINS is a contiguous window of the index |
34 of 34 |
index-served BEGINS dropping a row the operator keeps |
never |
The indexed route is a window opened over the index and handed back unfiltered: always a superset of the operator, never smaller. The extras are whatever the collation ties to the pattern — äbc and Äbc for BEGINS "abc", ssx for BEGINS "ß", the empty value for a blank pattern.
Output fidelity¶
What is being compared¶
41 values and 34 patterns, chosen so every rule BEGINS obeys has a value only that rule explains — case pairs, accents against their base letter, sharp-s against ss, blanks and the empty value, % and _ as ordinary data, a tab, a tilde. The forty-first value is unknown and no pattern reaches it on either engine.
OpenEdge answers each pattern twice, indexed and unindexed. Those 68 answers are the ground truth, re-measured on OpenEdge 12.8 (cpcoll=Basic, cpinternal=ISO8859-1) for this report and reproducing the stored baselines 68 of 68.
PostgreSQL gets the operand FWD would give it, built in Java before any SQL exists — toUpperCase(convertToSQLBegins(rightTrim(pat))) — so what separates the forms is the form and the collation, nothing else.
Measured on PostgreSQL 14.24 and again on 17.11. All 1 020 cells — six collations, five forms, 34 patterns — are identical. Nothing in this chapter depends on the server version.
The scores, under the collation FWD deploys¶
Out of 34 patterns, on en_US@iso88591_fwd_basic. Latin-1 and UTF-8 score identically.
| Form | Matches the OE operator | Matches the OE index bracket | Same rows as FWD's LIKE |
|---|---|---|---|
FWD today, LIKE |
29 of 34 | 16 of 34 | — |
| range, collation bound | 29 of 34 | 16 of 34 | yes, all 34 |
range, collation bound + LIKE |
29 of 34 | 16 of 34 | yes, all 34 |
range, code-point bound + LIKE |
29 of 34 | 16 of 34 | yes, all 34 |
text_pattern_ops |
29 of 34 | 16 of 34 | yes, all 34 |
| range, code-point bound, alone | 21 of 34 | 21 of 34 | no |
Every form that keeps the LIKE returns the same row ids FWD returns today, on all 34 patterns. A range changes the plan, not the answer — the LIKE stays in the predicate and re-checks whatever the bracket collected. PostgreSQL always re-checks, which is also why no form here can reproduce OpenEdge's unfiltered bracket. Confirmed at scale on the million-row table: every one- and two-character prefix present in the data, 702 of them against 20 001 distinct keys, zero disagreements.
The last row is the shape a careless range rewrite would take. Two bounds close the window:
| Bound | What BEGINS "a" becomes | Why |
|---|---|---|
| from the collation | >= 'A' and < 'À' |
À is the next character with a different sort weight, so the window stops at the end of the plain A run |
| from the code point | >= 'A' and < 'B' |
B is the next code point, and the accents À…Å sort between A and B, so they fall inside the window |
The code-point bound sweeps in the accented rows — closer to OpenEdge's index bracket, hence 21 against it rather than 16, but wrong on eight patterns more than the LIKE is. Pairing it with the LIKE removes the extras and returns it to 29.
Where FWD is wrong today¶
Five patterns, none of them about indexes, ranges or collations. Every form above fails them identically.
| Pattern | OpenEdge returns | FWD returns | Cause |
|---|---|---|---|
BEGINS "abc " |
"abc " |
all 11 values starting abc |
rightTrim strips the pattern's trailing blank, so the blank stops being significant |
BEGINS " " |
" ", " ", " abc" |
all 40 values | rightTrim empties the pattern; LIKE '%' then matches everything |
BEGINS " " |
" ", " abc" |
all 40 values | same |
BEGINS "ß" |
"ßx" |
"ssx" |
Java's toUpperCase expands ß to SS; PostgreSQL's upper() leaves the column's ß alone |
BEGINS "ÿ" |
"ÿx" |
nothing, or a server error | Java's toUpperCase maps ÿ to Ÿ (U+0178), which PostgreSQL's upper() never produces and Latin-1 cannot hold |
Two bugs. The pattern is right-trimmed and the value is not, so blanks stop being comparable. And the pattern is uppercased by Java while the column is uppercased by PostgreSQL, which disagree wherever a character has no one-to-one uppercase. Both are independent of the index question.
Collation¶
The collation changes results, not only speed. Same 34 patterns, scored against the OpenEdge operator, on the UTF-8 databases.
| Form | FWD custom | standard en_US | C |
|---|---|---|---|
LIKE — what FWD emits today |
29 | 30 | 25 |
| range, collation bound | 29 | 9 | 25 |
range, collation bound + LIKE |
29 | 30 | 25 |
| range, code-point bound | 21 | 7 | 25 |
range, code-point bound + LIKE |
29 | 18 | 25 |
| FWD custom | standard en_US | C | |
|---|---|---|---|
| Where accents sort | own weight, right after the base letter | tied with the base letter | after Z, at their code points |
| Ties accents to the base letter, as OpenEdge does | no | yes | no |
upper() folds accented letters |
yes | yes | no |
| A range bound that selects exactly the operator's rows | exists | none exists | exists |
Default index can serve LIKE |
no | no | yes |
C is not output-neutral. Its LC_CTYPE leaves accents alone, so upper('äbc') comes back unchanged while the pattern is already 'Äbc'. Four more patterns break than under FWD's collation (ä, Ä, ç, é), taking LIKE from 29 to 25. Switching to C so the existing index can serve LIKE buys the index with silently wrong results on accented data.
Standard en_US admits no exact range bound. Its accents tie with the base letter, so the next distinct weight after A is b — a window from A runs all the way to b and takes every a-led value with it. Hence 9 of 34 for a bare range. Paired with the LIKE it is correct again at 30 of 34; what it loses is selectivity, not correctness.
A code-point bound is not always above the rows it bounds. Incrementing the last character gives a string larger by code point, which under a linguistic collation need not be larger at all: BEGINS "z" becomes >= 'Z' and < '[', and en_US sorts [ before the letters — empty window, row lost. Likewise BEGINS "a_" (_ to `) and BEGINS "9" (9 to :). Twelve patterns lose rows this way, taking range + LIKE to 18 of 34, and the LIKE cannot rescue them: it removes rows the range collected, it cannot restore rows the range skipped. A bound must be derived from the collation in force.
Encoding matters on exactly one pattern. BEGINS "ÿ" uppercases to Ÿ, which Latin-1 cannot represent — the Latin-1 database rejects the statement, the UTF-8 one quietly returns nothing. Under standard en_US on UTF-8 the pattern works, because glibc uppercases the column's ÿ the same way Java uppercased the pattern; that single cell is the whole 30-versus-29 difference above. On the FWD collation and on C the two encodings score the same.
Performance¶
Retrieval shapes¶
| Shape | Rows | OpenEdge | FWD | range | range + LIKE | pattern_ops |
|---|---|---|---|---|---|---|
FIND FIRST, front prefix |
1 | 0.000 | 0.037 | 0.036 | 0.096 | 0.107 |
FIND FIRST, back prefix |
1 | 0.000 | 134.3 | 0.032 | 0.523 | 0.534 |
FIND LAST, back prefix |
1 | 0.005 | 0.034 | 0.023 | 0.542 | 0.549 |
FOR EACH, front prefix, all |
99 | 0.075 | 41.2 S | 0.072 | 0.085 | 0.101 |
FOR EACH, back prefix, all |
1 450 | 0.970 | 42.7 S | 0.295 | 0.409 | 0.423 |
FOR EACH, 3.8% of table |
38 499 | 34.0 | 43.4 S | 6.33 | 8.86 | 8.95 |
FOR EACH, back prefix, first 10 |
10 | 0.010 | 134.5 | 0.039 | 0.532 | 0.537 |
FOR EACH, ordered, back prefix |
1 450 | 0.985 | 42.9 S | 0.880 | 0.997 | 1.003 |
FOR EACH, no match |
0 | 0.000 | 43.5 S | 0.039 | 0.041 | 0.046 |
FOR EACH, empty pattern |
1 000 000 | 887.5 | 59.7 S | 61.7 S | 86.0 S | 58.0 S |
FWD is fast only where the prefix sits at the end of the index it walks from — FIND FIRST on a front prefix, FIND LAST on a back prefix. At the other end it costs 134 ms, walking the index from the wrong end and filtering. The range holds up on every early-exit shape; adding the LIKE gives that back, landing near 0.53 ms alongside text_pattern_ops.
Selectivity¶
| Rows matched | OpenEdge | FWD | range | range + LIKE | pattern_ops |
|---|---|---|---|---|---|
| 1 | 0.005 | 43.7 S | 0.055 | 0.059 | 0.061 |
| 10 | 0.010 | 43.4 S | 0.059 | 0.062 | 0.061 |
| 100 | 0.070 | 44.1 S | 0.075 | 0.087 | 0.086 |
| 1 000 | 0.750 | 44.2 S | 0.199 | 0.293 | 0.280 |
| 10 000 | 8.34 | 44.6 S | 1.35 | 2.06 | 2.15 |
| 100 000 | 83.0 | 46.7 S | 9.14 | 12.09 | 12.04 |
Index covering three fields¶
| Search | Rows | OpenEdge | FWD | range | range + LIKE | pattern_ops |
|---|---|---|---|---|---|---|
BEGINS on a |
10 000 | 55.1 | 30.8 S | 11.03 | 11.23 | 12.26 |
a exact, BEGINS on b |
100 | 0.085 | 11.02 | 0.435 | 0.439 | 0.465 |
a and b exact, BEGINS on c |
1 | 0.005 | 0.453 | 0.052 | 0.058 | 0.061 |
a exact, BEGINS on c, b skipped |
100 | 50.7 | 11.11 | 1.030 | 1.076 | 0.670 |
BEGINS on b only |
10 000 | 472.2 | 34.7 S | 59.0 S | 61.8 S | 34.9 S |
BEGINS on a and on b |
100 | 51.3 | 35.2 S | 1.622 | 1.609 | 0.994 |
range on a, BEGINS on b |
100 | 52.1 | 11.42 | 1.318 | 1.311 | 59.7 S |
FIND FIRST, a exact, BEGINS on b |
1 of 100 | 0.005 | 0.601 | 0.046 | 0.508 | 0.507 |
^@ and starts_with()¶
The one place the PostgreSQL version matters. Each cell reads PostgreSQL 14 / 17.
| Form | The index FWD builds | text_pattern_ops | SP-GiST |
|---|---|---|---|
LIKE |
scan / scan | index / index | index / index |
^@ |
scan / scan | scan / index | index / index |
starts_with() |
scan / scan | scan / index | scan / index |
On 14 starts_with has no prosupport entry, so nothing rewrites the call into an index bracket; on 17 it carries text_starts_with_support. ^@ on SP-GiST worked on 14 through the operator family rather than a rewrite. In throughput: 54 queries/sec rising to 19 382. Whether 15 or 16 introduced the support function was not tested — neither is installed here.
Neither operator can use the index FWD builds, on either version. Both need text_pattern_ops or SP-GiST, exactly as LIKE does. PostgreSQL 17 adds spellings, not index options.
One caveat on the timings above: PostgreSQL 17 chooses a parallel scan where 14 chose a plain one, which halves every S cell. With parallelism disabled the two versions are within one percent of each other, and at eight concurrent clients FWD measures 62 queries/sec on both. The upgrade spends more cores on the same work; it does not make BEGINS cheaper.
#37 Updated by Greg Shah about 1 month ago
I read #9305-36. The output seems useful but I found it hard to assess much of what was related. It uses quite a bit of jargon (terms that are undefined in this task), it doesn't map the results to example code in a way that is obvious, there are a lot of references to indexes and forms without really explaining what those are and how they relate to the problem; even the problem being analyzed is not well defined. Please provide much more context for us to allow for better comprehension.
#38 Updated by Stefanel Pezamosca about 1 month ago
From what I understand, if we want to keep using `LIKE`, or switch to `starts_with`, we still need either a supporting collation or an index using `text_pattern_ops`. So, I guess we could detect any columns/tables that would need such special index, like Alex mentioned.
If it is about special indexes, then lets have special indexes emitted (e.g. detect at conversion time BEGINS clauses in reports, use schema hints, use run-time statistics to identify tables that are target to BEGINS, etc.).
The `range` option would be the quickest win. We could also use a `range` with a wider window and then filter out the remaining outliers with an `and LIKE ` condition, e.g. `(upper(col) >= 'ZZ' AND upper(col) < bound AND upper(col) LIKE 'ZZ%')`.
I read #9305-36. The output seems useful but I found it hard to assess much of what was related. It uses quite a bit of jargon (terms that are undefined in this task), it doesn't map the results to example code in a way that is obvious, there are a lot of references to indexes and forms without really explaining what those are and how they relate to the problem; even the problem being analyzed is not well defined. Please provide much more context for us to allow for better comprehension.
I actually trimmed and rewrote a lot of the information that I felt wasn’t useful. Here’s a more detailed report:
FOR EACH item NO-LOCK WHERE item.itemName BEGINS "abc":
OpenEdge answers it from an index. FWD reads the whole table:
Parallel Seq Scan on item Filter: (upper(rtrim(item_name)) ~~ 'ABC%'::text)
999 900 of the million rows are read and discarded to return 100. That is 36.3 ms instead of 0.085 ms, 9 346 disk pages instead of 6, and 62 queries/sec instead of 64 158 at eight clients. The cost tracks how many rows exist, not how many match, so it does not improve as the search narrows.
How that statement becomes SQL — the full chain
Why the index is not used¶
Conversion emits upper(col) like 'ABC%' against an expression index (upper(rtrim(col)), recid). PostgreSQL can rewrite a prefix LIKE into an index range, but only where that is provably correct: the index collation must be C/POSIX, or the index must use a *_pattern_ops operator class.
The options¶
All four return the same rows. They differ in what the planner can do and what they cost to adopt.
| Option | SQL emitted | Uses FWD's existing index | Needs a new index | Needs PostgreSQL 17 |
|---|---|---|---|---|
| FWD today | upper(col) like 'ABC%' |
no — full scan | — | — |
| range | upper(col) >= 'ABC' and upper(col) < 'ABÇ' |
yes | no | no |
text_pattern_ops |
upper(col) like 'ABC%' |
no | yes | no |
^@ / starts_with() |
starts_with(upper(col), 'ABC') |
no | yes | yes |
The two operator forms need a second index on the same expression with the text_pattern_ops opclass — the same index a LIKE would need — so they add nothing LIKE could not already have, and before PostgreSQL 17 they are not bracketed at all.
Recommendation¶
Emit the range, with the upper bound derived from the collation. It is the only option that works with the index FWD already builds, on the PostgreSQL version already deployed.
| Measure | FWD today | range |
|---|---|---|
FOR EACH, the example above, 100 rows |
36.3 ms | 0.085 ms |
FOR EACH, a wider prefix, 1 450 rows |
42.7 ms | 0.295 ms |
FIND FIRST, prefix at the far end of the index |
134.3 ms | 0.032 ms |
| Pages read, the example above | 9 346 | 6 |
| Queries/sec, 8 clients, the example above | 62 | 64 158 |
| Rows returned, 34 test patterns | baseline | identical on all 34 |
The range is also the only form that keeps FIND FIRST and FIND LAST fast, because it is the only one the planner can serve with an ordered index scan that stops at the first row.
Two conditions on it¶
The bound must come from the collation, never from incrementing the last character. BEGINS "z" would become >= 'Z' and < '[', and standard en_US sorts [ before the letters — empty window, row silently lost. That mistake costs 12 of the 34 patterns.
The rewrite is safe under the collation FWD deploys, not under every collation. Under a standard en_US collation the accents tie with the base letter, so a window from A must run all the way to b and a bare range scores 9 of 34.
Reading the two OpenEdge columns in #9305-36¶
/* A: an index serves the predicate */ FOR EACH item NO-LOCK WHERE item.itemName BEGINS "abc": /* B: index bypassed, so the operator runs per row */ FOR EACH item NO-LOCK USE-INDEX pk: IF item.itemName BEGINS "abc" THEN ...
With abc, ABC, Abc, äbc, Äbc in the table, A returns five rows, B returns three. A brackets the index over collation weights — where ä weighs the same as a — and returns that window as its answer; B compares characters and keeps the distinction. They disagree on 17 of the 34 patterns. This is the #5219 observation.
FWD reproduces B — which is also what OpenEdge itself gives whenever no index serves the query.
"28 of 34" in those tables means 28 of the 34 fixed patterns returned exactly OpenEdge's rows. #36 prints 29 for that figure; 28 is the corrected value, because FWD's rightTrim strips a trailing tab as well as trailing blanks and so breaks one pattern more than I had modelled. S marks a plan that read the whole table. Times are milliseconds, best of five warm runs.
#39 Updated by Greg Shah about 1 month ago
Thank you, that is very helpful.
In regard to the "two conditions": if the WHERE clause needs to be rewritten based on the collation, then I think this must be done at runtime since converted code can be used with multiple collations. Is that the idea?
Do any of the 3 solutions deliver a more complete solution? From a functional perspective, are there limitations on the 4GL compatibility of these options?
#40 Updated by Stefanel Pezamosca about 1 month ago
Considering that the PostgreSQL planner for LIKE with text_pattern_ops already does something similar to a range + LIKE:
Index Cond: ((upper(rtrim((item_name)::text)) ~>=~ 'ABC'::text)
AND (upper(rtrim((item_name)::text)) ~<~ 'ABD'::text))
Filter: (upper(rtrim((item_name)::text)) ~~ 'ABC%'::text)
I think we should do the same. But, I will also test without the LIKE filter maybe I get better results comparing to OE.If no objection a will start implementing it this way. The only limitation would be how the collations and accented words are handled.
#41 Updated by Stefanel Pezamosca 29 days ago
In task branch 9305a revision 16704 I committed the first stable iteration of begins rewrite to range.
This should not regress trunk, but also fixes some other edgecases.
I'm currently in the process of validating and fixing some points from the AI self-review.
The changes work well for most cases, but some edge cases are still failing, including some issues related to handling patterns with trailing spaces.
This is how it currently works:
Every indexed BEGINS was a sequential scan¶
Trunk emits upper(name) like 'AB%'. The functional index Postgres builds for a case-insensitive character field is on expression upper(rtrim("name"::text)), but Postgres will only satisfy LIKE 'prefix%' from a btree created with text_pattern_ops. I think that using text_pattern_ops has more limitations than the flexibility offered by the range implementation.
9305a brackets the same expression as a range, which the existing index does serve:
trunk: upper(name) like 'AB%'
9305a: (upper(rtrim(name)) >= upper('ab') and upper(rtrim(name)) <= udf.upper_bound(upper('ab')))
For special cases a LIKE filter will be emitted:
(upper(rtrim(name)) >= upper('ab') and upper(rtrim(name)) <= udf.upper_bound(upper('ab'))) and upper(name) LIKE upper('ab%')
Case folding in the JVM disagreed with the folding in the database¶
Trunk folds the operand with Java toUpperCase() while the column side folds with SQL upper(). The two are not the same function: Java expands ß to SS, Postgres upper() leaves it alone.
name BEGINS "straße" trunk: upper(name) like 'STRASSE%'
column: upper('Straße') = 'STRAßE' → no rows, ever
9305a: upper(name) like upper('straße')
9305a stops folding BEGINS operands in the JVM (expressions.rules, where_clause.rules) and emits upper(...) in SQL instead, so both sides fold with the same function.
A pattern ending in the escape character crashed¶
convertToSQLLike(String, boolean, boolean) and convertToRegEx(String, boolean)+ 1)@ with no bounds check:
name MATCHES "ab~" trunk: StringIndexOutOfBoundsException
9305a: escape discarded → 'AB%' (Progress behaviour)
The same crash occurred for a trailing \ on non-Windows, where the backslash falls through to the '~' case.
An escaped tilde lost its tilde¶
The inner look-ahead switch consumed only * and ., so both tildes of ~~ were treated as escapes and dropped:
name MATCHES "a~~b" trunk: 'AB%' -- tilde vanished
9305a: 'A~B%' -- escaped tilde is a literal tilde
#42 Updated by Stefanel Pezamosca 26 days ago
- % Done changed from 50 to 80
UnitTests status for 9305a:¶
| trunk r16725 | 9305a r16726 + working tree | |
|---|---|---|
| tests found | 257 | 257 |
| successful | 198 | 205 |
| failed | 59 | 52 |
Regressions: 0. No test that passes on trunk fails on 9305a. The 9305a failure set is a strict subset of trunk's.
7 fixed¶
None of these come from the range rewrite — they're all from the rtrim/escaping work in SQLHelper.convertToSQLBeginsTrimmed and the rules:
| test | trunk | 9305a |
|---|---|---|
BeginsIndexedFieldTest.tabInsidePattern{Permanent,TempTable} |
20 rows, expected [29] | ✔ |
BeginsIndexedFieldTest.trailingTabPermanent |
20 rows, expected [29] | ✔ |
BeginsMatchesTest.mEscapedTilde{Permanent,Temp} |
[], expected [39] | ✔ |
BeginsMatchesTest.noMatchesPatternSpellsAloneTilde |
expected 39, got nothing | ✔ |
BeginsUnindexedFieldTest.sharpSpatternPermanent |
[31], expected [30] | ✔ |
52 common failures¶
5 - 9305a returns fewer rows (big improvement, still failing)¶
Trunk was catastrophically over-inclusive on blank patterns:
| case | expected | trunk | 9305a |
|---|---|---|---|
| blank-only (Perm) | 15,18,27,28 | all 40 | 18,27,28 — missing 15 |
| single-blank-pattern (Perm + TT) | 15,18,27,28 | all 40 | 18,27,28 — missing 15 |
| two-blank-pattern (Perm + TT) | 15,18,27,28 | all 40 | 18,28 — missing 15,27 |
1 - same count, different row¶
BeginsIndexedFieldTest.sharpSpatternPermanent: expected [30,31]; trunk [31], 9305a [30]. Sideways.
46 - byte-identical on both¶
- Accent/case folding (~30) — e-acute, c-cedilla, a-diaeresis, sharp-s, double-s, plain-c, plain-e, plain-ascii-prefix, same-prefix-uppercased, single-letter-uppercased, shorter-prefix, digit-led-prefix, control-*. All too few rows - the TextOps/CAPS codepage-table gap.
- Trailing-blank (4) — one-trailing-blank, two-trailing-blanks, trailing-blank-in-pattern. Disjoint: expected [1..6], both return [1,2,3,6,7,8,16,17,26,35,36].
- MATCHES temp-table (4) — m-empty-pattern, m-exact-no-wildcard, m-exact-trailing-blank too many; m-two-dots too few.
- Unindexed temp-table (2) — sharp-s, double-s, too many.
- Structural (5) —
beginsCombinedWithAsecondPredicate(2 rows, expected 5),dynamicQueryAnswersAsTheStaticOne,unknownPatternMeetsOnlyTheUnknownValue,aWideMatchesDoesNotRepairTheBracket,matchesSpellsTheOperatorAnswerNotTheBracket.
For now, I'm working on simplifying / cleaning up the branch and to do more performance testing.
#43 Updated by Stefanel Pezamosca 21 days ago
- % Done changed from 80 to 90
- Status changed from WIP to Review
- reviewer Alexandru Lungu, Teodor Gorghe added
- reviewer deleted (
Eric Faulhaber)
I have fixed more self-review issues. 9305a is currently based on trunk/16724. Last revision is 16728.
For now I think 9305a is stable enough.
Can you please take a look and give some feedback for 9305a?
I implemented it so that a BEGINS with a simple prefix are translated into a simple range, while special cases, including unindexed BEGINS expressions, use an additional LIKE filter.
#44 Updated by Stefanel Pezamosca 21 days ago
The upper bound of the range used for BEGINS implementation will be computed directly in PSQL while running udfs.sql and is based on the collation order the database uses.
#45 Updated by Stefanel Pezamosca 21 days ago
In 9305a rev. 16729 I have rewritten how the query gets converted from begins to a range bound. It's a little bit simpler than the previous revisions.
#46 Updated by Alexandru Lungu 20 days ago
- Status changed from Review to WIP
- % Done changed from 90 to 80
I reviewed 9305a:
- Are the changes only for PostgreSQL? I see udfs being created only for it and only its dialect being changed. Does other dialects simply fall back to the
like 'pattern%'?- What about H2; I would imagine BEGINS is a viable clause for temp-tables as well (and persistent H2 of course, but in-memory H2 is currently used in production).
- MariaDB and MSSQL Server shall be investigated ... maybe they do not lack performance for pattern matching, but this needs understanding.
- Please reduce history entries to 1 at most 2 lines and one entry. Any valuable information should be incorporated in javadocs or comments instead of history entry.
- I see some changes to inlining. From my understanding, the generated SQL is now f1 >= lower_bound and f1 <= udf.begins_upper(lower_bound) - omitted rtrim and upper for brevity. Are the lower_bounds actually positional parameters or string literals?
- Please test characters that are defined as case-sensitive either in source-code (e.g. f1 begins cs_char) or in database (e.g. f1 begins c, f1 is defined as case-sensitive in the .df)
- Are all possible collations considered or only what FWD supports by now?
generate_series(1, CASE WHEN d.encoding = pg_char_to_encoding('UTF8') THEN 1114111
+ WHEN pg_encoding_max_length(d.encoding) = 1 THEN 255
+ ELSE 0 END) AS i
Doesn't this mean that the UDF is compatible only with UTF-8 and single-byte encodings? What about UTF-16 or multi-byte encodings? I prefer to have this udf smart enough to cover all collations. Otherwise, it will be a pain to always come back to this specific udf to adjust each time we support new collations.
repeat(%L::text, 256)I am testing now 9305a against some sample test-cases to confirm I understood correctly the fix; expect for more entries in this review pass. I am interested to see:
- indexed vs non-indexed check in actions; especially cases like (f1, f2) index and
where f1 = ? and f2 begins ?orwhere f1 begins ?orwhere f2 begins ?orwhere f1 begins ? and f2 begins ?- I guess
f1 begins f2orf1 begins f2 + "a"would be tricky :)
- I guess
- how matches and begins are behaving
- both considering all the edge cases in this list
- how the udf will be used by the database
- how different edge cases are handled (rtrim, case-sensitive, empty strings, special characters, etc.)
From AI Critical:
- P2JPostgreSQLDialect.canBracketIndexedBegins() returns unconditional true, but udfs.sql's own DO block declines to create udf.begins_upper on a multibyte non-UTF8 encoding (and the install warning is never read). Every indexed BEGINS then fails with SQLSTATE 42883, and since that isn't FWD00, there's no fallback to the LIKE.
- This is what I also considered, but I guess this is a prerequisite. Any server runtime presumed that the UDFS ARE INSTALLED CORRECTLY BEFOREHAND. There is no fallback in case udf doesn't exist. However, I would want to fix this "multibyte non-UTF8" cases to avoid the udf not being created.
- RandomAccessQuery.reset() nulls helper when wasInlined() but leaves nonNullArgsHelper, which getHelper() restores: so a nested FIND FIRST child WHERE child.name BEGINS parent.pfx reuses the first pass's inlined bound and silently returns the wrong rows.
wasInlinedis something that flag a consumer that the fql preprocessor should be re-executed because some of the parameters, that in the mean-time changed, are now poisoning the SQL. For instance, if there is a parameter "null", then the SQL generatedf1 is nullinstead off1 = null. Now, because this inlined happened, when changing the parameter from unknown to non-unknown, the sql shall be regenerated. I can't understand fully why this task affects inlining. If this is the point signaled above about using positional parameters for the BEGINS syntax, then lets keep them as positional parameters instead of inlining. Inlining in general makes the query re-execution slower and caching worse.
Other bullets:
- [MAJOR] functional
FQLPreprocessor.bracketIndexedBegins: whenisSimplePrefix()accepts the prefix (any purely[0-9A-Za-z]prefix — the common case) the LIKE is dropped and the collation range alone decides the answer. That is equivalent to BEGINS only under a single-level, per-character collation — whatlocale/*_fwd_basic(order_start forward) andCgive, and what a 4GL collation table is. It is not what a stock glibc language locale gives: ISO 14651 tables make space and punctuation primary-ignorable and case a tertiary difference, so values that do not begin with the prefix fall inside the bracket. Trigger:FOR EACH customer WHERE customer.name BEGINS "abc"withnamedriving the index, against a database whosedatcollateisen_US.utf8— exactly whatimport.sh'screatedb_postgres()produces (barecreatedb, no--lc-collate;explicitSetCollation()returns false so no per-column collation is pinned either). Verified empirically on PostgreSQL 14 withinitdb --locale=en_US.utf8and the branch's verbatim DO block:'ab cd','a b c d','ab-cd',"ab'cd",'ab.cd','ab_cd','AbCd','ABCD'are all in range but faillike 'abc%', including under theupper(rtrim(...))case-insensitive shape, while underCOLLATE "C"all are out of range. The javadoc defence (on an index "the collation decides which rows begin with the prefix andäbcis among thoseabcbrackets") holds only for a per-character weight table, where collation order and prefix order have the same shape; glibc drops characters from the primary level entirely, which no 4GL collation does, and"ab cd"is not a collation-flavoured view of "begins with abc". FWD ships no UTF-8fwd_basiclocale at all, so a UTF-8 database — the configuration the new UDF is expressly written for — has no correct collation available exceptC/C.utf8. Fix: gate the drop on the database's actual collation (probe'ab cd' < 'abc'/'ABCD' < 'abc'once at install or connect), else keep the LIKE beside the range. - [MAJOR] functional
PostgreSQLScriptRunner.createMissingUdfs/udfs.sql:udf.begins_upperbakes the collation-maximum character into its body as a literal at install time and is declared IMMUTABLE, but the reapply check (countUDFs(conn, "udf", "begins_upper") == 0) fires only when the function is absent — there is no version or collation check anywhere. The baked character is the maximum under one specific collation, and collations disagree about which that is: measured on glibc 2.35, the maximum overchr(1)..chr(0x10FFFF)isU+9FA5underen_US.UTF-8butU+10FFFFunderC/C.UTF-8.pg_dumpemits the function body with the literal already substituted, so both triggers leave the installed function untouched: (a) a glibc-collated database restored into aC/C.UTF-8cluster (common for containerised targets) — every character aboveU+9FA5(Yi, fullwidth forms, all supplementary-plane characters) now sorts above the bound, so matching rows fall outside<=and are dropped; (b) aC-collated database restored into a glibc cluster — the bakedU+10FFFFis undefined in glibc's table and collates as ignorable, so the bound collapses to roughly the prefix itself and an indexed BEGINS returns almost nothing, on pure-ASCII data. Neither restore raises a collation-version mismatch or requires a REINDEX, so this is not one more line on the known glibc-upgrade maintenance list but a silent wrong-answer path with no diagnostic; thelike 'prefix%'recheck cannot help, sinceisSimplePrefixprefixes drop it entirely. Fix: re-measure andCREATE OR REPLACEwheneverudfs.sqlruns, and trigger the reapply on something other than absence (compareudf.begins_upper('')against a freshly measured maximum, or key offpg_database.datcollate/datcollversion). - [MAJOR] performance
FQLPreprocessor.bracketIndexedBegins:inlinedBounds |= literal.getType() SUBSTmakeswasInlined()true for an indexedBEGINS <variable>whose parameter value is known, and — unlike the pre-existing LIKE inlining — it does so even when the caller forbade inlining (inline false), becausebracketIndexedBegins()is invoked frompreprocess()onwhere.contains("begins_index")with noinlinegate. On theFINDfamily (FQLHelper/RandomAccessQuery/FindQuery, where conversion emits a freshnew FindQuery(...)per execution) that newly disables three cache layers: (1)FQLPreprocessor.get()skipsputCacheNoArgs(), so each distinct value re-parses and re-rewrites the clause (keyWithArgsstill serves repeats of the same value); (2)FQLHelper.obtain()skips its cache, whose key carries no parameter values, so an index-for-sort lookup,SortCriterion.parse,SortIndexand full bundle assembly run on every execution even with an unchanged value — the most severe layer, since this path never inlined the LIKE parameter before; (3)Persistence.Context.getQuery()keys its 1024-entry per-contextstaticQueryCacheon the FQL text, so each value adds an entry, evicts other queries and forces a freshFqlToSqlConverter.toSQL()and prepared statement, which also pollutes the static 8192-entryfqlAstCacheacross sessions. Trigger:FIND FIRST item WHERE item.item-num BEGINS v-prefix NO-LOCK NO-ERROR.in a loop on PostgreSQL. Note theFOR EACH/preselect path (QueryComponent,inline == true) is unaffected —wasInlined()was already true there, since PostgreSQL is alsoisQueryRangeParameterInlined().
These 3 bullets look a bit more technical and I will let you assess them by the time I will manage to run some tests with 9305a my own and fully understand the changes.
There are also some minor bullets you can (I guess) extract your own. I will try to make tests eventually around them and let you work directly on unit tests.
#47 Updated by Stefanel Pezamosca 20 days ago
Thanks for the extensive review. I’ll take some time to go through all the details. The UDF part is one of the areas that changed quite a bit from commit to commit, as I was experimenting with different approaches along the way.
#48 Updated by Stefanel Pezamosca 19 days ago
I rebase 9305a to trunk/16735 and fixed some review points in revision 16741.
I need to investigate the other collations next and look into how the UDF can be improved. I also need to revisit the isSimplePrefix() logic. Using this guard to drop the LIKE filter fixed some of my unit tests.
#49 Updated by Stefanel Pezamosca 18 days ago
Committed some more changes in rev 16742. Currently I'm working to generate more unit tests to cover more cases. The UDF changes are still experimental.
#50 Updated by Stefanel Pezamosca 12 days ago
- % Done changed from 80 to 90
9305a was rebased to trunk 16752. In the last revisions a cleaned up more review points that I got. I'll continue to do some more functional and performance testing. Also, I will look into the temp-table implementation of begins next.
#52 Updated by Stefanel Pezamosca 9 days ago
- Status changed from WIP to Review
- % Done changed from 90 to 100
In revision 16764 I Added BEGINS range rewrite support for H2 dialect.
Please do a final review of 9305a. And let me know if you want me to test anything else.
I'm also working on some fwd-h2 level improvements that should help, but they don't depend on 9305a.
#53 Updated by Alexandru Lungu 8 days ago
Please do a final review of 9305a. And let me know if you want me to test anything else.
Doing it now.
#54 Updated by Alexandru Lungu 8 days ago
Stefanel, I think there are files not committed yet:
> Task :compileJava
/media/al2/kiwi1/claude/9305a/src/com/goldencode/p2j/persist/dialect/H2Helper.java:313: error: cannot find symbol
BeginsSupport.setCollationOrder(next, greatest.repeat(CEILING_PAD));
^
symbol: variable BeginsSupport
location: class H2Helper
/media/al2/kiwi1/claude/9305a/src/com/goldencode/p2j/persist/pl/BuiltIns.java:90: error: cannot find symbol
BeginsSupport.class,
^
symbol: class BeginsSupport
location: class BuiltIns#55 Updated by Stefanel Pezamosca 8 days ago
Sorry I forgot to bzr add that file. Doing it now.
#56 Updated by Stefanel Pezamosca 8 days ago
You can update the branch. Committed rev. 16766.
#57 Updated by Teodor Gorghe 8 days ago
Ștefănel, did you wrote some testcases for BEGINS, on multiple collations?
I need to see how it works and why the augmented expression (range check and then bunch of OR based expression) is needed.
#58 Updated by Stefanel Pezamosca 8 days ago
Teodor Gorghe wrote:
Ștefănel, did you wrote some testcases for BEGINS, on multiple collations?
I need to see how it works and why the augmented expression (range check and then bunch of OR based expression) is needed.
Wait, that's my bad, I committed some experimental changes in 9305a, that I uncommitted, please update the branch, for now there is only the range check. I'm experimenting with different options right now. But, I think I will keep only a simple LIKE filter at last. It should be safer.
#59 Updated by Alexandru Lungu 8 days ago
Wait, that's my bad, I committed some experimental changes in 9305a, that I uncommitted, please update the branch, for now there is only the range check. I'm experimenting with different options right now. But, I think I will keep only a simple LIKE filter at last. It should be safer.
Currently I see outputs as begins udf ("begins(upper(tt1.f2), upper(tt1.f1))") or upper(tt1.f1) like begins_index(upper('l%')) with 9305a/16766. Is this the right revision to Review (2026-09-15 06:41:13)?
#60 Updated by Stefanel Pezamosca 8 days ago
Alexandru Lungu wrote:
Wait, that's my bad, I committed some experimental changes in 9305a, that I uncommitted, please update the branch, for now there is only the range check. I'm experimenting with different options right now. But, I think I will keep only a simple LIKE filter at last. It should be safer.
Currently I see outputs as
begins udf ("begins(upper(tt1.f2), upper(tt1.f1))")orupper(tt1.f1) like begins_index(upper('l%'))with 9305a/16766. Is this the right revision to Review (2026-09-15 06:41:13)?
upper(tt1.f1) like begins_index(upper('l%')) should be turned into the range check in the final sql. I think the udf part is emitted for a begins without an index, I need to recheck.
#61 Updated by Alexandru Lungu 8 days ago
- Status changed from Review to WIP
- % Done changed from 100 to 90
Please let me know when 9305a is a fully implemented and explored solution.
#63 Updated by Stefanel Pezamosca 8 days ago
- % Done changed from 90 to 100
- Status changed from WIP to Review
- Kept the LIKE beside the range as its re-check.
- Answer an unknown indexed BEGINS pattern with a null test, not a LIKE.
I have also prepared an fwd-h2 specific change that fixes some `MATCHES` cases caused by H2’s automatic trimming.
#64 Updated by Teodor Gorghe 7 days ago
Changes from 9305a/r16767 to r16768 is supposed to fix the issue from #9305-60?
I have looked what is new and I see that you have made an optimization (eg. begins ? into IS NULL) and added the (expression_like OR equality_expression).
Why the equality is needed since it will be also matched in the like expression?
#65 Updated by Stefanel Pezamosca 7 days ago
Teodor Gorghe wrote:
Changes from 9305a/r16767 to r16768 is supposed to fix the issue from #9305-60?
Both are FQL, before the preprocessor runs, so both are expected.
begins_index(...) is a conversion-time marker, emitted by rules/annotations/where_clause.rules:678,1187 when the left operand is a character field and is_index_field says it's indexed.
begins(upper(tt1.f2), upper(tt1.f1)) is field-BEGINS-field. The marker rule only fires for a string literal or a substitution parameter; a field-to-field BEGINS is emitted as the begins() UDF and never becomes a LIKE, so bracketIndexedBegins() skips it at compare.getType() != LIKE. This is trunk behaviour.
I have looked what is new and I see that you have made an optimization (eg.
begins ?intoIS NULL) and added the(expression_like OR equality_expression).
Why the equality is needed since it will be also matched in the like expression?
Because the LIKE pattern keeps the prefix's trailing blanks, and the equality is the one case it misses.
BEGINS "abc " converts to x like 'abc %'. In 4GL that is TRUE for the row x = "abc", but 'abc' LIKE 'abc %' is FALSE. The range bound is built from rtrim(prefix) precisely so it still admits that row, but LIKE filters it. If I used the rtrimmed value directly for the LIKE, it will break more testcases.
#66 Updated by Stefanel Pezamosca 7 days ago
- Cache compiled LIKE patterns to avoid recompiling per row, skip the LIKE to '=' rewrite under rtrim compare modes (which would change semantics), and reset invalidPattern between calls so a bad pattern doesn't poison later ones.
#67 Updated by Teodor Gorghe 7 days ago
I understand now the changes, thanks. Does the changes from H2Helper and from UDF are compatible with all 4GL collations, or we limit to some (I don't see such thing in the code).
#68 Updated by Teodor Gorghe 7 days ago
- File OUTPUT.txt
added
I mean that the range check is not the universal solution. Take as for example, character ț, which is represented as 0xC8 0x9B. In Romanian alphabet, ț sits between t and u, but in English, is treated as same as t. (ț maps to t).
When comparing by character code-point, it will break the collation rules.
When using single-byte encoding, if the database uses our predefined collations, I think range check is fine. The problem comes when using a ICU encoding, because of semantics of arithmetic inequality operators in SQL (compares the code-point instead of each character order corresponding with the collation).
There is a risk that the range expression to return false and the like expression to return true and we need to assess.
#69 Updated by Stefanel Pezamosca 7 days ago
- PostgreSQL (udf/postgresql/udfs.sql, begins_upper) is correct in general. It does not assume any particular character order; instead, it determines the true maximum character at runtime using actual SQL comparisons (max(chr(i)) / greatest()), which are subject to the database’s active collation.
It then brackets (appends) the prefix with copies of that single global maximum character. The resulting range check looks like this: (field >= 'prefix' and field <= 'prefix' || '<maxchar>')
- H2Helper is a bit more limited, and I think it could be improved.
#70 Updated by Teodor Gorghe 7 days ago
I know, but I mean, if you use en-US@UTF_8 collation on 4GL, persistent or temporary table, field begins "at", it will find the ața record from database?
#71 Updated by Teodor Gorghe 7 days ago
This is a testcase:
DEFINE VARIABLE m AS MEMPTR NO-UNDO.
DEFINE VARIABLE s AS CHARACTER NO-UNDO.
DEFINE TEMP-TABLE tt NO-UNDO FIELD f AS CHARACTER INDEX ix IS PRIMARY f.
SET-SIZE(m) = 5.
PUT-BYTE(m, 1) = 97.
PUT-BYTE(m, 2) = 200.
PUT-BYTE(m, 3) = 155.
PUT-BYTE(m, 4) = 97.
PUT-BYTE(m, 5) = 0.
s = GET-STRING(m, 1).
SET-SIZE(m) = 0.
CREATE tt.
tt.f = s.
FIND FIRST tt WHERE tt.f BEGINS "at" NO-ERROR.
PUT UNFORMATTED "cpcoll=" SESSION:CPCOLL
" f BEGINS 'at' : " STRING(AVAILABLE tt, "TRUE/false") SKIP.
DISPLAY tt.
Output:
proenv>_progres -b -p 9305.p -cpinternal utf-8 -cpstream utf-8 -cpcoll ICU-en cpcoll=ICU-en f BEGINS 'at' : TRUE f -------------- ața
I am a bit surprised that ICU-ro also matches the ața record:
proenv>_progres -b -p 9305.p -cpinternal utf-8 -cpstream utf-8 -cpcoll ICU-ro cpcoll=ICU-ro f BEGINS 'at' : TRUE f -------------- ața
#72 Updated by Teodor Gorghe 7 days ago
Changed the code to use the cedilla version (ţ instead of ț), and the results are the ones which I have expected: ICU-en matches while ICU-ro doesn't.
#73 Updated by Stefanel Pezamosca 7 days ago
I was doing the same kind of test right now, actually. Both, measured on OE 12.8 with -cpinternal UTF-8, indexed field:
-cpcoll ICU-en_US (= en-US@UTF_8) — finds it, 2 records: FOR EACH t WHERE t.c BEGINS "at" -> ata, ața index order -> ata, ața, aua, aza ț shares t's primary weight, so it sorts between ata and aua and falls inside the bracket.
-cpcoll Basic (same UTF-8 code page) — does not find it, 1 record: FOR EACH t WHERE t.c BEGINS "at" -> ata index order -> ata, aua, aza, ața Basic on UTF-8 sorts non-ASCII by raw encoded bytes, so ț (0xC89B) lands after z and is outside the bracket entirely.So the code page is the same in both cases - the collation alone decides.
So, we can’t always match exactly what happens in OE for a BEGINS query on an indexed field. The behavior depends on the collation being used and how the LIKE operator handles it.
At the moment, the results are correct when compared with the equivalent unindexed OE BEGINS behavior. Given this, I think it would be reasonable to treat this as a current limitation and consider matching the behavior of the unindexed BEGINS as sufficient for now.
Trying to reproduce the exact OE behavior in every case could get quite complicated, and I’m not sure the added complexity would be worth it. It feels more reasonable to keep the behavior consistent with the unindexed BEGINS and avoid over-engineering this for what are likely to be edge cases.
#74 Updated by Ovidiu Maxiniuc 7 days ago
Teodor Gorghe wrote:
I agree with what you said, but let's refine things a bit:
I mean that the range check is not the universal solution. Take as for example, character
ț, which is represented as0xC8 0x9B. In Romanian alphabet,țsits betweentandu, but in English, is treated as same ast. (țmaps tot).
ț(whose UNICODE position (hex code-point) is 021B) is encoded in UTF-8 as0xC8 0x9B- from lexical PoV,
tandțare the same letter, they only differ by an accent. Some dialects (like SQL Server) have special collations which take into consideration the accents, or not (Latin1_General_100_CI_AS_SC_UTF8vsLatin1_General_100_CI_AI_SC_UTF8:AS— Accent-Sensitive,AI— Accent-Inensitive);
I am a bit surprised that ICU-ro also matches the
ațarecord:
- The fact that you got
a╚¢ainstead ofața(Romanian for 'the thread') is most likely the byte order (tryPUT-BYTE(m, 3) = 200. PUT-BYTE(m, 2) = 155., or betterPUT-STRING('ț', 2)); - when the database compares the strings, it does not compare the bytes directly, but reads one character (that is, one or more bytes, decodes them to Unicode) at a time and compares their code-point, using the collation rules;
ICU-roandICU-enhave different rules.
#75 Updated by Stefanel Pezamosca 6 days ago
I committed 16769,16770 with some cleanups and simplifications. The code should be simpler now.
#76 Updated by Teodor Gorghe 5 days ago
I have analyzed the changes on latest 9305a, changes looks great, can be controlled from directory.xml.
I am a little bit concerned about when BEGINS works in case sensitive mode vs case insensitive, but we need a confirmation with some testcases. Is begins udf ("begins(upper(tt1.f2), upper(tt1.f1))") output right?
I'll let Alex to decide the final result of review process.
#77 Updated by Stefanel Pezamosca 5 days ago
Teodor Gorghe wrote:
I am a little bit concerned about when
BEGINSworks in case sensitive mode vs case insensitive, but we need a confirmation with some testcases. Isbegins udf ("begins(upper(tt1.f2), upper(tt1.f1))")output right?
Your concern is right, there are some issues for case-sensitive. I will fix them in the next commit.
#78 Updated by Alexandru Lungu 5 days ago
I'll let Alex to decide the final result of review process.
I will look into it first thing tomorrow morning.
#79 Updated by Stefanel Pezamosca 5 days ago
Stefanel Pezamosca wrote:
Your concern is right, there are some issues for case-sensitive. I will fix them in the next commit.
Committed in revision 16771. After I generated some testcases to test conversion/runtime for case sensitivity I fixed more issues with case sensitivity for BEGINS, MATCHES, LOOKUP and INDEX (They were related to each other and had changes in the exact same places).
#80 Updated by Teodor Gorghe 5 days ago
Ok, the only issue which I currently see in the code are just two large history entries. It would have been nicer if these could be summarized into just 1 line (in rare case, two, if there is so much to say).
I have one question. If you have a case sensitive variable which is the second operand of BEGINS, how it gets into SQL? I am curious to see if in this case, it will use the upper(rtrim(my_table_field)) index.
#81 Updated by Stefanel Pezamosca 5 days ago
Teodor Gorghe wrote:
Ok, the only issue which I currently see in the code are just two large history entries. It would have been nicer if these could be summarized into just 1 line (in rare case, two, if there is so much to say).
Alright, I'll see what I can do about this.
I have one question. If you have a case sensitive variable which is the second operand of
BEGINS, how it gets into SQL? I am curious to see if in this case, it will use theupper(rtrim(my_table_field))index.
It will not, at least from what I tested and compared with OE, that index can't be used.
#82 Updated by Teodor Gorghe 5 days ago
Ok, if you query the INDEX-INFORMATION attribute, that index is not there, correct?
#83 Updated by Stefanel Pezamosca 5 days ago
Ok, I was wrong, it will use the index, but it will find rows only if the second operand of BEGINS is uppercased. I have to add more detail to my testcases.
So, for a case-insensitive field it should be like this: upper(rtrim([field])) LIKE [pattern]:CS / upper([pattern]:CI).
#84 Updated by Stefanel Pezamosca 5 days ago
Stefanel Pezamosca wrote:
So, for a case-insensitive field it should be like this:
upper(rtrim([field]))LIKE[pattern]:CS/upper([pattern]:CI).
Fixed this case in revision 16772 together with some History entry cleanups.