User tests: Successful: Unsuccessful:
Hi all,
Hit this on a production Joomla 5.4.6 site while trying to clean up a stuck check-in state. Tracing it back showed the bug isn't environment-specific, it's a predicate mismatch in CheckinModel that any site can hit once a row has been edited and released via the standard Table API.
Pull Request resolves # none (bug not previously reported on the issue tracker).
CheckinModel::checkin() and CheckinModel::getItems() both use WHERE checked_out IS NOT NULL for nullable checked_out columns. By Joomla's own convention, checked_out = 0 means "not locked": Joomla\CMS\Table\Table::checkIn() explicitly sets $this->checked_out = 0 to release a lock, and Table::isCheckedOut() tests > 0. The IS NOT NULL predicate matches 0, so every row that has been edited and properly released via the API gets counted as needing check-in.
This PR replaces the IS NOT NULL clause with > 0 in both methods. > 0 is correct for nullable columns too because NULL > 0 evaluates to NULL, which WHERE treats as false. The change is two identical hunks; the if/else branches on column nullability become redundant and collapse to a single line.
checked_out column (e.g. #__modules, #__menu, #__ats_cannedreplies if you have Akeeba ATS installed, or any third-party table that defaults checked_out to 0).UPDATE #__modules SET checked_out = 0, checked_out_time = NULL WHERE id = <some-id>;. (You can also produce this state naturally by editing the row in the admin and saving it, which causes Joomla to call Table::checkIn() and store 0.)Without this PR: the table appears in the list with a count of 1, ticking it and clicking Check-in shows a "success" banner with the raw key text COM_CHECKIN_N_ITEMS_CHECKED_IN, and the row stays in the same state.
With this PR: the table does not appear in the list. To verify the working path still works, set checked_out = 1 (or any value > 0) on a row; the table will appear, ticking and clicking Check-in shows "Item checked in." and clears the lock.
I also confirmed multi-row counts and pluralization still render correctly by locking three rows with checked_out > 0 and checking them in (banner shows "3 items checked in.").
Global Check-in lists tables with a count of N when N rows have checked_out IS NOT NULL, including rows where checked_out = 0 (which are not locked, by Joomla's own convention). Clicking Check-in on such a table runs an UPDATE that matches the same rows but sets checked_out = DEFAULT (already 0) and checked_out_time = NULL (already NULL), so getAffectedRows() returns 0. Text::plural('COM_CHECKIN_N_ITEMS_CHECKED_IN', 0) has no matching _0 variant and no base fallback in com_checkin.ini, so the success banner renders the raw key string.
Tables only appear in Global Check-in when at least one row has checked_out > 0 (i.e. is actually locked by a user). Checking those tables in clears the lock and the success banner reads "Item checked in." or "N items checked in." as appropriate. Tables whose rows are all in the released-via-API state (checked_out = 0 or NULL) no longer appear at all, which is the correct behavior because there is nothing to check in.
Please select:
Documentation link for guide.joomla.org:
No documentation changes for guide.joomla.org needed
Pull Request link for manual.joomla.org:
No documentation changes for manual.joomla.org needed
5.4-dev @ 83d4bf7afe. Identical lines are present on 6.1-dev, 6.2-dev, and 7.0-dev; happy to forward-port or leave that to maintainer up-merge per project convention.Thanks for taking the time to review. Happy to adjust anything you'd like changed.
| Status | New | ⇒ | Pending |
| Category | ⇒ | Administration com_checkin |
@richard67 Thanks for the careful read.
On portability: this is ANSI SQL three-valued logic. NULL > 0 evaluates to UNKNOWN, and WHERE treats UNKNOWN as not-true, so the row is filtered. MySQL, MariaDB, and PostgreSQL all behave this way consistently (SQL-92 behavior, predates all three).
On the underlying convention in core: the "both NULL and 0 mean released" semantic is set by libraries/src/Table/Table.php on 5.4-dev @ 83d4bf7afe:
Table::checkIn() writes either NULL or 0 depending on $_supportNullValue (Table.php#L1286-L1291):
$nullID = $this->_supportNullValue ? 'NULL' : '0';
...
->set($db->quoteName($checkedOutField) . ' = ' . $nullID)Table::isCheckedOut() treats both as "not locked" via PHP truthy test (Table.php#L1450):
if (!$against || ($against == $with)) {
return false;
}!0 and !null both short-circuit to "not checked out".
So both states (NULL and 0) genuinely occur on this column after a clean release, and need to be filtered out either way. checked_out > 0 does that in one predicate; IS NOT NULL AND > 0 does it in two with identical SQL semantics.
On the readability of the two-condition form: my hesitation is that the redundant predicate reads as authorial uncertainty. A reader who doesn't know the NULL semantics sees both conditions and asks "why both?", which is the same knowledge gap the suggestion was meant to close. It moves the confusion rather than removing it.
If reader comprehension is the underlying concern, I'd happily add a short inline comment instead:
// checked_out > 0 also excludes NULL rows (NULL > 0 is UNKNOWN, treated as false by WHERE),
// matching Table::checkIn() which writes either NULL or 0 to release a lock.
$query->where($db->quoteName('checked_out') . ' > 0');That keeps the predicate clean while making both the NULL behavior and the cross-reference explicit. Let me know which way you'd prefer.
| Labels |
Added:
PR-5.4-dev
|
||
| Title |
|
||||||
@richard67 Good point on the style. Switched to the block form and pushed it:
/*
* checked_out > 0 excludes both release states Table::checkIn() can write:
* NULL (when $_supportNullValue) and 0 (otherwise).
* NULL > 0 evaluates to UNKNOWN, which WHERE treats as false.
*/
$query->where($db->quoteName('checked_out') . ' > 0');I have tested this item ✅ successfully on 6714a20
Tested successfully but did not encounter the Text::plural('COM_CHECKIN_N_ITEMS_CHECKED_IN', 0) issue before the PR.
I have tested this item ✅ successfully on 6714a20
Tested successfully but did not encounter the Text::plural('COM_CHECKIN_N_ITEMS_CHECKED_IN', 0) issue before the PR.
Please reduce the comment to a one-liner: // Both NULL and 0 represent records that are not checked out.
I have tested this item ✅ successfully on 6714a20
I changed the admin module data in phpMyAdmin with a checked_out of '0' and checked_out_time 'null' (by flag)
Maintenance: Global Check-in showed the module for checkin before apply of PR and did not display it after
Additional tested article state checked out and it appeared without the "admin module" checkout state
I have tested this item ✅ successfully on 6714a20
I changed the admin module data in phpMyAdmin with a checked_out of '0' and checked_out_time 'null' (by flag)
Maintenance: Global Check-in showed the module for checkin before apply of PR and did not display it after
Additional tested article state checked out and it appeared without the "admin module" checkout state
@MacJoom Done, pushed in 9c956c1. Both occurrences now use your wording verbatim:
// Both NULL and 0 represent records that are not checked out.
$query->where($db->quoteName('checked_out') . ' > 0');Comment-only change, no logic difference from the commit @QuyTon and @ThomasFinnern tested.
RTC
| Status | Pending | ⇒ | Ready to Commit |
RTC
RTC
then the same change should be made here
joomla-cms/plugins/task/globalcheckin/src/Extension/Globalcheckin.php
Lines 95 to 104 in c6ea2c1
and also i'm afraid that we need a better explanation i don't know where is better in code comments or in some manuals
something like :
// Both NULL and 0 represent records that are not checked out.
// NULL > 0 evaluates to NULL/false in SQL; 0 > 0 is false.
// Only user IDs (positive integers) indicate active locks.
@alikon Good catch on the task plugin. plugins/task/globalcheckin/src/Extension/Globalcheckin.php has the identical if/else on column nullability, so the same predicate mismatch is there. Happy to fold that into this PR so both call sites stay consistent. (Impact there is milder in practice: with the default delay > 0, the checked_out_time < $delayTime clause already filters checked_out = 0 rows because their time is NULL, so it only bites at delay = 0 or on rows with a stale non-NULL checked_out_time. Same defect regardless.)
On the comment, I need a steer before I touch it. The three-line version you've quoted is essentially what was in 6714a20; @MacJoom asked me on 07-10 to reduce it to the single line, I did that in 9c956c1, and the RTC followed. I don't want to revert one maintainer's request on another's say-so and start a ping-pong.
@MacJoom @richard67 @alikon, could you settle on one form? I'll implement whichever you agree on and apply it identically at all three sites (both CheckinModel methods plus the task plugin).
If the fuller explanation wins, note that @richard67 flagged earlier that multi-line inline comments should use the block style rather than stacked // lines, which matches core (ApplicationModel, InstallModel, et al.). So it would look like:
/*
* Both NULL and 0 represent records that are not checked out.
* NULL > 0 evaluates to UNKNOWN, which WHERE treats as false, and 0 > 0 is false.
* Only a positive user ID indicates an active lock.
*/
$query->where($db->quoteName('checked_out') . ' > 0');I've used "UNKNOWN, which WHERE treats as false" rather than "NULL/false" since that's the precise three-valued-logic behavior, but I'm not attached to the wording.
Heads up that adding the plugin fix will drop the RTC label and invalidate the two successful tests from @QuyTon and @ThomasFinnern, so it goes back through the test queue. I think that's the right trade rather than shipping the fix at only two of the three call sites.
On the comment, I need a steer before I touch it. The three-line version you've quoted is essentially what was in
6714a20; @MacJoom asked me on 07-10 to reduce it to the single line, I did that in9c956c1, and the RTC followed. I don't want to revert one maintainer's request on another's say-so and start a ping-pong.@MacJoom @richard67 @alikon, could you settle on one form? I'll implement whichever you agree on and apply it identically at all three sites (both
CheckinModelmethods plus the task plugin).
I am ok with the short comment, but I can also live with the long comment. So @MacJoom and @alikon have to fight it out, or we get more opinions.
Heads up that adding the plugin fix will drop the RTC label and invalidate the two successful tests from @QuyTon and @ThomasFinnern, so it goes back through the test queue. I think that's the right trade rather than shipping the fix at only two of the three call sites.
I agree. Would be good to fix it all at once in the same way. It would also need to add a test case for the global checking task plugin to the testing instructions.
If the field is well documented in the manual (which i can not confirm now) then we don't need a comment in the code at all. so one line should be enough. Imagine all decisions in the code be documented with a block...
| Labels |
Added:
RTC
|
||
for the comment i'm ok with the one-liner
it took me sometimes to understand when reading the code but probably that's me 🥲
| Status | Ready to Commit | ⇒ | Pending |
| Labels |
Removed:
RTC
|
||
| Category | Administration com_checkin | ⇒ | Administration com_checkin Front End Plugins |
| Title |
|
||||||
@richard67 @MacJoom @alikon Sounds like consensus, so I've pushed it in 7d8fe7f.
richard67's one-liner is now at all three call sites:
// The following expression evaluates to false for values <= 0 as well as for NULL values
$query->where($db->quoteName('checked_out') . ' > 0');I also folded in the task plugin fix @alikon spotted. plugins/task/globalcheckin/src/Extension/Globalcheckin.php had the identical if/else on column nullability and now uses the same predicate.
As flagged, adding the plugin drops RTC and invalidates the successful tests from @QuyTon and @ThomasFinnern, since this is no longer a comment-only change. Apologies for the extra round. Testing instructions in the description now include a task plugin case, per @richard67's request. Note that on the plugin side the difference is a no-op write either way, so it reads as a correctness fix rather than a visible behavior change; the steps spell that out so nobody burns time hunting for a symptom that isn't there.
I've also retitled the PR, since "CheckinModel" no longer covers the full scope.
I have tested this item ✅ successfully on 7d8fe7f
I have tested this item ✅ successfully on 7d8fe7f
I have tested this item ✅ successfully on 7d8fe7f
First tests like above,
Scheduled Tasks:
checked_out = 1, checked_out_time = '2026-07-16 15:51:22' ==> null, null
checked_out = 0, checked_out_time = '2026-07-16 15:59:36' ==> 0, 2026-07-16 15:59:36
I have tested this item ✅ successfully on 7d8fe7f
First tests like above,
Scheduled Tasks:
checked_out = 1, checked_out_time = '2026-07-16 15:51:22' ==> null, null
checked_out = 0, checked_out_time = '2026-07-16 15:59:36' ==> 0, 2026-07-16 15:59:36
| Status | Pending | ⇒ | Ready to Commit |
RTC
RTC
| Labels |
Added:
RTC
|
||
✅ Final test before merge with JBT and MariaDB
content table is listed in Global Check-in after hacking article's checked_out with 0 before PRcontent table is no more listed in Global Check-in for an article with checked_out = 0content table is listed in Global Check-in for an article being editedbanners and guidedtours as two more tables:
checked_out = 0 they are listed in Global Check-inchecked_out = 0 for an article entry, the content table is not listed in Global Check-incontent table is not listed in Global Check-in for an article with checked_out = 0content table is listed in Global Check-in for an article being editedJust something to consider regarding this global change: the following tables contain a checked_out column:
checked_out is compared against NULL. Should these places also be changed?
find . -name \*.php | xargs grep -n "'checked_out'"| grep NULL
checked_out is compared against NULL. Should these places also be changed?
find . -name \*.php | xargs grep -n "'checked_out'"| grep NULL
⚠️ Question: There are other places wherechecked_outis compared againstNULL. Should these places also be changed?* Searched with `find . -name \*.php | xargs grep -n "'checked_out'"| grep NULL` * libraries/src/Table/MenuType.php:222 * administrator/components/com_banners/src/Helper/BannersHelper.php:59 * administrator/components/com_templates/src/Model/StyleModel.php:473 * administrator/components/com_templates/src/Model/StyleModel.php:493
These other places handle core table written by core only, so checking for NULL only is sufficient. For the global checkout handled in this PR here we have to handle also tables of 3rd party extensions, that's why we cannot rely on NULLbeing used.
| Status | Ready to Commit | ⇒ | Fixed in Code Base |
| Closed_Date | 0000-00-00 00:00:00 | ⇒ | 2026-07-17 17:37:03 |
| Closed_By | ⇒ | muhme | |
| Labels |
Added:
bug
|
||
Thank you very much @genr8r for your contribution. Thanks to @richard67, @alikon and @MacJoom for supporting. Thank you to @ThomasFinnern, @QuyTon and @alikon for testing.
@genr8r Can we be sure that it works this way on all supported databases (MySQL, MariaDB and PostgreSQL)?
And even if it does, it is hardly understandable for a code reader who is not an SQL expert.
For better readability and to make sure it always works like we want, I would suggest:
That will result in
That works the same way, and I don't really expect a performance issue with the additional
IS NOT NULLcondition.That's just an idea or suggestion, not a change request by me.
What do you think about it?