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?)

@enjeck

enjeck commented Oct 15, 2026

Copy link
Copy Markdown
Contributor

Hello!

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?)

I too am confused why you chose to store grid pages as views without a table. Until now, a view in Tables has always been a projection of one table. By changing this, we're now forced to add null checks to rows, columns, imports, relations, permissions and activity. And to changes the uuid index and the technical names of views.

Why not just use Applications, which already have a page model. tables_contexts_page has a page_type column, and today only the start page uses it. Did you consider adding a grid page type there instead? Data widgets would then point at the application's own tables and views. Sharing the application would also share the data on its pages. Unless I'm missing something.

@rubenvdlinde

rubenvdlinde commented Oct 15, 2026 •

Copy link
Copy Markdown

Thanks both, and especially @enjeck for the direct question. You're right, and we'd like to explain how we got here and then change course.

Why we built pages as views

Our starting point was openbuild, which stores its pages in OpenRegister. There, a page is one entity holding its widgets and their layout. When we brought that to Tables, we looked for the smallest change to existing code. Views already had a grid-like shape, a UUID, a technical name and an API. Adding a type to views felt safer than touching the application model.

Applications felt like the riskier place. tables_contexts_page and tables_contexts_page_content drive every application's start page today. Changing them meant changing how existing applications load and render. We worried about breaking working installs, so we stayed out.

That trade-off was wrong. The null checks in rows, columns, relations, permissions and activity are the price of it. So are the UUID index and technical name changes @blizzz raised. A view is a projection of one table, and it should stay one.

Changing the course (or as we say in dutch Beter ten halve gekeerd dan ten hele gedwaald)

Tables already has the split openbuild keeps in one entity. A page belongs to an application. A content row places one of the application's nodes on that page. That content row is our widget, only without a type, a configuration or a position. So we would need to add that there.

Building on it also fixes the permission question. A data widget can only point at a node of its own application. Sharing the application then shares the data on its pages, with the permissions already set on the node. So yeah... Better achitecture all together.

Extending it without breaking anything

  • New columns on tables_contexts_page: title and technical_name, both nullable.
  • New columns on tables_contexts_page_content: type (default data), configuration (nullable), and grid_x, grid_y, grid_width, grid_height (nullable).
  • node_rel_id becomes nullable (for widgets without data such as a header or a text block).
  • A new page type grid. The start page keeps its type.

One query needs real work: ContextMapper::getFindContextBaseQuery joins page content and nodes separately, then filters with pc.node_rel_id = r.id OR pc.node_rel_id IS NULL. A content row without a node would then match every node of the application. We'll move that condition into the join and select pc.node_rel_id itself. The response stays the same. This query loads every application, so it gets its own tests on all supported databases.

Two side effects are worth knowing. The scheme import already sets node_rel_id to null when it can't map a node, which fails today against the NOT NULL column. That gets fixed along the way. And a scheme exported from the new version won't import into an older Tables if it contains widgets without data. We'll note that in the changelog. But it does mean that user using the new version can export to old versions. We don't consider that breaking but you might.

Existing start pages

We would need to do something to keep uniformity here. the start page moves to the grid as well, so applications have one way of showing pages. Existing content rows load like this:

  • A row without a type is a data widget for its node.
  • A row without a position is full width, 5 grid rows high, stacked by its current order.
  • A table added to an application later lands below the lowest widget, in the same way.

Existing start pages therefore show their tables and views in the same order as today. Nothing in the database changes until someone saves the page in the grid editor (so its backwards compatible).

Every save also fills in order from the position, top to bottom and then left to right. Older clients, which read only order and skip content without a node, still show the data in the right order. The reorder endpoint keeps working too. On a page with positions, the grid editor becomes the place to arrange content. We'd replace the order list in the edit dialog with a link to it.

So whats next?

This PR and three of the PRs stacked on it store pages in views. Those we'll close:

The canvas (#3079) and the widget forms (#3081) are frontend code, and most of it carries over. They are stacked on the PRs above, so they'll come back as new PRs on top of the page model. The data widget will point at a node of the application instead of a view.

The application menu (#3086 to #3089) is a separate stack on main and does not depend on any of this. It is still in review, so nothing of it has landed yet. Today a menu item points at a view, a table or a URL. We'll add a page target while the stack is in review:

Until the page model lands, the dashboard item opens today's start page. Once it lands, the same page shows as a grid, and the menu item stays as it is. Existing applications get no automatic item; their menu stays as saved.

What we'll do

  1. Add the page target and the dashboard item to the menu stack (feat(contexts): add a menu to applications #3086 to feat(contexts): edit the menu when creating an application #3089).
  2. Open a new PR with only the page model: the migration, the entities, the query fix with tests, and the reading of existing rows. No UI.
  3. Add the page and widget endpoints, including the widget types from feat(api): list widget types and create views without a table on api/2 #3077.
  4. Bring back the canvas and the widget forms on top of that, and show every page of an application in the grid, the dashboard included.

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.

5 participants