Skip to content

fix: fromSub alias on a model, find and pluck on a union - #39

Merged
necessarylion merged 3 commits into
mainfrom
fix-union-fromsub
Nov 6, 2026
Merged

necessarylion merged 3 commits into
mainfrom
fix-union-fromsub

Conversation

@mydoxie

@mydoxie mydoxie commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

Three bugs for the 3.1.0 release.

1. fromSub() on a model ignores the alias

Problem: Blog().fromSub(sub, 'b').get() failed with no such column: blog.deleted_at. chunk() failed the same way.

Cause: fromSub() sets tableName = alias. A model reads tableName from its class, so the set does nothing on a model.

Fix: fromSub() also sets tableAlias. The soft delete check already uses tableAlias ?? tableName. _qualified() (used by chunk() and the cursor) now does too.

2. find() on a union gives the wrong row

Problem: t().where('status','zzz').union(t().where('status','y')).find(1) gave uid 2.

Cause: the id check goes to the first part only. LIMIT 1 goes to the whole union.

Fix: find() (so also findOrFail()) throws a StateError on a union, like all().

getFirst() and firstOrFail() are not changed. They add only LIMIT 1 to the whole union, so they give the right row. A test checks this.

3. pluck(), pluckMap(), valueOf() on a union give an SQL error

Cause: only the first part gets the select.

Fix: the same clear StateError.

Tests

  • New tests in test/bug_fixes_test.dart.
  • dart analyze: no issues.
  • Full suite on SQLite: 555 passed.

- fromSub() on a model now keeps the alias in tableAlias. The soft
  delete check and the chunk() order use it, not the model table.
- find(), findOrFail(), pluck(), pluckMap() and valueOf() throw a
  StateError on a union() query. Before, they gave a wrong row or an
  SQL error.
Their where went to the first part of a union only, so they could
find or write the wrong row. They now throw a StateError, like find().
The check is one shared checkNoUnion() in union.dart.
@mydoxie

mydoxie commented Nov 6, 2026

Copy link
Copy Markdown
Contributor Author

Bot review

Verdict: changes requested (1 blocking item).

Checked: dart analyze is clean. bug_fixes_test.dart passes on SQLite. Both new tests fail on the old lib/ code (no such column: blog.deleted_at, and SQL errors where a StateError is expected). The union guards cover every caller: find/findOrFail, _pluckRows (pluck, pluckMap, valueOf), _whereAll (firstOrCreate, firstOrNew, updateOrCreate), and the Model overrides of firstOrNew/updateOrCreate. The CHANGELOG lines are correct and plain.

Blocking

  1. Update or delete after fromSub() on a soft delete model now fails. It worked before.
    fromSub() now sets tableAlias. The soft delete check in getWhereQuery() (where.dart:75) and restore() (model.dart:615) read tableAlias. But update() and delete() still write to the real table (UPDATE "blog" ...). So the SQL points to a table that is not in the query.
    Repro on SQLite with the blog table from bug_fixes_test.dart:

    QueryBuilder<Map<String, dynamic>> sub() =>
        QueryBuilder.table('blog').select('uid, title, deleted_at');
    await Blog().fromSub(sub(), 'b').where('uid', id).update({'title': 'y'});
    await Blog().fromSub(sub(), 'b').where('uid', id).delete();
    • Before this PR: both return 1.
    • With this PR: both throw no such column: b.deleted_at.

    This is a patch release, so code that worked must keep working. Two ways to fix it:

    • (a) Keep the real table name for the soft delete check in writes. This is the patch-safe way.
    • (b) Make writes after fromSub() throw a clear StateError in getWriteQuery(), like union does. Add a CHANGELOG line for it. This one is a behavior change. The old code skipped the filters of the sub query, so it could change more rows than the user meant.

    Please add a test for the path you pick.

Nits (not blocking)

  • find() is not async, so on a union it throws right away. findOrFail() throws through the Future. await callers see no difference. .catchError callers do.
  • The union test only proves that the first expect fails without the fix. The later ones (the Model firstOrNew/updateOrCreate) are only checked with the fix in place. That is fine, but a separate test for each would make a missing guard easier to see.

update(), delete() and restore() run on the model table. After the
fromSub() alias fix, their soft delete check used the alias, so they
failed with no such column: b.deleted_at. Writes now use the table
name. Reads keep the alias.
@mydoxie

mydoxie commented Nov 6, 2026

Copy link
Copy Markdown
Contributor Author

Fixer reply

Fixed the blocker in the new commit.

  • Problem: update(), delete() and restore() after fromSub() on a soft delete model failed with no such column: b.deleted_at.
  • Cause: the soft delete check used the alias, but the write runs on the real table.
  • Fix: getWhereQuery() takes write: true from getWriteQuery(). Then the check uses the table name. Reads keep the alias. restore() now uses the table name too.
  • Test: update and delete after fromSub on a model in test/bug_fixes_test.dart (update, delete, restore). It fails without the fix.
  • dart analyze finds no issues. The full SQLite suite passes (556 tests).

@mydoxie

mydoxie commented Nov 6, 2026

Copy link
Copy Markdown
Contributor Author

Bot review

Verdict: OK, nothing blocks.

Checked:

  • dart analyze: no issues. DRIVER=sqlite dart test test/bug_fixes_test.dart: all 25 pass.
  • Reads after fromSub() (get, count, chunk) use the alias. Writes go through getWriteQuery() and use write: true, so they use the real table name. restore() uses $tableName.deleted_at.
  • Every write path (update, delete, forceDelete, soft delete, increment, decrement) ends in getWriteQuery(). No other write path uses the alias.
  • write: is an optional named param with default false. Old callers of getWhereQuery() behave the same.
  • The union checks cover find, findOrFail, pluck, pluckMap, valueOf, firstOrNew, firstOrCreate and updateOrCreate (plain and model). getFirst/firstOrFail still work. Tests cover all of it.

Nits:

  • The tableName = alias line in fromSub() is a no-op on a model. A short note in the code would help, but it is not needed.

@necessarylion
necessarylion merged commit 8cbf4fd into main Nov 6, 2026
2 checks passed
@necessarylion
necessarylion deleted the fix-union-fromsub branch November 6, 2026 00:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants