Source:
my blog.
I wrote a little something about the FIND statements and such ... could be nice for reading on a Sunday evening
![:wink: :wink:]()
. For the better edited version, go to my blog (the tables that I used are a pain in the ass here
![:( :(]()
).
This is a very challenging blogpost in my opinion. I see many stories going around on what would be the best way to use the new FIND-instructions, what C/AL to write in what case. I have my own thoughts on this which I would like to share. I don't want to say the following is the best way to go, let's just say it's a good way to go .
First question (and I think the most important question that is on people's mind), how to use the FIND statements. This is not easy, because using the FIND statement really depends on the type of loop you're using.
First of all the basics. I think the table from stryk explains it very well:
-----------------------------------------------------
Customer.SETRANGE("Country Code", 'US');
Customer.FIND('-');
SELECT *,DATALENGTH("Picture")
FROM "dbo"."Cronus$Customer"
WITH (READUNCOMMITTED)
WHERE (("Country Code"='US'))
ORDER BY "No_"
-----------------------------------------------------
Customer.SETRANGE("Country Code", 'US');
Customer.FINDFIRST;
SELECT TOP 1 *,DATALENGTH("Picture")
FROM "dbo"."Cronus$Customer"
WITH (READUNCOMMITTED)
WHERE (("Country Code"='US'))
ORDER BY "No_"
-----------------------------------------------------
Customer.SETRANGE("Country Code", 'US');
Customer.FIND('+');
SELECT *,DATALENGTH("Picture")
FROM "dbo"."Cronus$Customer"
WITH (READUNCOMMITTED)
WHERE (("Country Code"='US'))
ORDER BY "No_" DESC
-----------------------------------------------------
Customer.SETRANGE("Country Code", 'US');
Customer.FINDLAST;
SELECT TOP 1 *,DATALENGTH("Picture")
FROM "dbo"."Cronus$Customer"
WITH (READUNCOMMITTED)
WHERE (("Country Code"='US'))
ORDER BY "No_" DESC
-----------------------------------------------------
Customer.SETRANGE("Country Code", 'US');
Customer.FINDSET;
SELECT TOP 500 *,
DATALENGTH("Picture")
FROM "dbo"."Cronus$Customer"
WITH (READUNCOMMITTED)
WHERE (("Country Code"='US'))
ORDER BY "No_"
-----------------------------------------------------
Customer.SETRANGE("Country Code", 'US');
Customer.FINDSET(TRUE);
SET TRANSACTION ISOLATION LEVEL
SERIALIZABLE
SELECT *,DATALENGTH("Picture")
FROM "dbo"."Cronus$Customer"
WITH (UPDLOCK)
WHERE (("Country Code"='US'))
ORDER BY "No_"
-----------------------------------------------------
Customer.SETRANGE("Country Code", 'US');
IF Customer.ISEMPTY THEN;
SELECT TOP 1 NULL
FROM "dbo"."Cronus$Customer"
WITH (READUNCOMMITTED)
WHERE (("Country Code"='US'))
-----------------------------------------------------
Customer.SETRANGE("Country Code", 'US');
IF Customer.COUNT <> 0 THEN;
SELECT COUNT(*)
FROM "dbo"."Cronus$Customer"
WITH (READUNCOMMITTED)
WHERE (("Country Code"='US'))
-----------------------------------------------------
Customer.SETRANGE("Country Code", 'US');
IF Customer.COUNTAPPROX <> 0 THEN;
SET SHOWPLAN_ALL ON
SELECT *
FROM "dbo"."Cronus$Customer"
WITH (READUNCOMMITTED)
WHERE (("Country Code"='US'))
-----------------------------------------------------
GLEntry.LOCKTABLE;
GLEntry.FIND('+');
SET TRANSACTION ISOLATION LEVEL
SERIALIZABLE
SELECT *
FROM "dbo"."Cronus$G_L Entry"
WITH (UPDLOCK)
ORDER BY "Entry No_" DESC
-----------------------------------------------------
GLEntry.LOCKTABLE;
GLEntry.FINDLAST;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE
SELECT TOP 1 *
FROM "dbo"."Cronus$G_L Entry"
WITH (UPDLOCK)
ORDER BY "Entry No_" DESC
A ground rule might be: try to fetch as less data as needed - but all data you need - in one server call. E.g. Don't fetch all records if you're not going to loop those records / Don't fetch data if you just want to know if a record is present for that range or not, ... .
The table above is nice, but it still doesn't make me think about the things I have to take in count when writing code. Let me try to help you with this by asking a few basic questions:
WHEN NOT LOOPING DATA
Am I going to change data?
Suppose you write this code:
recCust.GET('10000');
recCust.VALIDATE(Name, 'Van Terp Kantoorinrichting');
recCust.MODIFY(TRUE);
Not really exciting. In fact, I see code like this going around quite often. Well, the problem with this is that you do an extra server call, because you didn't apply that you wanted to change data. In fact, you'll see this in the client monitor:
-----------------------------------------------------
recCust.GET('10000');
SELECT *,DATALENGTH("Picture")
FROM "CRONUS BELGIË NV$Customer"
WITH (READUNCOMMITTED)
WHERE "No_"='10000'
Read the data without setting a lock (you didn't specify this)
-----------------------------------------------------
recCust.VALIDATE(...);
recCust.MODIFY(TRUE);
SELECT *,DATALENGTH("Picture") FROM "CRONUS BELGIË NV$Customer" WITH (UPDLOCK, REPEATABLEREAD) WHERE "No_"='10000'
UPDATE "CRONUS BELGIË NV$Customer" WITH (REPEATABLEREAD) SET ...
Read the data again and place a lock.
Update the fields.
-----------------------------------------------------
Two times the SELECT is overkill, and must be avoided. And avoiding it is simple. Specifying that you want to change the data can be done by "LOCKTABLE". This makes our small piece of code like:
recCust.LOCKTABLE;
recCust.GET('10000');
recCust.VALIDATE(Name, 'Van Terp Kantoorinrichting');
recCust.MODIFY(TRUE);
To make the comparison complete, here is what happens behind the scenes:
-----------------------------------------------------
recCust.LOCKTABLE;
recCust.GET('10000');
SELECT *,DATALENGTH("Picture") FROM "CRONUS BELGIË NV$Customer" WITH (UPDLOCK, REPEATABLEREAD) WHERE "No_"='10000'
Read the data with setting a lock
-----------------------------------------------------
recCust.VALIDATE(...);
recCust.MODIFY(TRUE);
UPDATE "CRONUS BELGIË NV$Customer" WITH (REPEATABLEREAD) SET ...
-----------------------------------------------------
This being said, take a look at following example:
IF ICHandledInboxTransaction.FIND('-') THEN BEGIN
ICHandledInboxTransaction.LOCKTABLE;
ICHandledInboxTransaction.Status := ICHandledInboxTransaction.Status::Posted;
ICHandledInboxTransaction.MODIFY;
END;
This is code from default NAV (earlier version) which could be replaced by (to not change the behaviour on Native):
IF RECORDLEVELLOCKING THEN
ICHandledInboxTransaction.LOCKTABLE;
IF ICHandledInboxTransaction.FINDFIRST THEN BEGIN
ICHandledInboxTransaction.LOCKTABLE;
ICHandledInboxTransaction.Status := ICHandledInboxTransaction.Status::Posted;
ICHandledInboxTransaction.MODIFY;
END;
The statement "RECORDLEVELLOCKING" is the way for checking in code whether you're working on SQL Server (TRUE) or Native (FALSE). Furthermore, I added these two lines of code because it's necessary to lock before we read (to send the UPDLOCK in SQL).
Does a record exist?
There are two (good) ways to check whether a record is present for a certain range:
Using FINDFIRST: Use this statement when you need the data of the first record, and the first record only.
Using ISEMPTY: this is a somewhat forgotten statement, but it's lighter then the FINDFIRST. If you only want to check if a record exists in the filtered range, but you don't need any value of a field of any of the records, use this statement.
See table above for what SQL Statement it produces (TOP 1 * or TOP 1 NULL).
For example:
IF SalesLine.FIND('-') THEN
Cust.CheckBlockedCustOnDocs(Cust,"Document Type",TRUE,TRUE);
Could be replaced by
IF NOT SalesLine.ISEMPTY THEN
Cust.CheckBlockedCustOnDocs(Cust,"Document Type",TRUE,TRUE);
WHEN LOOPING DATA
That wasn't that exciting, was it? But what if we want to loop data. That's a whole different story .
First thing that I have to state is that NAV uses cursors to simulate native behaviour. The new FIND statements (from 4.0) are the first step to go around this native behaviour.
Looping without changing data
In Stryk's tabel above, you can see the difference on SQL level between FIND('-'), FINDFIRST and FINDSET. I'll try to make the comparison of what happens on SQL Server:
-----------------------------------------------------
IF recCust.FINDFIRST THEN
REPEAT
UNTIL recCust.NEXT = 0;
SELECT TOP 1 *,DATALENGTH("Picture") FROM "CRONUS BELGIË NV$Customer" WITH (READUNCOMMITTED) ORDER BY "No_"
SELECT *,DATALENGTH("Picture") FROM "CRONUS BELGIË NV$Customer" WITH (READUNCOMMITTED) WHERE "No_">'01121212' ORDER BY "No_"
FETCH 5
FETCH 20
FETCH 40
FETCH 40
-----------------------------------------------------
IF recCust.FINDSET THEN
REPEAT
UNTIL recCust.NEXT = 0;
SELECT TOP 501 *,DATALENGTH("Picture") FROM "CRONUS BELGIË NV$Customer" WITH (READUNCOMMITTED) ORDER BY "No_"
-----------------------------------------------------
IF recCust.FIND('-') THEN
REPEAT
UNTIL recCust.NEXT = 0;
SELECT *,DATALENGTH("Picture") FROM "CRONUS BELGIË NV$Customer" WITH (READUNCOMMITTED) ORDER BY "No_"
FETCH 5
FETCH 20
FETCH 40
FETCH 40
-----------------------------------------------------
What do we learn from this:
The best way to go is using the FINDSET. Why? It's fetching a SET of records and will be working with this set all the way. You see that there is no cursor activity (no FETCH statements). If you read further, you'll see that this is not always the case!
FINDFIRST is worse then FIND('-') when looping records because of the extra call (TOP 1 *). NAV does not expect a loop after a FINDFIRST. If it sees that you are looping, it's doing a new SELECT * (same as FIND('-') statement) to be ready for the loop and working with cursors to loop through the data (therefore the FETCH statements). Therefore, please do not just replace FIND('-') with FINDFIRST.
Never blindly replace FIND('-') by FINDFIRST. Think of what you're doing.
Now, what about the number of records? Does that count?
Well, NAV behaves like this: if the set is bigger then 500 records (or the size you specified as "Record Set" value in the "Alter Database" window), it will not use the recordset, but is going to "cursor" it's way through the rest of the loop, like "old school". I'll try to show it using tho examples. In both of them, the code runs 2840 times through the loop (or in other words, the messages shows "2840").
-----------------------------------------------------
i := 0;
IF recGL.FIND('-') THEN
REPEAT
i := i + 1;
UNTIL recGL.NEXT = 0;
MESSAGE('%1',i);
SELECT * FROM "CRONUS BELGIË NV$G_L Entry" WITH (READUNCOMMITTED) ORDER BY "Entry No_"
FETCH 5
FETCH 20
FETCH 60
FETCH 60
FETCH 60
.
.
.
A total of 2845 "FETCHes"
-----------------------------------------------------
i := 0;
IF recGL.FINDSET THEN
REPEAT
i := i + 1;
UNTIL recGL.NEXT = 0;
MESSAGE('%1',i);
SELECT TOP 501 * FROM "CRONUS BELGIË NV$G_L Entry" WITH (READUNCOMMITTED) ORDER BY "Entry No_"
SELECT * FROM "CRONUS BELGIË NV$G_L Entry" WITH (READUNCOMMITTED) WHERE "Entry No_">501 ORDER BY "Entry No_"
FETCH 5
FETCH 20
FETCH 60
FETCH 60
FETCH 60
.
.
.
A total of 2365 "FETCHes"
-----------------------------------------------------
In case of the FINDSET: A first impression shows you the when the resultset is bigger than 500 records, it just throws an extra database call without the "TOP 500" and is going to cursor it's way through the records. When you examine deeper, you conclude that it's actually looping through the recordset of 500 records, and after that, NAV does not call the next 500 records, but just trows a "SELECT *" and is going to cursor it's way through the rest of the records.
That's why with FINDSET, we have about 500 FETCHes less than FIND('-'). BUT, we have an extra database call, which is quite "heavy" (a "SELECT *" is heavier than a "FETCH 60").
It's hard to discuss what's best. Further benchmarking should point that out. I would bet my money on using FINDSET in all cases. Why?
You avoid about 10 times a FETCH x call
A "SELECT TOP 500" as extra database call doesn't seem that heavy for me.
If anyone is interested in doing a thorough investigation on this ... Please count me in or let me know the results.
What do we learn from this:
Using FINDSET in front of every loop, is a pretty sure bet.
Using FINDSET only gets the first 500 records as a set, but is going to cursor it's way through the rest of the records like FIND('-').
Looping with changing data
The same rules apply like above, only see if you're locking the data before you're reading it. You can do this by either using LOCKTABLE, like:
recGL.LOCKTABLE;
IF recGL.FINDSET THEN
REPEAT
...
UNTIL recGL.NEXT = 0;
Or, if using the FINDSET statement, you can use its parameter like:
IF recGL.FINDSET(TRUE) THEN
REPEAT
...
UNTIL recGL.NEXT = 0;
There is also an option FINDSET(TRUE, TRUE). Only use this if you are going to modify any field value within the current key. Now, I really don't recommend to use this. You have to be aware for the "NEXT FROM HELL" (like Hynek likes to call it).
The "Next from hell" can happen when you filter on a value, and in a loop, you're changing that value. The loop jumps to places that are hard to predict. Also, when you change a key value in a loop (even without modify), a "NEXT" can give unexpected results. Try to avoid these situations by just declaring a new variable, and modify in that variable. For example, this is looking for trouble:
recCust.SETRANGE("Country/Region Code", 'BE');
IF recCust.FINDSET(TRUE) THEN
REPEAT
recCust.VALIDATE("Country/Region Code", 'US');
recCust.MODIFY;
UNTIL recCust.NEXT = 0;
You're modifying the Country Code while you're filtered on that field. Try to use a seperate record variable, and leave your looping variable intact, like:
recCust.SETRANGE("Country/Region Code", 'BE');
IF recCust.FINDSET(TRUE) THEN
REPEAT
recCust2 := recCust;
recCust2.VALIDATE("Country/Region Code", 'US');
recCust2.MODIFY;
UNTIL recCust.NEXT = 0;
Conclusion
When I started writing this article, I didn't expect that it was so hard to put this subject into words. I hope the above is clear to you, but to be sure, I just wanted to give you an overview of the things mentioned above.
Not looping data
Modifying data
Use LOCKTABLE with GET or FINDFIRST
Not modifying data (usually to check if a record exists or not)
Use FINDFIRST if you need the data of the record further down the road
Use ISEMPTY if you do not need the values of the keys
Looping data
Not modifying data
Use loop like: IF FINDSET THEN REPEAT UNTIL NEXT=0;
Modifying data: see that you lock the records that you read.
Use loop like: IF FINDSET(TRUE) THEN REPEAT UNTIL NEXT=0;
Mind the "NEXT FROM HELL" !
So, always think what the code is doing, then make up your mind what you will need for most optimal performance on SQL Server.
Writing this article, I used the SQL Profiler and Client monitor to monitor what my code is doing. I noticed very few people know what this client monitor can do, so who knows, there might be an upcoming blog about monitoring your code .
Any comments are appreciated!