Fixed year not appearing on citation key for In-* entries due to orphaned crossref links (#9071)#15582
Conversation
Review Summary by QodoFix orphaned crossref links on parent citation key change
WalkthroughsDescription• Intercepts FieldChangedEvent to dynamically update citation index • Prevents orphaned crossref links when parent citation key changes • Ensures child entries reflect parent year in generated citation keys • Updates CHANGELOG.md with fix description Diagramflowchart LR
A["Parent Entry Citation Key Changes"] -->|FieldChangedEvent| B["relayEntryChangeEvent Intercepts"]
B -->|Check if SINGLE_ENTRY_LINK or MULTIPLE_ENTRY_LINK| C["Update Citation Index Map"]
C -->|Remove old reference| D["Remove from old key set"]
C -->|Add new reference| E["Add to new key set"]
E -->|Child entry synced| F["Child crossref stays valid"]
F -->|Year now available| G["Citation key includes year"]
File Changes1. jablib/src/main/java/org/jabref/model/database/BibDatabase.java
|
Code Review by Qodo
1.
|
| private void relayEntryChangeEvent(FieldChangedEvent event) | ||
| { | ||
| Field field = event.getField(); | ||
|
|
||
| // Check if the field changed was a single entry or multiple entry link | ||
| boolean isLinkedField = field.getProperties().contains(FieldProperty.SINGLE_ENTRY_LINK) || | ||
| field.getProperties().contains(FieldProperty.MULTIPLE_ENTRY_LINK); | ||
|
|
||
|
|
||
| if (isLinkedField) | ||
| { | ||
| BibEntry entry = event.getBibEntry(); | ||
| String oldValue = event.getOldValue() == null ? "" : event.getOldValue(); | ||
| String newValue = event.getNewValue() == null ? "" : event.getNewValue(); | ||
|
|
||
| //Remove from the old key's set | ||
| if (oldValue != null) | ||
| { | ||
| Set<BibEntry> referenceEntries = citationIndex.get(oldValue); | ||
| if (referenceEntries != null) | ||
| { | ||
| referenceEntries.remove(entry); | ||
| if (referenceEntries.isEmpty()) | ||
| { | ||
| citationIndex.remove(oldValue); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Add to the new key's set | ||
| if (newValue != null) | ||
| { | ||
| citationIndex.computeIfAbsent(newValue, k -> ConcurrentHashMap.newKeySet()).add(entry); | ||
| } | ||
| } | ||
|
|
||
| // After changes, post the event just like before | ||
| eventBus.post(event); | ||
| } |
There was a problem hiding this comment.
2. No tests for index update 📘 Rule violation ☼ Reliability
Core model behavior was changed by updating citationIndex during FieldChangedEvent handling, but this PR does not include corresponding test additions/updates to prevent regressions. This violates the requirement to adapt/update tests when modifying org.jabref.model behavior.
Agent Prompt
## Issue description
A behavior change in `BibDatabase` (updating `citationIndex` on `FieldChangedEvent`) is not covered by tests in this PR.
## Issue Context
This code lives under `org.jabref.model` and affects crossref/link behavior; a regression here can reintroduce stale/orphan link indexing.
## Fix Focus Areas
- jablib/src/main/java/org/jabref/model/database/BibDatabase.java[548-586]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
…' into fix-issue-9071-orphaned-crossref
| } | ||
| } | ||
|
|
||
| // After changes, post the event just like before |
There was a problem hiding this comment.
There is no "before" or "after" in a codebase. A person reading will only know the current state.
This is a telltale sign of AI usage. Seems like you did not read our contributing guidelines at all.
There was a problem hiding this comment.
here for after i meant after the checks i made.
There was a problem hiding this comment.
There is also "just like before" at the end of the sentence.
There was a problem hiding this comment.
this part is written by me as i had to restyle to match the styling convention of jabref and then again write tests for the Junit to get accepted in the qodo ai review
There was a problem hiding this comment.
This discussion is in context of the highlighted comment above, not code style conventions or unit tests.
There was a problem hiding this comment.
Your thought process was - it would be unclear to reviewers that the functionality of an untouched part of code is still the same unless you add a comment stating that the functionality of an untouched part of code is unchanged?
You've talked about JabRef style/conventions above. If you had even read this class once yourself, or at least the surrounding code, you'd have realized that nobody writes obvious comments like "delete from old key set" or "add to new key set" or refer to history of code. That, however takes time.
We are really not new to this experience, we've dealt with an uncountable number of pull requests now (and the senior maintainers here even more), and some patterns which you're unaware of, are persistent every time someone uses AI. They may not be obvious to you and it might feel really easy to claim they're intentionally made thinking we won't understand, but that's not really the case. We can see through them.
I've made mistakes like you (which are not even considered mistakes for newcomers), and also use AI during my day to day. Only thing that matters is the honesty and consistency of the human being involved.
I do not wish to continue this discussion further, so I leave it on other maintainers
There was a problem hiding this comment.
Since you at least tested the changes - we will not reject your contribution and give you another chance. Please be careful henceforth.
There was a problem hiding this comment.
I am being honest @subhramit . I am really serious about this contribution. I went through the other parts of the code in the same class as well. I went through a considerable part of the codebase during the debugging process. I have said in my previous comment that i have taken help of ai assistant to guide me. I mentioned that in my previous gitter chats as well. I have no reason to lie as it would harm my own efforts. I am being honest, that I wrote that part of code myself including that comment line. I used AI to assist me. But the entire code is written by me. I have not copy pasted a single line in that method. I can share my logic report as well as to how i formulated the logic. I humbly appeal to you, please check my code again, I will be happy to explain my thought process for any logic part. I apologise if i overstepped at any point. I hope you will consider my request. Thank you for guiding me, I have learnt a lot from this first contribution and wish to improve further.
There was a problem hiding this comment.
It's fine. You deserve a benefit of doubt, and you shall be given that.
I have reviewed your code in the next comments, considering your contribution honest.
Please remove this comment from the code.
There was a problem hiding this comment.
Thank you @subhramit for your consideration. I will try to explain your questions in comments with as much clarity as i can.
Thank you for your guidance. I will remove that comment from the code and commit the changes. If i need to make any other changes, please mention that, i would try to fix them as well.
| String newValue = event.getNewValue() == null ? "" : event.getNewValue(); | ||
|
|
||
| // Remove from the old key's set | ||
| if (oldValue != null) { |
There was a problem hiding this comment.
How can oldvalue ever be null?
There was a problem hiding this comment.
When a child (ex - InProceedings) is added as entry for first time, there is no crossref field attached. This field is not there for the child. So this will be null. When the user types the crossref and saves it, then it is saved with some value. As the field was empty before, the old value can be null. Now since the citationIndex in BibDatabase.java is initialised as a final ConcurrentHashMap and ConcurrentHashMap does not support null values, there can be a null pointer exception. Hence I added this check. if there is no crossref for a new entry, then it will be seen as empty string to prevent any null pointer exception error.
There was a problem hiding this comment.
I understand that you are talking about getOldValue(). I inquired about oldValue.
You define oldValue as
String oldValue = event.getOldValue() == null ? "" : event.getOldValue();above.
Hence, it can be "" but never null.
Hence your logic regarding ConcurrentHashMap doesn't follow, nor this check if (oldValue != null) because it will always be non-null.
There was a problem hiding this comment.
Yes the if statement is redundant part. When i first wrote the code i did the check with
if(oldVaue!=null&&!oldValue.isEmpty())
{
}
But then while checking i realised that i need the empty strings for checking too otherwise the new entry will not be linked. So i removed the isEmpty part and added a ternary operator above. I forgot to remove the redundant if parts for this and the newvalue. I will rectify them the same time when i remove the comment you suggested.
| } | ||
|
|
||
| // Add to the new key's set | ||
| if (newValue != null) { |
There was a problem hiding this comment.
How can newvalue ever be null?
There was a problem hiding this comment.
The new value can be null if suddenly a user deletes a crossref field for the child. If there is only 1 crossref entry for a child, and user deletes it, then that field will be removed from the BibEntry object of this entry. Then the new value becomes null. Now as i mentioned in my previous old value comment, the citationIndex in Bibdatabase.java is initialised with a final ConcurrentHashMap. and ConcurrentHashMap does not support null keys. So trying to put a null key will cause null pointer exception. Hence i made the check to keep an empty string for the null value to avoid that error.
There was a problem hiding this comment.
Similar response as above - what does this check signify?
Did you read your code before submitting?
There was a problem hiding this comment.
See @programmer1128 - it doesn't matter how many preliminary steps you went through before arriving at a final state and submitting a PR as ready for review. The bottom line is that you did not check your final code before submitting - hence we are spending time on this.
We understand - you're a human and this can happen, but as far as the PR review is concerned, the maintainers really don't have time for these endless excuses or knowing the entire history of your process. Please just say "oh I missed this from a previous draft" and just fix it.
There was a problem hiding this comment.
I apologise for my mistake @subhramit . I will make all the necessary changes that you have suggested, recheck my code and then commit it. Thank you for your thorough review and time to check my code and guide me.
There was a problem hiding this comment.
No issues, Aritra, as blunt my tone may sound, I am keeping in mind that this is your first time - and trying to make you pick up as many things as possible.
There was a problem hiding this comment.
I really appreciate it @subhramit . I have learnt a lot from this and I am still learning and will continue to do so. It is a really good experience for me. Thank you for taking the time to mentor me.
|
|
||
| // Add to the new key's set | ||
| if (newValue != null) { | ||
| citationIndex.computeIfAbsent(newValue, k -> ConcurrentHashMap.newKeySet()).add(entry); |
There was a problem hiding this comment.
I have used a concurrent hashmap here firstly because the citationIndex field itself is initialised as a final ConcurrentHashMap in the BibDatabase.java file.
To ensure that the key insertion, update and deletion is thread safe, I followed the same ConcurrentHashMap as done in the class previously.
Also, as I understood, jabref uses javafx for ui. So there is JavaFx ui thread plus many other task threads. Now the change i made is inside a method that is relayEntryChangeEvent(FieldChangedEvent event). This method is called, whenever an event is posted to the eventbus and these events can come from many threads. Now if we use a normal HashMap a thread that posted event to change citationIndex can interfere with the JavaFx thread reading it to show in interface which can cause a ConcurrentModificationException error in java.
So if we use concurrent hashmap then the operations can happen safely with all the threads without the ui thread being interfered.
There was a problem hiding this comment.
You are correct about javafx and background threads posting to the eventbus, and relayEntryChangeEvent can run off the fx thread while the UI reads the index, so thread-safety does matter here.
However, ConcurrentHashMap doesn't make compound operations atomic. Consider your L562 onwards:
Set<BibEntry> referenceEntries = citationIndex.get(oldValue);
if (referenceEntries != null) {
referenceEntries.remove(entry);
if (referenceEntries.isEmpty()) {
citationIndex.remove(oldValue);
}
}Between isEmpty() and remove(oldValue), another thread can computeIfAbsent(oldValue...) and repopulate the set, the remove would then delete a non-empty mapping. However as far as I see, a similar pattern already exists in the code which you probably faithfully followed (in removeEntryFromIndex), so for the scope of this PR you can ignore this - and the situation is unlikely to occur during JabRef's practical use.
Just thought of putting this here as the topic of concurrency was raised, so that you can also take this opportunity learn about this more deeply.
There was a problem hiding this comment.
Thank you @subhramit for your guidance. I think this is similar to time of check to time of use condition. I may be wrong. I will keep your advice in mind . Thank you for pointing it out and reviewing my PR thouroughly.
|
@LoayTarek5 would you like to review this? Since you delved into the issue a bit, this may be a good opportunity! |
Sure, i will do it |
|
Hi @subhramit , I have updated the PR based on your suggestions. I have removed the redundant if checks. While fixing, another test case came to my mind. If the new value is kept empty by user, then the entry will be registered with a crossref of empty value, which is not valid as a empty crossref is not a valid one. Hence I have added a if check for the new value, that checks if the new value is empty or not and accordingly places the entry in the map. |
|
Please note that AI use needs to be disclosed. |
|
Hi @calixtus, I have taken help of AI assistant to guide me through the open source contribution process, but I have done the entire debugging process myself, with detailed reports at every step as I commented on issue page, guidance from JabRef mentors as well, and the entire code has been written by me myself with my own logic formulation. The code may not have been very clean at the first, but I made changes as per guidance of mentors and then submitted the proper PR. I have learnt a lot from this contribution and I am continuing to learn. I thank the entire community for guiding me towards a proper PR and a better contributor. |
| boolean isLinkedField = field.getProperties().contains(FieldProperty.SINGLE_ENTRY_LINK) || | ||
| field.getProperties().contains(FieldProperty.MULTIPLE_ENTRY_LINK); | ||
|
|
||
| if (isLinkedField) { | ||
| BibEntry entry = event.getBibEntry(); | ||
| String oldValue = event.getOldValue() == null ? "" : event.getOldValue(); | ||
| String newValue = event.getNewValue() == null ? "" : event.getNewValue(); | ||
|
|
||
| // Remove from the old key's set | ||
|
|
||
| Set<BibEntry> referenceEntries = citationIndex.get(oldValue); |
There was a problem hiding this comment.
MULTIPLE_ENTRY_LINK fields can hold multiple keys in one value, so citationIndex.get(oldValue) misses when oldValue is something like "Key1, Key2".
BibDatabase.java already has forEachCitationKey(), which parses link fields with entry.getEntryLinkList(field, this) and is reused by indexEntry and removeEntryFromIndex, consider using it here too so all link parsing stays in one place
There was a problem hiding this comment.
Hi @LoayTarek5, Thank you for reviewing my code. i though initially that crossref multiple entry fields were stored individually by default in separate strings all containing same child. Thank you for pointing out my mistake. I will try to implemet the logic you suggested using the forEachCitationKey() method and correct my logic and test it with a testcase similar to "key1,key2.." for multiple entry fields.
| void crossrefChangeUpdatesCitationIndex() { | ||
| BibDatabase database = new BibDatabase(); | ||
| BibEntry parent = new BibEntry(StandardEntryType.Proceedings) | ||
| .withCitationKey("TestParent"); | ||
| BibEntry child = new BibEntry(StandardEntryType.InProceedings); | ||
|
|
||
| database.insertEntry(parent); | ||
| database.insertEntry(child); | ||
|
|
||
| // Setting the crossref field after insertion should trigger the FieldChangedEvent | ||
| // and safely update the citation index without crashing. | ||
| child.setField(StandardField.CROSSREF, "TestParent"); | ||
|
|
||
| // Verify that the database successfully linked the child to the parent | ||
| assertEquals(Optional.of(parent), database.getReferencedEntry(child)); | ||
| } |
There was a problem hiding this comment.
The existing crossrefChanged test in KeyChangeListenerTest.java sets the crossref before insertEntry, so it never triggers the stale index, the real bug when crossref is set after insertion
Please add a test that:
inserts parent and child separately, then sets child.crossref after insertion.
renames the parent key --> verifies the child's crossref follows
renames the parent key a second time --> verifies it still follows
There was a problem hiding this comment.
Thank you @LoayTarek5 for this new test case. I will test it with my new correct logic with a new test that will implement the 2 time rename of parent. I will try to record the results and post it here.
| String newValue = event.getNewValue(); | ||
|
|
||
| // split the old multiple key string into individual keys and remove the entry from all old keys | ||
| if (oldValue != null && !oldValue.trim().isEmpty()) { |
| String oldKey = rawKey.trim(); | ||
| if (!oldKey.isEmpty()) { |
| } | ||
|
|
||
| // split the new multiple key string into individual keys and add the entry to all new keys | ||
| if (newValue != null && !newValue.trim().isEmpty()) { |
| String newKey = rawKey.trim(); | ||
| if (!newKey.isEmpty()) { |
|
Hi @subhramit , Thank you for reviewing my code and suggesting the changes. I will implement these changes and commit the corrected code. |
…latedIndexUpdatesWhenCommaSeparatedFieldChanges() into 2 separate tests as childIsIndexedUnderEachKeyInCommaSeparatedField and changingOneKeyInCommaSeparatedFieldUpdatesIndex
| String newValue = event.getNewValue(); | ||
|
|
||
| // split the old multiple key string into individual keys and remove the entry from all old keys | ||
| if (oldValue != null && !oldValue.isBlank()) { |
There was a problem hiding this comment.
When a change is suggested, please don't just blindly replace as per your gut feeling. Go into the function isBlank, see what it does. If it does the same as isEmpty(), a critical thinker would think - then why the need for replacement?
If you go into the function body, you'll find that the null check is already being done there - so the review comments were intended to reduce extra code.
There was a problem hiding this comment.
Also, you did not use StringUtil as mentioned in #15582 (comment)
jabref/jablib/src/main/java/org/jabref/logic/util/strings/StringUtil.java
Lines 576 to 578 in 28e1b59
There was a problem hiding this comment.
I apologise @subhramit , i misunderstood your suggested change. I thought you meant the built in isBlank() method for string class in java and since the built in method does not check the null, i kept the null check.
For the isEmpty() part I thought you have suggested the isBlank() because .trim() creates a new String object in java and to avoid that.
I apologise for my mistakes. I will fix them immediately and commit the corrected code.
There was a problem hiding this comment.
Same comments on the other related parts.
There was a problem hiding this comment.
Thank you @subhramit for providing the code segment. It is a great help. I will fix the code immediately to use this method and then commit the corrected code.
If there is any more change that i need to make, please mention them, i will fix them and commit the proper code.
| // split the old multiple key string into individual keys and remove the entry from all old keys | ||
| if (oldValue != null && !oldValue.isBlank()) { | ||
| for (String rawKey : oldValue.split(",")) { | ||
| String oldKey = rawKey.trim(); |
There was a problem hiding this comment.
Trim is also no longer needed. See what isBlank does by Ctrl+click.
There was a problem hiding this comment.
Hi @subhramit , I saw what the method does. By using !StringUtil.isBlank(rawKey) i can avoid the new string creation that trim does even for empty invalid strings.
I just want to clarify, that when the string is not empty and contains characters with leading spaces or spaces at end, then i have to do trim so that the citationIndex can properly match the keys. For that reason, i think i should move the trim part to inside the if statement that trims the rawKey only when it is not empty and checks in citationIndex. Can you please clarify my approach.
There was a problem hiding this comment.
You can keep the trim where you are using the trimmed version beyond the checks
There was a problem hiding this comment.
Thanks a lot @subhramit for clarifying my approach. I will make the fixes immediately and commit the proper code with the changes you suggested.
Thank you for your guidance.
|
Hi @subhramit and JabRef Community, I have made the suggested changes in my code. I have updated the .isEmpty() to .isBlank() method written in StringUtil.java in jabref/logic and commited the updated code. Thank you for your guidance at every step. |
|
Thanks a lot @subhramit and @Siedlerchr and JabRef Community for approving my pull request. It really means a lot to me. I am seeing some binaries install issues on the checks made for the merge. If i can contribute in any manner from my end to resolve this please tell me, i will try to help and fix the checks. |
…urityIssues * upstream/fixSecurityIssues: fix jbang (#15649) Add initial claude action support Chore(deps): Bump org.glassfish.grizzly:grizzly-bom in /versions (#15648) Update IntelliJ settings (#15629) Chore(deps): Bump org.openrewrite.recipe:rewrite-recipe-bom (#15626) Fixed year not appearing on citation key for In-* entries due to orphaned crossref links (#9071) (#15582)

Related issues and pull requests
Closes #9071
PR Description
Previously, the BibDatabase.citationIndex only updated upon entry creation or deletion, leading to a stale index cache when individual link fields like crossref were edited. This PR intercepts FieldChangedEvent to safely update the index map dynamically whenever a SINGLE_ENTRY_LINK or MULTIPLE_ENTRY_LINK is modified. This ensures the map stays perfectly synced and prevents child crossref links from becoming permanently orphaned when a parent's citation key is later auto-generated.
Steps to test
Checklist
I own the copyright of the code submitted and I license it under the MIT license
I manually tested my changes in running JabRef (always required)
[/] I added JUnit tests for changes (if applicable)
I added screenshots in the PR description (if change is visible to the user)
I added a screenshot in the PR description showing a library with a single entry with me as author and as title the issue number
I described the change in CHANGELOG.md in a way that can be understood by the average user (if change is visible to the user)
[/] I checked the user documentation for up to dateness and submitted a pull request to our user documentation repository
Test Video
JabRefTestingVideo.mp4