Skip to content

feat(views): allow views without a table - #3075

Open
remko48 wants to merge 8 commits into
mainfrom
feat/noid/views-without-table
Open

remko48 wants to merge 8 commits into
mainfrom
feat/noid/views-without-table

Conversation

@remko48

@remko48 remko48 commented Oct 12, 2026

Copy link
Copy Markdown

Views can now exist without a table.
With this PR we prepare for the use of pages inside an application.
Views also get a type (table or grid).
Every existing view becomes table, so nothing changes for current users.

What changes

  • Migration: tables_views gets a type column with the default table. table_id was already nullable (for federated views), so a view without a table needs no other schema change.
  • Ownership: a view normally inherits its ownership from its table. A view without a table is owned by the user who created it.
  • Creating: ViewService::create() accepts no table when the type is not table. A table view still needs a table, and a view without a table cannot be changed to table later.
  • Technical name: a view without a table is always named by its id (view_<id>). Setting a technical name on it is rejected, and an import drops it. The unique index on (table_id, technical_name) treats a missing table differently per database.
  • Guards: places that assume every view has a table now handle the missing table:
    • rows: listing returns nothing and the row count is 0; creating rows, importing or adding columns returns a not-found error
    • columns, relations and permission inheritance: nothing to inherit or show
    • activity: activities belong to a table, so a view without a table creates none
  • Listing: the user's own views without a table are now shown in their shared views, because no table shows them.
  • User deletion: When a user is deleted, views without a table are also deleted if the user owns them.
  • API: tableId is now nullable and type is added to the view response (OpenAPI and TS types regenerated). type can already be changed through the existing view update endpoints. There is no new endpoint yet; creating a view without a table through the API comes in a later PR of this stack.

🖼️ Screenshots

Backend only, no UI changes.

🏁 Checklist

  • ⛑️ Tests (unit and/or integration) are included or not needed
  • 🔙 Backport requests are created or not needed: /backport to stableX.X
  • 📅 Milestone is set
  • 🌸 PR title is meaningful (if it should be in the changelog: is it meaningful to users?)

🤖 AI (if applicable)

  • The content of this PR was partly or fully generated using AI

remko48 and others added 3 commits October 12, 2026 10:06
A view can now exist without a table and carries a type (table or grid),
so an application can hold pages that are not tied to one table.

- tables_views gets a type column and table_id becomes nullable
- a view without a table is owned by its creator and listed for them
- row, column, import, relation, permission and activity code skips
  views without a table instead of failing on the missing table
- views without a table are removed with their owner

Co-authored-by: Ruben van der Linde <ruben@conduction.nl>
Assisted-by: ClaudeCode:claude-opus-5-5
Signed-off-by: Remko <remko@conduction.nl>
Federated views have no table_id either, so a missing table alone does not
mean a view is standalone. View::isStandalone() checks for no table and no
external id, and the guards, the owner fallback and the query for a user's
standalone views now use it.

- a row update, delete or presence check against a view without a table
  no longer ends in a TypeError: the view holds no rows
- the view type is validated with the rest of the input and is typed as
  table or grid in the API
- the migration no longer makes table_id nullable, it already is

Assisted-by: ClaudeCode:claude-opus-5-5
Signed-off-by: Remko <remko@conduction.nl>
The unique index on (table_id, technical_name) does not hold for a missing
table on MySQL, PostgreSQL and SQLite, and on Oracle it makes a name unique
across the whole instance. A view without a table is therefore always named
view_<id>: create and update refuse a chosen name, and an import ignores the
name from its source. The page a user names is the application's menu item,
whose technical name is unique within the application.

Assisted-by: ClaudeCode:claude-opus-5-5
Signed-off-by: Remko <remko@conduction.nl>
@remko48
remko48 requested review from blizzz and enjeck as code owners October 12, 2026 14:41
@remko48
remko48 added this pull request to stack #3080 October 12, 2026 14:56
@blizzz blizzz added the 3. to review Waiting for reviews label Oct 13, 2026

@blizzz blizzz left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And a general question:

  • What is the motivation on the change on Technical name? They are not dependent upon a table. Technical names are user-provided aliases for easier access in in external handlers (e.g. external Flows).

Comment thread lib/Db/Row2Mapper.php Outdated
Comment thread lib/Db/Row2Mapper.php Outdated
Comment thread lib/Db/ViewMapper.php
Comment thread lib/Listener/UserDeletedListener.php Outdated
Comment thread lib/Service/ViewService.php
@blizzz

blizzz commented Oct 13, 2026

Copy link
Copy Markdown
Member

Another hint:

  • GRID-type views are not exported via TablesMigrator at the moment (for the record, not blocking)
  • Updating or deleting a row through such a view results in a 500, not 40x (not tested, from reviewing. Not blocking either).
  • UUID uniqueness is gone for these views: adding a row with an UUID but table_id being empty into oc_tables_views does not trigger the unique index constraint from
    $targetTable->addUniqueIndex(['table_id', self::COL_UUID], $indexName);
    :
image

@remko48

remko48 commented Oct 13, 2026 •

Copy link
Copy Markdown
Author

GRID-type views are not exported via TablesMigrator at the moment

Correct a later pr in the 3rd stack wil cover the export per application but not the account migration so we will fix that

Updating or deleting a row through such a view results in a 500, not 40x

We traced it back and we see that this was already a 500 for normal views too but with the gridviews we have made it is now easier to reach.
We will add a guard so it returns a 404.

This is now done in 4a25495

UUID uniqueness is gone for these views

Correct. The app always creates a fresh UUID (v7), so duplicates only come from import or manual database work. Tables already has an index on uuid alone (tables_tables_uuid_uniq); we will add the same for views, and fix existing duplicates in the migration first.

This is now done in 4a25495 and 475a169

- the row checks use isStandalone(), like the rest of the row code
- deleting a user's views without a table lives in ViewService; the listener only calls it
- a grid view cannot have a table: refused on create and when changing the type
- changing or deleting a row through a view without a table is a 404 instead of a 500
- a view's uuid is unique on its own: duplicates get a fresh uuid in a migration before the
  index is added, and an import only keeps the source uuid when it is still free

Assisted-by: ClaudeCode:claude-opus-5-5
Signed-off-by: Remko <remko@conduction.nl>
- a proxy view no longer copies the uuid of the remote view, so one view federated to
  several users on the same instance does not clash on the uuid index of views; tables
  keep the source uuid as on main
- the migration keeps the uuid on the local view when a proxy shares it, and drops the
  old (table_id, uuid) index that the uuid-only index covers
- the row checks say that federated views read their rows through the federation proxy

Assisted-by: ClaudeCode:claude-opus-5-5
Signed-off-by: Remko <remko@conduction.nl>
@blizzz

blizzz commented Oct 14, 2026

Copy link
Copy Markdown
Member

Correct. The app always creates a fresh UUID (v7), so duplicates only come from import or manual database work. Tables already has an index on uuid alone (tables_tables_uuid_uniq); we will add the same for views, and fix existing duplicates in the migration first.

Nah, we cannot have UUIDs on views alone. The naming is not ideal here. While the value is created from UUID provider, the use case is slightly bigger.

We introduced them to be able to update a Table schema, and we had to have a reliable identifier, that would also work across instances. At the same time, you should be able to import the same schema in different tables. Hence the connector between table ID and view UUID. This will break, when view UUIDs turn unique.

The difference was tables – looks like a inconsistency here – is that they were not needed at all originally. Only when we applied the same mechanics to contexts.

And thus one other question, unless @enjeck asked it elsewhere already, what is the underlying idea to use extend the case of views, instead of adding such a functionality within Contexts/Applications? My understand is to use it as an element on a context page, but it would not strictly need to be a view. Unless all other View functionalities would be used (but where do the rows come from? I assume I'd find the answer in follow-up works?)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

3. to review Waiting for reviews AI assisted

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants