Skip to content

No conditional-visibility EXCLUDE rule can be created: isInclude=false is a boolean bound into a smallint column (400 on PostgreSQL = the CI default) #96

Description

@rubenvdlinde

Summary

No conditional-visibility EXCLUDE rule can be created — through any path. isInclude: false is refused with HTTP 400 on PostgreSQL, which is the shared quality workflow's default database, so this is the CI fixture too and not a local quirk.

The whole "Hide when…" half of conditional visibility is therefore dead in production. VisibilityChecker's exclude-overrides-include logic can never fire, because no exclude rule can exist to fire it.

Reproduction (isolated, identical bodies, one field different)

Against a stock nextcloud:34.0.0-apache + PostgreSQL 16, launchpad development @ 66957c17:

POST /index.php/apps/launchpad/api/widgets/164/rules
{"ruleType":"group","ruleConfig":{"groups":["marketing"]},"isInclude":true}
  -> 201 {"id":8,...,"isInclude":true}

POST /index.php/apps/launchpad/api/widgets/164/rules
{"ruleType":"date","ruleConfig":{"startDate":"2026-12-01"},"isInclude":false}
  -> 400 {"error":"Operation failed"}

POST /index.php/apps/launchpad/api/widgets/164/rules
{"ruleType":"date","ruleConfig":{"startDate":"2026-12-01"},"isInclude":true}
  -> 201 {"id":9,...}

The update path is refused the same way, so there is no create-then-toggle workaround either:

PUT /index.php/apps/launchpad/api/rules/9  {"isInclude":false}   -> 400 {"error":"Operation failed"}

The UI hits exactly this: ConditionalVisibilityEditor emits {"ruleType":…,"ruleConfig":…,"isInclude":false} on the same endpoint when a row is toggled to Exclude.

Root cause: a boolean bound into a smallint column

=# \d oc_launchpad_conditional_rules
 is_include | smallint | not null | 1

lib/Db/ConditionalRule.php:

protected bool $isInclude = true;
$this->addType(fieldName: 'isInclude', type: 'boolean');

QBMapper::getParameterTypeForProperty() maps bool to IQueryBuilder::PARAM_BOOL, so PostgreSQL is handed a real boolean for a smallint column. Confirmed directly against the table:

INSERT INTO oc_launchpad_conditional_rules (..., is_include, ...) VALUES (..., 0, ...);      -- OK
INSERT INTO oc_launchpad_conditional_rules (..., is_include, ...) VALUES (..., false, ...);
  ERROR:  column "is_include" is of type smallint but expression is of type boolean

MySQL and SQLite coerce silently, which is why this has survived: it is invisible on those backends and fatal on the one CI actually uses.

Second defect, and the reason this took so long to find

RuleApiController::addRule() reports the failure with

return ResponseHelper::error(exception: $e);

and passes no logger. ResponseHelper::error()'s own docblock says callers SHOULD pass their LoggerInterface so the real exception reaches the server log; this caller does not. The result is a generic {"error":"Operation failed"} to the client and nothing at all in nextcloud.log — an error path that is undiagnosable from either end. Worth fixing independently of the type bug, and worth sweeping for elsewhere: grep -rn "ResponseHelper::error" lib/ shows more call sites without a logger.

Candidate fixes (deliberately not chosen here)

  1. Migrate the column to boolean, matching the entity. Correct long-term; needs raw platform-specific DDL, because a Doctrine type change emits ALTER COLUMN … TYPE BOOLEAN with no USING clause and PostgreSQL rejects that on smallint.
  2. Make the entity integer-backed, matching the column (protected int $isInclude, addType('isInclude','integer')), and cast to bool in jsonSerialize() so the JSON contract is unchanged. Smaller migration risk, but it touches the two strict comparisons in the gating path — VisibilityChecker.php:146 (=== $isInclude) and ConditionalService.php:207 (=== true) — plus tests/Unit/Db/ConditionalRuleTest.php:44,80,83.

Option 2 is the app's own convention elsewhere (WidgetPlacement serialises isVisible/isCompulsory/showTitle as 1/0), but both options change behaviour in a visibility-gating path, which is why this is filed rather than patched in a coverage sweep.

Impact on gate-19

conditional-visibility-editor::include-rules-grouped-under-an-or-heading cannot be covered until this is fixed — its GIVEN requires a stored exclude rule. It is left uncovered and NOT @e2e excluded: a browser observes it fine once the rule can be saved, so an exclusion would record a false statement, and per .github#345 the gate counts an exclusion as positive coverage.

includeexclude-toggle IS covered, narrowly and on purpose — that requirement is about what the row emits and where it moves to, both observable before the request leaves the browser. tests/e2e/conditional-visibility-editor.spec.ts asserts exactly that and deliberately does not assert persistence.

Found while writing gate-19 round-3 coverage.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions