Skip to content

Add entityType discriminator and table-scoped HTS queries - #683

Closed
ruolin59 wants to merge 17 commits into
linkedin:mainfrom
ruolin59:views-entity-type-discriminator
Closed

Add entityType discriminator and table-scoped HTS queries#683
ruolin59 wants to merge 17 commits into
linkedin:mainfrom
ruolin59:views-entity-type-discriminator

Conversation

@ruolin59

@ruolin59 ruolin59 commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

This is the first PR toward supporting Iceberg views in OpenHouse. Views will share the
(databaseId, objectId) key space with tables, so this change adds the discriminator that tells
them apart and makes the existing table queries filter on it.

The new entity_type column runs from MySQL/H2 through HTS, the generated client, and the internal
HouseTable pointer. NULL and TABLE mean table, VIEW means view. The column is nullable and
not backfilled, so existing rows and existing table writes are unaffected. Nothing writes VIEW
yet, so this is inert at runtime.

JDBC methods

Entity type is chosen by calling a different method rather than by passing an argument.

Scope Methods
Neutral findBy…, existsBy…, deleteBy…, findById, existsById, deleteById, renameTableId
Databases findAllDistinctDatabaseIds ×2
Both types findAllByFilters ×2, findAllByDatabaseIdAndTableIdLikeAllIgnoreCase ×2
Tables only (new) findTableBy…, findAllTablesByFilters ×2, findAllTablesByDatabaseIdAndTableIdLikeAllIgnoreCase ×2

Nothing existing was renamed or changed behaviourally. The shared JPQL moved into constants
(COMMON_FILTER_CLAUSES, PATTERN_KEY_CLAUSES, TABLE_ROW_PREDICATE) so each table-scoped method
composes it, and the expansions were compared to confirm the general queries are byte-identical to
before. View methods follow the same shape and land with the code that calls them.

Three things shaped this:

  • Methods returning database names stay unfiltered. Filtering them would hide a database that
    contains only views, even though it exists and is addressable.
  • Point reads and mutations on the shared key stay neutral, since the PUT, delete, and restore paths
    need to see a row of any type to detect a collision.
  • The pattern methods kept dedicated table-scoped versions instead of folding into
    findAllByFilters, which matches tableId exactly. Merging a LIKE parameter would make _
    behave as a wildcard, and identifiers here often contain underscores.

findAllByDatabaseIdIgnoreCase ×2 were dropped. The paged one was never called, and paged
listTables already went through findAllByFilters, so findAllTablesByFilters covers both.

Callers need no type logic

HTS returns the correct rows, so none of its callers check entity type. A view 404s from the table point
read, surfaces as HouseTableNotFoundException, and reads as absent — which makes doRefresh,
dropTable, findTableRefById, and rename-source all correct as written. dropTable matters here
because it bypasses loadTable to survive corrupted metadata, so a guard on the refresh path would
have missed it.

Name occupancy — "what is at this key?" — needs to see rows of any type, and its only callers are
CREATE TABLE and the rename-destination check. That lands with the view work.

Incidental fix

MapStruct applies any String -> String method on a mapper to every String property.
stripOhNamespace assumed a non-null key, which held only because HouseTable had no nullable
String field until now. It is now null-safe; a null tableVersion would have hit the same bug.

Tests

Existing tests cover every changed call site in UserTablesServiceImpltestGetUserTables,
testUserTableQuery, testGetUserTablesWithTablePattern, testGetUserTablesWithSearchFilter,
testUserTableGet, testListDatabases. All still pass unmodified: the diff on that test class is
247 insertions, 0 deletions, and no existing assertion anywhere in this PR was deleted or
relaxed. Since the table behaviour was meant to be unchanged, those tests are the regression proof.

New tests were written before the implementation. They cover both-types vs tables-only results
across the filter and pattern families, page counts with views interleaved, the point read treating
a view as absent while the neutral read still returns it, and unrecognised discriminators failing
closed. Page assertions check content, size, total elements, and total pages, so filtering a
returned page instead of the query would fail them. Case handling is asserted in Java, since H2 in
MODE=MySQL is case-sensitive and production MySQL is not.

Module Tests Failures
services:housetables 177 0
iceberg:openhouse:internalcatalog 87 0
services:tables 475 0
tables-test-fixtures_2.12 8 0
openhouse-spark-3.5-itest 66 0

Both fixture variants compile and the 1.2 fixture's tests run, since HouseTableRepository is
inherited by Spring Data proxies in published fixture code.

Rollout

schema.sql uses CREATE TABLE IF NOT EXISTS, so production needs
ALTER TABLE user_table_row ADD COLUMN entity_type VARCHAR(128) DEFAULT NULL before deploying this pr. No backfill needed. Deploy HTS before the tables service, since the filtering lives in HTS.

@ruolin59
ruolin59 force-pushed the views-entity-type-discriminator branch from dddcf42 to 76c72fb Compare August 13, 2026 23:54
@ruolin59 ruolin59 changed the title Add entityType discriminator and isolate tables from views Add entityType discriminator and table-scoped HTS queries Aug 17, 2026

@cbb330 cbb330 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for the change!

The piece I want to understand is why views should live in the same rows as tables. Iceberg does not require that, it needs a pointer to view metadata and a views API.

apache/polaris also puts both in the same store under one unique name. but /tables and /views stay different APIs, so a table call never returns a view. this is compatible with the iceberg rest spec as well.

Adding entityType onto UserTable gets the unique name. but GET /hts/tables will 404 a view, which is ok, but PUT / delete / rename on that path still see any row, and a view can be written through the tables endpoint. That is what will confuse people later: the column is still table_id.

Could you say why not a separate view table, and whether views get their own endpoint before anything writes a VIEW?

metadata_location VARCHAR (512) ,
storage_type VARCHAR (128) DEFAULT 'hdfs' NOT NULL,
creation_time BIGINT DEFAULT NULL,
entity_type VARCHAR (128) DEFAULT NULL,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

entity_type will change the composite PK, and require updating the index in mysql. so this should be as tiny as possible, to allow the index to scale. consider either: a new type (enum) or smaller value.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

hey @cbb330, I'm not sure how adding a new general column will modify the composite PK. As you can see in the unchanged line 12, the primary keypair remains as (database_id, table_id). Unless you are suggesting that we should add entity_type as part of the PK? This is not necessary because we specifically are relying on the fact that the pk is (database_id, table_id) to enforce naming uniqueness across views and tables.

Re. your other comment related to separating tables for views, this is a part of the fundamental design which has already gone over multiple rounds of review and has been documented extensively. Briefly here, table paths will only see tables, and view paths will only see views, neither will see the other except for the case of confirming name uniqueness

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

You're right that this column does not change the PK.

Could you add the comparison for keeping views on /hts/tables vs a separate view endpoint (or a separate table)? Unique names can live in one store either way, so I want the data for this API choice: what it buys, what it costs, and what was tried.

In this PR, GET /hts/tables hides views, but PUT / delete / rename still see any row. If /hts/views is meant to own those writes, when does it land relative to the first VIEW row?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

this pr is the first of a series of prs that implements views, it only adds the new column to differentiate between tables and views in HTS and does not modify the behavior of any endpoints, except to ensure that fetching tables will not return views. As of this pr, there is still no way to insert a view into the hts

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

  1. How does a table write not overwrite a view?

This PR makes GET /hts/tables treat a view identifier as missing, while PUT / delete / rename still key only on (databaseId, tableId). PUT already accepts entityType=VIEW.

The M1 doc puts the 409 (NAME_ALREADY_EXISTS_AS_TABLE) and “table drop/rename reject VIEW” on the tables service, not in HTS. Which change actually prevents a table PUT from attaching to that row? If it is a later PR, what stops a write through HTS in the meantime?

  1. Why does a separate table not work?

This PR puts entity_type on user_table_row. The M1 doc says two key spaces would let a table and a view share a name, and that cannot be tightened later.

That argues against two independent names, not against two tables with a uniqueness check. Why does that option lose?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

  1. this pr does not add any paths pertaining to views, it is only scoped to adding entityType in the db to be able to differentiate views. There is another pr that will implement the actual api paths which ensures that table endpoints only touch tables, and view endpoints only touch views
  2. please read the on the extra context discussion doc linked at the beginning of M1, it goes into details as to why 2 tables don't work to ensure uniqueness

@cbb330 cbb330 Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

  1. GET /hts/tables hides views. but, the other /hts/tables APIs still see them. PUT / delete / rename stay on findById and will return or update a VIEW row.

For this PR, either drop entityType=VIEW from the PUT body until the later PR, or reject a VIEW row on table PUT / delete / rename (see it, don't use it). Until one of those lands, any caller of the unfiltered methods, or a new @Query without the predicate, sees views. The tables-service client is told not to filter at all.

Stepping back a bit and looking at the big picture: the view exclusion is a predicate on specific read methods, not on the row. Every new table query has to remember it. That feels like the wrong layer storage or the model should own it, not each API. The current gaps are below:

API Filtered?
GET /hts/tables yes
GET /hts/tables/query yes
GET /v1/hts/tables/query yes
GET /hts/tables/querySoftDeleted no — other table, no entity_type
PUT /hts/tables no
DELETE /hts/tables and /v1/hts/tables no
PATCH /hts/tables/rename no
PUT /hts/tables/restore no — occupancy is findById
DELETE /hts/tables/purge no — other table
list databases (empty query) no — findAllDistinctDatabaseIds
  1. Read the discussion section of your doc. Two tables vs one is the same cost. Table and view are independent rows either way. Occupancy for both is two HTS lookups, the result is O(1)×2 on the index. @mkuchenbecker can you call this?
one table + entityType two tables
CREATE 2 lookups + insert 2 lookups + insert
READ (typed) 1 lookup 1 lookup
READ (name) 2 lookups 2 lookups
UPDATE 1 lookup + write 1 lookup + write
DELETE 1 lookup + delete 1 lookup + delete

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

On the unfiltered methods: those are neutral by design, not by omission, and filtering them is what would introduce a bug.

restoreUserTable is the clearest case. It checks occupancy with findById (UserTablesServiceImpl:195-201), throws AlreadyExistsException if anything holds the name, and catches DataIntegrityViolationException for the TOCTOU window. That only works because the read sees every row type. Scope it to tables and a VIEW holding the name becomes invisible — restore proceeds, and save() overwrites the view row, because it's the same primary key. The "gap" is the thing preventing the clobber.

Same for PUT and rename: cross-type collision detection requires seeing rows you don't own. That's pinned by testNeutralPointReadStillSeesEveryEntityType.

On rejecting VIEW rows today: nothing populates entityType in this PR, so no VIEW row can exist. A rejection branch would be unreachable code with no way to test it. Population happens controller-side in the follow-up ticket — this PR contains no controller or handler changes at all, which is also why there's no entityType=VIEW PUT body to drop.

You're right about querySoftDeleted. soft_deleted_user_table_row has no entity_type, so the discriminator is lost on delete and returns NULL on restore. I found the same thing and it's tracked in BDP-108627 — it has to be fixed before any backfill, or restore reintroduces NULLs.

On two tables vs one: the cost table counts lookups and omits the constraint, which is where the difference is.

The PK (database_id, table_id) is what makes a name unique across both types. With one table the database enforces it: concurrent CREATE TABLE and CREATE VIEW on the same name, one gets a PK violation. Atomic, no application logic.

With two tables nothing enforces it. Occupancy becomes check-tables, check-views, then insert — and two concurrent creates can both pass their checks and both insert, because no constraint spans two tables. You'd need a trigger or a third names table, which is the shared key space with extra steps.

So CREATE isn't symmetric either: with one table it's insert-and-catch, not two lookups plus insert. Equal row counts, unequal guarantees — atomicity is the reason for the shared key space, not lookup count.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Right now “tables only” is pasted onto some queries and left off others. That’s the remaining concern. Callers have to pick the right method on the same repo, with no type safety.

Do this once on the type:

  1. Table reads: new class TableOnlyRow. Same table, Hibernate @Where = tables only. GET / list use this. New queries through this class hide views.
  2. Writes / occupancy: keep UserTableRow with no filter. PUT / rename / restore / delete use this. A view at that name still shows up, so these paths do not treat it as missing.

Do not copy the filter onto each query.

@mkuchenbecker mkuchenbecker left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think we should explicitly backfill vs implicitly treating null as table.

* com.linkedin.openhouse.internal.catalog.mapper.HouseTableSerdeUtils#HTS_FIELD_NAMES}, so it
* serializes as the {@code openhouse.entityType} table property.
*/
private String entityType;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can we type this with an enum or does it need to be a string?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed on the enum — adding one for the HTS-internal representation (UserTableRow, UserTableDto). It converts back to a String at the API boundary, though: UserTable feeds the OpenAPI spec and the generated :client:hts, and StorageType is the precedent here — deliberately a class of public static final Type constants with a fromString factory rather than a Java enum, so new types don't break existing clients.

* com.linkedin.openhouse.internal.catalog.mapper.HouseTableSerdeUtils#HTS_FIELD_NAMES}, so it
* serializes as the {@code openhouse.entityType} table property.
*/
private String entityType;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

How are we backfilling for existing tables?


@Schema(
description =
"Type of the catalog object occupying this (databaseId, tableId) key. Null or 'TABLE' "

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I don't like an implicit default as table as compared to ensuring this is plumbed.


Long creationTime;

String entityType;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Enum please.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed on the enum — adding one for the HTS-internal representation (UserTableRow, UserTableDto). It converts back to a String at the API boundary, though: UserTable feeds the OpenAPI spec and the generated :client:hts, and StorageType is the precedent here — deliberately a class of public static final Type constants with a fromString factory rather than a Java enum, so new types don't break existing clients.


Long creationTime;

String entityType;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

how is this being added to the existing MySQL? I have seen database upgrades as modeled as a series of DDL operations after initial creation.

*/
private String storageType;

/**

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The database needs to be updated before this code lands.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

this has been called out in the description of this pr already

* com.linkedin.openhouse.internal.catalog.mapper.HouseTableSerdeUtils#HTS_FIELD_NAMES}, so it
* serializes as the {@code openhouse.entityType} table property.
*/
private String entityType;

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.

This would be now referred in multiple places. Can we create enum for this?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed on the enum — adding one for the HTS-internal representation (UserTableRow, UserTableDto). It converts back to a String at the API boundary, though: UserTable feeds the OpenAPI spec and the generated :client:hts, and StorageType is the precedent here — deliberately a class of public static final Type constants with a fromString factory rather than a Java enum, so new types don't break existing clients.

+ "(:storageType IS NULL OR u.storageType = :storageType) AND "
+ "(:creationTime IS NULL OR u.creationTime = :creationTime)";

String TABLE_ROW_PREDICATE = "(u.entityType IS NULL OR upper(u.entityType) = 'TABLE')";

@abhisheknath2011 abhisheknath2011 Aug 18, 2026

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.

As we are adding new column entity_type to the existing table. We could consider updating existing rows to table and then rollout the changes. If there any any new table created during the table schema update and server side deployment, there will be null for those tables. So we could update those entires again. After this we should be able to remove this NULL check from the code.

@abhisheknath2011

Copy link
Copy Markdown
Member

Summary

This is the first PR toward supporting Iceberg views in OpenHouse. Views will share the (databaseId, objectId) key space with tables, so this change adds the discriminator that tells them apart and makes the existing table queries filter on it.

The new entity_type column runs from MySQL/H2 through HTS, the generated client, and the internal HouseTable pointer. NULL and TABLE mean table, VIEW means view. The column is nullable and not backfilled, so existing rows and existing table writes are unaffected. Nothing writes VIEW yet, so this is inert at runtime.

JDBC methods

Entity type is chosen by calling a different method rather than by passing an argument.

Scope Methods
Neutral findBy…, existsBy…, deleteBy…, findById, existsById, deleteById, renameTableId
Databases findAllDistinctDatabaseIds ×2
Both types findAllByFilters ×2, findAllByDatabaseIdAndTableIdLikeAllIgnoreCase ×2
Tables only (new) findTableBy…, findAllTablesByFilters ×2, findAllTablesByDatabaseIdAndTableIdLikeAllIgnoreCase ×2
Nothing existing was renamed or changed behaviourally. The shared JPQL moved into constants (COMMON_FILTER_CLAUSES, PATTERN_KEY_CLAUSES, TABLE_ROW_PREDICATE) so each table-scoped method composes it, and the expansions were compared to confirm the general queries are byte-identical to before. View methods follow the same shape and land with the code that calls them.

Three things shaped this:

  • Methods returning database names stay unfiltered. Filtering them would hide a database that
    contains only views, even though it exists and is addressable.
  • Point reads and mutations on the shared key stay neutral, since the PUT, delete, and restore paths
    need to see a row of any type to detect a collision.
  • The pattern methods kept dedicated table-scoped versions instead of folding into
    findAllByFilters, which matches tableId exactly. Merging a LIKE parameter would make _
    behave as a wildcard, and identifiers here often contain underscores.

findAllByDatabaseIdIgnoreCase ×2 were dropped. The paged one was never called, and paged listTables already went through findAllByFilters, so findAllTablesByFilters covers both.

Callers need no type logic

HTS returns the correct rows, so none of its callers check entity type. A view 404s from the table point read, surfaces as HouseTableNotFoundException, and reads as absent — which makes doRefresh, dropTable, findTableRefById, and rename-source all correct as written. dropTable matters here because it bypasses loadTable to survive corrupted metadata, so a guard on the refresh path would have missed it.

Name occupancy — "what is at this key?" — needs to see rows of any type, and its only callers are CREATE TABLE and the rename-destination check. That lands with the view work.

Incidental fix

MapStruct applies any String -> String method on a mapper to every String property. stripOhNamespace assumed a non-null key, which held only because HouseTable had no nullable String field until now. It is now null-safe; a null tableVersion would have hit the same bug.

Tests

Existing tests cover every changed call site in UserTablesServiceImpltestGetUserTables, testUserTableQuery, testGetUserTablesWithTablePattern, testGetUserTablesWithSearchFilter, testUserTableGet, testListDatabases. All still pass unmodified: the diff on that test class is 247 insertions, 0 deletions, and no existing assertion anywhere in this PR was deleted or relaxed. Since the table behaviour was meant to be unchanged, those tests are the regression proof.

New tests were written before the implementation. They cover both-types vs tables-only results across the filter and pattern families, page counts with views interleaved, the point read treating a view as absent while the neutral read still returns it, and unrecognised discriminators failing closed. Page assertions check content, size, total elements, and total pages, so filtering a returned page instead of the query would fail them. Case handling is asserted in Java, since H2 in MODE=MySQL is case-sensitive and production MySQL is not.

Module Tests Failures
services:housetables 177 0
iceberg:openhouse:internalcatalog 87 0
services:tables 475 0
tables-test-fixtures_2.12 8 0
openhouse-spark-3.5-itest 66 0
Both fixture variants compile and the 1.2 fixture's tests run, since HouseTableRepository is inherited by Spring Data proxies in published fixture code.

Rollout

schema.sql uses CREATE TABLE IF NOT EXISTS, so production needs ALTER TABLE user_table_row ADD COLUMN entity_type VARCHAR(128) DEFAULT NULL before deploying this pr. No backfill needed. Deploy HTS before the tables service, since the filtering lives in HTS.

Can we test the changes in docker container first and update the test results here? As a next step we could also validate the changes in test cluster before merging openhouse and li-openhouse (if any) changes. In such case in memory DB can use update schema.

// no-op for util class constructor
}

@VisibleForTesting public static final String ENTITY_TYPE_FIELD_NAME = "entityType";

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.

Can we have a common constant for this field?

StreamSupport.stream(
htsJdbcRepository
.findAllByDatabaseIdAndTableIdLikeAllIgnoreCase(
.findAllTablesByDatabaseIdAndTableIdLikeAllIgnoreCase(

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.

Any specific reasons for adding AllTables for these methods instead of All? Is view going to be served out of these methods?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

the reason is that this will be a table-only method. for views, there will be separate "view"-named methods to handle. To keep things cleanly separated, the convention in the jdbc methods will be as follows:

  • table specific methods: always filter on table-predicate, ie: is NULL OR = 'TABLE'
  • view specific methods: always filter on view-predicate, ie; = 'VIEW'
  • "general" entity fetching methods that returns entity of either: no table/view predicate filtering

@ruolin59
ruolin59 force-pushed the views-entity-type-discriminator branch from 17f29b7 to 7271380 Compare August 20, 2026 17:58
ruolin59 and others added 15 commits August 26, 2026 10:47
Tables and views share one (databaseId, objectId) pointer key space, so a
name must resolve to exactly one catalog object. This adds a nullable
entityType discriminator end-to-end and makes every table path aware of it.

Semantics: NULL and any case spelling of TABLE mean table; any case spelling
of VIEW means view; any other non-null value fails closed. The column is
nullable with no backfill, so existing rows and existing table writes are
untouched -- ordinary commits still write no discriminator.

Read paths filter in the query, never by post-filtering a returned Page. A
fetch-then-filter implementation returns short pages and inflated totals; the
predicate and its countQuery are the same shared String constant, so content
and count cannot diverge. Applied to both /hts query families, the internal
catalog listings, listHouseTables, searchTables, and database enumeration.

Write paths separate typed load from name occupancy. findById and
findTableRefById answer "can this be loaded as a table?" and hide non-table
rows; the new findOccupyingEntityTypeById answers "is this name taken, and by
what?" without parsing metadata. CREATE and rename-destination consult
occupancy before authorization, storage allocation, metadata writes, and
pointer saves, so a collision is an accurate 409 rather than a misleading
concurrency error. HTS errors propagate rather than reading as a free name.

The drop guard lives in findTableRefById and OpenHouseInternalCatalog rather
than doRefresh, because deleteTable deliberately bypasses loadTable so drops
survive corrupted metadata; a doRefresh-only guard would be inert there.

Wrong-type read and drop return 404; collisions return 409.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The @query annotations added to HouseTableRepository were inert in
production and broke a universal convention in this repo, so this reverts
that interface to its pre-change state and drops the tests that only
exercised them.

Why they were inert: TablesSpringApplication excludes
DataSourceAutoConfiguration, so the tables service has no DataSource bean
and the only @EnableJpaRepositories scan is HTS-scoped. No Spring Data
proxy of HouseTableRepository can ever be created there. The sole bean
behind that interface is the hand-written HouseTableRepositoryImpl, which
ignores @query entirely and talks to HTS over HTTP. HTS in turn already
applies the same table-only predicate in SQL inside
UserTableHtsJdbcRepository, so production filtering is complete without
these annotations.

Why they were wrong stylistically: only a handful of files in this repo
carry @query, and every one of them executes against a real database. The
established precedent for exactly this shape is HtsRepository, an empty
interface whose JPA semantics live entirely on its impl/jdbc class.
Production interfaces declare the contract; implementations own behavior.
Restoring the interface puts HouseTableRepository back in line with that,
and leaves internalcatalog's main sources with no spring-data-jpa usage
at all.

Why the removed tests go with them: the eleven deleted listing tests in
RepositoryTest, DatabasesControllerTest and TablesControllerTest ran
against the H2 Spring Data double, where the annotations did take effect.
The production methods they covered (listTables, listHouseTables,
searchTables, findAllIds) are byte-for-byte unchanged by this change set,
so those tests were verifying a test double rather than production code.
The genuine coverage for the same acceptance criteria lives in
services/housetables, where the predicate actually runs in SQL. Every
view isolation guard test that exercises real production logic is kept.

Adding the same filtering to the H2 doubles is deliberately left out; it
belongs with the view-commit work, since nothing in main sources writes a
VIEW discriminator yet, which would make the filter unreachable and
untestable today.

Verified: housetables 153, internalcatalog 124, tables 519 (was 530,
exactly the 11 removed), tables-test-fixtures 8, spark-3.5 catalogTest 66
- all green, plus spotlessCheck.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Reverts the table predicate on both findAllDistinctDatabaseIds overloads
in UserTableHtsJdbcRepository to their pre-change form, and drops the two
tests that only asserted the reverted behavior. The four table-row filters
are untouched: findAllByDatabaseIdIgnoreCase, the tableId-pattern variant,
their paginated forms, and the findAllByFilters entity-type clause remain
exactly as they are. Those are the genuine production filtering for this
ticket.

These two methods return a projection of database-ID strings, not rows, so
no view can appear in their output under any implementation. The filter did
not hide a view; it only changed which database names get listed.

That is outside the scope this change set set for itself. The design
enumerates the queries that need the table predicate and this is not among
them, the stated harm is that SHOW TABLES would return views, and the
acceptance criterion is that no view appears in a table listing. A database
listing is not a table listing.

Filtering here also contradicts three other design statements taken
together: a namespace maps to an already-existing database and is never
created implicitly, the server never auto-creates databases, and HTS infers
databases from object rows and has no way to represent an empty database.
With the filter, a database holding only views becomes non-existent by the
only existence mechanism OpenHouse has - while views may only be created in
databases that already exist.

Concretely this path is Spark's SHOW DATABASES via
OpenHouseCatalog.listNamespaces(). With the filter, a view-only namespace
would be missing from SHOW DATABASES while still being addressable at
/v2/databases/foo/views/v1.

The rule this restores: queries that enumerate objects must be type-scoped;
queries that enumerate containers must not.

Removed with it, as they asserted only the reverted behavior:
HtsRepositoryTest#testFindDistinctDatabasesExcludesViewOnlyDatabases and
HtsControllerTest#testDatabaseQueriesExcludeViewOnlyDatabases. No fixture,
helper or import became unused. The pre-existing testFindDistinctDatabases
and the entity-type case/garbage matrix are unaffected and stay.

Verified: housetables 151 (was 153, exactly the 2 removed), internalcatalog
124, tables 519, tables-test-fixtures 8, spark-3.5 catalogTest 66 - all
green, plus spotlessCheck.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ionUtils

Restores the private rootMetadataFileLocation in
OpenHouseInternalTableOperations to its pre-change form, doing the naming
work inline, and deletes MetadataLocationUtils along with its test.

The stated goal was to move this into a shared helper so the table and view
paths use one implementation. The view path is not part of this change,
so the helper has exactly one production caller: the very method it was
extracted from. That is indirection rather than sharing. The caller now
hops through a private wrapper into a public util, and
OpenHouseInternalTableOperations picked up an import and a delegation
without getting any simpler. The codecName parameter exists only to serve a
future view caller, since Iceberg's table and view compression defaults
differ, and the helper's test covered a gzip path that no production caller
passes today.

An extraction is a refactor that a second caller justifies. The view commit
work will have that second caller and can do the extraction then, with the
real shape of both callers in hand. This is the same reasoning that
deferred the HouseTableMapper ViewMetadata overload out of this change.

Behavior is unchanged, as it was when the code was extracted: identical
path format, five-digit zero-padded version, random UUID, and extension
resolved from the same codec property. Every OpenHouseInternalTableOperations
metadata-location test passes untouched. The plain-text javadoc reference to
this method in InternalRepositoryUtils#getSchemeLessPath again describes the
inline implementation it was written against.

The doRefresh non-table guard in this file is untouched; that is real view
isolation logic and stays.

Verified: internalcatalog 121 (was 124, exactly the 3 MetadataLocationUtilsTest
cases), housetables 151, tables 519, tables-test-fixtures 8, spark-3.5
catalogTest 66 - all green, plus spotlessCheck.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…nstead

Restores OpenHouseInternalCatalog#resolveFileIO to its pre-change form and
gives the raw-pointer test fixtures the storage type they were missing.

The guard was compensating for a malformed fixture, not for a production
condition. seedRawPointer built a HouseTable with databaseId, tableId,
clusterId, tableUri, tableUUID, tableLocation, tableVersion and entityType
but no storageType, so storageType.fromString(null) threw. A row seeded that
way would have thrown just the same with entityType TABLE; the discriminator
was incidental to the failure. The HTS schema settles it: storage_type is
VARCHAR(128) DEFAULT 'hdfs' NOT NULL, so a null storage type cannot exist in
production, whereas entity_type is DEFAULT NULL and is null on every
pre-existing row.

The guard was also wrong on its own terms. A real view row carries a valid
storage type, so the original code returns the view's actual storage;
skipping the row instead consults storageSelector, which can resolve to a
different storage than the one the object is really on. And it is
unreachable for the purpose it claimed: dropTable rejects a view before
reaching this line, and on the newTableOps path doRefresh already treats a
view as absent while create-over-view is stopped by the occupancy check.

So the fix belongs in the fixture. Both seedRawPointer helpers now set
storageType from storageManager.getDefaultStorage(), the same value a real
table gets through HouseTableMapper. That makes the seeded row well-formed
rather than merely tolerated.

Every view-isolation guard test still passes, and now passes because the
pointer is realistic rather than because production skips it: drop-VIEW,
rename source and destination, CREATE-over-VIEW occupancy, findTableRefById,
and the 404/409 status assertions, including all four case and garbage
parameterizations of each.

The dropTable and renameTable entity-type guards in this file are untouched,
and so is the stripOhNamespace null-safety in the mapper - entity_type is
DEFAULT NULL, so MapStruct's implicit String conversion would NPE on the
real production mapping path without it.

Verified: internalcatalog 121, tables 519, housetables 151,
tables-test-fixtures 8, spark-3.5 catalogTest 66 - all green with no count
change from this commit, plus spotlessCheck.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…e pattern queries

Deletes both findAllByDatabaseIdIgnoreCase overloads and routes listTables
through findAllByFilters, and gives the two
findAllByDatabaseIdAndTableIdLikeAllIgnoreCase overloads an entityType
parameter.

The paginated listTables already called findAllByFilters(databaseId, null,
null, null, null, null, pageable) before this change set; it was switched to
findAllByDatabaseIdIgnoreCase along the way. Consolidating restores that
shape with entityType added. The non-paginated overload now matches it.

The two plain methods were redundant with the parameterized family. Compared
clause by clause: databaseId uses the same lower() comparison, tableId is
exact equality rather than LIKE so an unset value adds no constraint, every
other filter is guarded by an IS NULL check, DISTINCT over a single PK'd root
is a no-op, and a null entityType takes the same predicate branch that the
old hard-coded table predicate expressed. Identical results, one query family
instead of two.

The pattern overloads keep their own query because folding pattern matching
into findAllByFilters would mean either a second tableId parameter or turning
its exact match into a LIKE - and OpenHouse identifiers routinely contain
underscores, so a LIKE there would silently treat them as wildcards. They now
take entityType instead, reusing the same predicate constant.

No listing method has a type baked into its name any more, and the call sites
pass the request's own entityType rather than a hard-coded value, so the view
path needs no new query methods - only entityType=VIEW at a call site.

Verified: housetables 151 and tables 519, both unchanged and green, as
expected for a refactor with identical semantics. HtsControllerTest 26,
HtsRepositoryTest 17 and UserTablesServiceTest 21 all pass, which covers the
rerouted list and pattern paths. Plus spotlessCheck.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… guards

Removes every Java-side entity-type check in the tables service and the
internal catalog, along with the tests that exercised them. What remains is
the discriminator itself and the SQL that filters on it.

Point-read type filtering is deferred to the view-commit ticket, where it
will be done at the query level in HTS - a table-scoped getUserTable plus a
neutral entity endpoint - rather than as Java guards layered on top of a
type-blind read. Shipping the guards here would mean writing them twice and
migrating callers off them a ticket later.

The epic's acceptance criteria are evaluated across all six tickets rather
than per ticket. Nothing deploys until the whole epic ships, and substantial
client work is still required before a view can be created at all, so there
is no window in which views exist unprotected by this deferral.

Removed: the doRefresh non-table guard; the dropTable guard; the renameTable
source guard and occupied-destination preflight; the findTableRefById type
filter; findOccupyingEntityTypeById and its interface declaration and shared
raw-pointer helper; and rejectNonTableNameOccupancy with both call sites.
The five production files affected are now byte-identical to their pre-change
state.

Newly dead with them: HouseTableSerdeUtils.isTableEntityType,
isViewEntityType, TABLE_ENTITY_TYPE and VIEW_ENTITY_TYPE, which had no
remaining main-source caller. ENTITY_TYPE_FIELD_NAME stays - it is
@VisibleForTesting like its neighbours in that class and backs the serde
registration test, which is substrate. Write validation keeps its own
ENTITY_TYPE_REGEX in ValidatorConstants and never depended on the removed
constants.

Kept as substrate: the schema column; UserTableRow, UserTable, UserTableDto
and UserTablesMapper plumbing; HouseTable.entityType with its serde
registration and mapper handling; the entity-type SQL predicate and its four
query users in HTS; write validation; the stripOhNamespace null-safety; and
every HTS-layer test for the list predicates and the round trip.

Verified: housetables 151, internalcatalog 87, tables 475,
tables-test-fixtures 8, spark-3.5 catalogTest 66 - all green, no surviving
test failed. Plus spotlessCheck and checkstyle.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…lers

Adds a table-scoped point read to HTS and wires getUserTable to it, so a view
at a table's key is invisible to the table path because of the query rather
than because every caller checks.

getUserTable is the single HTS endpoint behind every table point read in the
tables service, so filtering it there makes four call sites correct with no
Java guard at all:

  doRefresh          findById -> getUserTable -> 404 -> HouseTableNotFound,
                     already caught, leaves Optional.empty, refreshes from a
                     null location exactly as for an absent row
  findTableRefById   findHouseTable catches the same exception and returns
                     empty
  dropTable          findHouseTable returns empty, so the existing
                     orElseThrow raises NoSuchTableException
  rename source      loadTable(from) -> doRefresh -> no metadata -> the same
                     NoSuchTableException

findByDatabaseIdIgnoreCaseAndTableIdIgnoreCase stays neutral on purpose.
HtsRepository.findById and existsById delegate to it and back putUserTable,
deleteUserTable, restoreUserTable and renameUserTable inside HTS, which must
see a row of any type to detect a collision at a shared key. Only the read
serving getUserTable changed.

TABLE_ROW_PREDICATE returns as the single statement of "null or TABLE", with
ENTITY_TYPE_FILTER_PREDICATE now composed from it, so the row test is written
once. No view-only method is added: nothing in this change reads views, and
the list queries already reach them through the entityType parameter.

Still deferred to the view-commit ticket, because they need the neutral
fetcher: occupancy, the rename destination preflight, and reading a view back
over HTTP.

The tables-service guard tests could not follow this filter - those tests run
the H2 double, which never goes through HTS - so the coverage moves to
services/housetables where the query actually executes: the case and garbage
matrix on the new point read, the neutral read still seeing every type, the
service-level getUserTable behavior, and the HTTP 404. Replicating the
predicate into the doubles was deliberately not done; that is the
testing-the-fake pattern already reverted for the list queries.

testEntityTypePutAndGetRoundTrip now asserts the view PUT is readable through
the PUT response and the persisted row, and that the table-scoped GET returns
404. That is the deferred neutral read, not a regression.

Verified: housetables 177, internalcatalog 87, tables 475,
tables-test-fixtures 8, spark-3.5 catalogTest 66 - all green, plus
spotlessCheck and checkstyle.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Applies the agreed query-level contract: a method whose name says "table"
filters to tables, everything else stays neutral or takes entityType as a
parameter.

Renamed and filtered, because every caller assumes tables:

  findAllByDatabaseIdIgnoreCase              -> findAllTablesByDatabaseIdIgnoreCase
  findAllByDatabaseIdIgnoreCase(Pageable)    -> findAllTablesByDatabaseIdIgnoreCase(Pageable)
  findAllByDatabaseIdAndTableIdLikeAllIgnoreCase          -> findAllTablesBy...
  findAllByDatabaseIdAndTableIdLikeAllIgnoreCase(Pageable) -> findAllTablesBy...(Pageable)

"TableId" in those names is the column table_id, which under a shared key
space holds a view's name too, so the old names were column-scoped and
type-ambiguous rather than already table-scoped.

Both findAllByDatabaseIdIgnoreCase overloads were removed earlier in this
branch when listTables was consolidated onto findAllByFilters; they are
restored under the new names and listTables routes back to them. The paged
overload was declared but never called before this branch, so adopting it for
paged listTables costs nothing.

Added findTableByDatabaseIdIgnoreCaseAndTableIdIgnoreCase, which getUserTable
now calls. That is the single HTS endpoint behind every table point read in
the tables service, so the guards removed earlier are correct by
construction: findById maps a 404 to HouseTableNotFoundException, which
doRefresh already catches to leave an empty Optional and refresh from a null
location, and which findHouseTable already catches to return empty - so
dropTable's existing orElseThrow raises NoSuchTableException, findTableRefById
returns empty, and a rename whose source is a view fails in loadTable.

findByDatabaseIdIgnoreCaseAndTableIdIgnoreCase stays neutral and untouched.
findById delegates to it and backs putUserTable, deleteUserTable and
restoreUserTable, which must see a row of any type to detect a collision at a
shared key. existsBy, deleteBy, renameTableId and both
findAllDistinctDatabaseIds overloads are unchanged; findAllByFilters keeps
entityType as a parameter because general search is caller-parameterized by
design. No view-only method is added: nothing here reads views.

TABLE_ROW_PREDICATE is the single statement of "null or TABLE" and is reused
verbatim in every filtered query including the paged countQuery.

With the list and pattern queries hard-coding the table predicate again, the
entityType entry in isNonKeyFieldsNullForUserTable is load-bearing once more:
it routes a databaseId + entityType=VIEW request to findAllByFilters instead
of to a table-only listing.

Tests live in services/housetables, where the query actually runs; the
predicate was deliberately not replicated into the services/tables H2
doubles.

Verified: housetables 177, internalcatalog 87, tables 475,
tables-test-fixtures 8, spark-3.5 catalogTest 66 - all green, plus
spotlessCheck and checkstyle.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… parameter

/hts/tables and /hts/tables/query are table endpoints, so the queries behind
them hard-code the table predicate and entityType is no longer a query
parameter anywhere. Views get mirror endpoints in the view-commit ticket.

That removes the parameterized type clause entirely: ENTITY_TYPE_FILTER_PREDICATE
is deleted and TABLE_ROW_PREDICATE is the single statement of "null or TABLE",
appended to every table-named query and repeated verbatim in each paged
countQuery through the same constant. No :entityType parameter remains in the
repository.

Table-scoped reads, all filtered, none parameterized:

  findTableByDatabaseIdIgnoreCaseAndTableIdIgnoreCase   new; getUserTable calls it
  findAllTablesByDatabaseIdIgnoreCase                   restored, renamed, filtered
  findAllTablesByDatabaseIdIgnoreCase(Pageable)         restored, renamed, filtered
  findAllTablesByDatabaseIdAndTableIdLikeAllIgnoreCase  renamed, filtered
  ...(Pageable)                                         renamed, filtered
  findAllTablesByFilters                                renamed, filtered, entityType param dropped
  ...(Pageable)                                         renamed, filtered, entityType param dropped

"TableId" in the pattern names is the column table_id, which under a shared key
space holds a view's name too, so those names were column-scoped rather than
already table-scoped.

Neutral and untouched: findByDatabaseIdIgnoreCaseAndTableIdIgnoreCase, which
findById delegates to and which putUserTable, deleteUserTable and
restoreUserTable need in order to see a row of any type at a shared key; plus
existsBy, deleteBy, renameTableId and both findAllDistinctDatabaseIds
overloads. No view-only method is added; nothing here reads views.

With entityType gone from the query surface,
isNonKeyFieldsNullForUserTable and the query branch of
OpenHouseUserTableHtsApiValidator are restored to their pre-change form, so
listDatabases, listTables, listTablesWithPattern and searchTables route exactly
as at base. The transport-model @pattern stays: entityType is still a valid PUT
payload field.

Because getUserTable is the one HTS endpoint behind every table point read in
the tables service, the guards removed earlier are correct by construction. A
404 becomes HouseTableNotFoundException, which doRefresh already catches to
leave an empty Optional and refresh from a null location, and which
findHouseTable already catches to return empty - so dropTable's existing
orElseThrow raises NoSuchTableException, findTableRefById returns empty, and a
rename whose source is a view fails inside loadTable.

Tests follow the surface: the type-selection tests are replaced by ones
asserting the table-scoped families never return a view, and the entityType
query parameter is now pinned as bound-but-ignored at the mapper, service and
HTTP layers. The predicate was deliberately not replicated into the
services/tables H2 doubles.

Verified: housetables 175, internalcatalog 87, tables 475,
tables-test-fixtures 8, spark-3.5 catalogTest 66 - all green, plus
spotlessCheck and checkstyle.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Restores findAllByFilters and findAllByDatabaseIdAndTableIdLikeAllIgnoreCase
to general methods that take entityType, and adds table-scoped default methods
that delegate to them. Nothing is renamed, and the general forms stay available
for the view and neutral work.

One shared ENTITY_TYPE_PREDICATE now spells all three branches out:

  null   matches any type - genuinely general, not a table default
  TABLE  matches TABLE and a stored null, because an absent discriminator
         means a table on a column that is nullable with no backfill
  VIEW   matches VIEW

An unrecognized request value matches no branch, so garbage fails closed. Note
this changes what a null entityType means: it used to be a disguised table
default, and it now returns both types, which is why every table caller pins
TABLE explicitly.

Added, all default and owning no JPQL:

  findAllTablesByFilters x2
  findAllTablesByDatabaseIdAndTableIdLikeAllIgnoreCase x2
  findTableByDatabaseIdIgnoreCaseAndTableIdIgnoreCase

The pattern family keeps its own @query because findAllByFilters matches
tableId exactly; folding a LIKE into it would make _ a wildcard and OpenHouse
identifiers routinely contain underscores. It shares the same predicate
constant.

The point read delegates rather than carrying its own query. The alternative
was a dedicated three-clause @query, which would read slightly more directly
but would restate the table branch of a predicate that already exists. Since
the key is the primary key, at most one row can match, so unwrapping the first
element is exact. The tradeoff is that the hottest read in HTS now runs the
general select DISTINCT; the key predicate is still exact, but say the word if
you would rather pay a duplicated clause to avoid the DISTINCT.

Untouched: findByDatabaseIdIgnoreCaseAndTableIdIgnoreCase, which backs findById
for putUserTable, deleteUserTable and restoreUserTable and must see a row of
any type at a shared key; existsBy; deleteBy; renameTableId; and both
findAllDistinctDatabaseIds overloads. No view-only method is added.

Call sites: listTables and searchTables use findAllTablesByFilters,
listTablesWithPattern uses the table-scoped pattern wrapper, and getUserTable
uses the table-scoped point read. entityType is not read from the wire, so
isNonKeyFieldsNullForUserTable and the validator's query branch stay at their
pre-change form and all four routes behave as at base.

Because getUserTable is the one HTS endpoint behind every table point read in
the tables service, the guards removed earlier remain correct by construction:
a 404 becomes HouseTableNotFoundException, which doRefresh already catches to
leave an empty Optional and which findHouseTable already catches to return
empty, so dropTable throws NoSuchTableException, findTableRefById returns
empty, and a rename off a view source fails inside loadTable.

Verified: housetables 177, internalcatalog 87, tables 475,
tables-test-fixtures 8, spark-3.5 catalogTest 66 - all green, plus
spotlessCheck and checkstyle.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
entityType is no longer a parameter anywhere in the query layer. A caller
picks a type by picking a method: findAllByFilters returns both types,
findAllTablesByFilters returns tables, and findAllViewsByFilters arrives with
the view ticket.

That drops the delegating-default idea: a typed wrapper cannot tell a
parameterless general method what to filter, so each typed method carries its
own @query. To avoid restating the filter body, the six general clauses are
extracted once into COMMON_FILTER_CLAUSES and the typed sibling composes that
constant with TABLE_ROW_PREDICATE. The pattern family is split the same way
through PATTERN_KEY_CLAUSES.

The extraction is provably behavior-preserving: both findAllByFilters
overloads now read "select DISTINCT u from UserTableRow u where " +
COMMON_FILTER_CLAUSES, which expands byte-for-byte to the ba400b3 string. The
pattern overloads are restored to their ba400b3 form exactly - derived
queries with no @query at all.

Added, table-scoped, each with its own query composed from the shared
constants:

  findTableByDatabaseIdIgnoreCaseAndTableIdIgnoreCase
  findAllTablesByFilters x2
  findAllTablesByDatabaseIdAndTableIdLikeAllIgnoreCase x2

Nothing is renamed and no view method is added. Unchanged from ba400b3:
findByDatabaseIdIgnoreCaseAndTableIdIgnoreCase, which backs findById for
putUserTable, deleteUserTable and restoreUserTable and must see a row of any
type at a shared key; existsBy; deleteBy; renameTableId; and both
findAllDistinctDatabaseIds overloads. The two findAllByDatabaseIdIgnoreCase
overloads stay deleted, since findAllTablesByFilters(db, null, ...) covers
them, which is what paged listTables already did at base.

Call sites: listTables and searchTables use findAllTablesByFilters,
listTablesWithPattern uses the table pattern methods, getUserTable uses the
table point read. entityType is not read from the wire, so
isNonKeyFieldsNullForUserTable and the validator's query branch remain at their
pre-change form and all four routes behave as at base.

Because getUserTable is the one HTS endpoint behind every table point read in
the tables service, the guards removed earlier stay correct by construction: a
404 becomes HouseTableNotFoundException, which doRefresh already catches to
leave an empty Optional and which findHouseTable already catches to return
empty, so dropTable throws NoSuchTableException, findTableRefById returns
empty, and a rename off a view source fails inside loadTable.

Verified: housetables 177, internalcatalog 87, tables 475,
tables-test-fixtures 8, spark-3.5 catalogTest 66 - all green, plus
spotlessCheck and checkstyle.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Reviewers asked for an enum. Introduce EntityType {TABLE, VIEW} and use it
for the HTS-internal representation only: UserTableRow (@Enumerated STRING)
and UserTableDto. The transport model UserTable and internalcatalog's
HouseTable stay String, so a future entity type is not a breaking change
for already-deployed generated clients.

The String <-> enum hop lives in UserTablesMapper, where the transport model
meets the internal ones. It parses case-insensitively, matching what
ENTITY_TYPE_REGEX already accepts, and turns an unrecognized value into a
RequestValidationFailureException so the mapper cannot convert a client
error into a 500 the way MapStruct's implicit Enum.valueOf conversion would.

Neither the stored column text nor the wire representation changes: the
constant names are the text already written, schema.sql is untouched, and
the regenerated HTS OpenAPI spec still declares entityType as a string with
the same pattern.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
entity_type stays VARCHAR(128) DEFAULT NULL, but every UserTableRow loaded
from storage now carries a type. Replace @Enumerated(STRING) with an
AttributeConverter, because @Enumerated cannot express a default on read.

The converter is deliberately asymmetric. Read defaults: a null column is a
legacy row and resolves to TABLE. Write does not: TABLE/VIEW/null pass through
verbatim, so the column vocabulary is unchanged and no byte moves. Stamping a
type onto a write is the endpoint's job in a later step; storage must not
invent one.

Read parses case-insensitively so hydration agrees with the case-insensitive
table predicate that selected the row. Previously a legacy 'table' row was
matched by the query and then exploded while loading, which is the worst of
both; now matching and hydration are consistent. A value outside the
vocabulary is still a hard failure naming the column and the offending value,
so corruption cannot masquerade as a table.

Consequence: HTS responses now always carry an entityType where a legacy row
previously returned none. Tests are updated to assert that. A row built from a
request payload never passes through the converter, so its field is still null
in memory until the write-side migration lands.

The repository queries, schema.sql, the UserTable transport model and
internalcatalog's HouseTable are untouched.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The field had zero production readers. It was fed from an openhouse.entityType
table property that nothing ever writes, so it was always null, and that null
was the only reason stripOhNamespace grew a null check: MapStruct picks that
method up as an implicit String -> String conversion and applied it to
getEntityType(). The null also rode out to HTS as a null entityType in every
commit's PUT payload. Removing the field removes all three.

Populating a type is the write side's job and has moved to its own ticket, so
nothing in this PR consumes the field. It goes now rather than sitting as
speculative plumbing.

HTS_FIELD_NAMES is reflected over HouseTable's declared fields, so the set
shrinks on its own and openhouse.entityType stops being a recognized property
key. ENTITY_TYPE_FIELD_NAME existed only to name that key and goes with it.
stripOhNamespace is restored byte for byte to its pre-PR form; its signature is
unchanged, since narrowing the return type would silently unwire it from the 20
other String properties it still converts.

toUserTable maps to the generated client UserTable, which keeps entityType, so
the target is now explicitly ignored rather than incidentally unmapped. The
wire contract is untouched: the HTS OpenAPI spec and generated client still
declare entityType as a string.

Tests that existed only to exercise the field are deleted. That includes the
one asserting ordinary commits do not stamp openhouse.entityType: with no
field, no code path can write that key, so the assertion no longer pins
behavior.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
ruolin59 and others added 2 commits August 26, 2026 10:48
services/housetables/src/main/resources/schema.sql is a bootstrap file of
CREATE TABLE IF NOT EXISTS statements, which is a no-op against an existing
table. Production DDL is applied out of band by the MySQL/DDS team, so nothing
in the repository records that a schema change happened or in what order.

Add services/housetables/ddl/ as a lightweight manual convention: a baseline
snapshot of the schema state before entity_type, and the single ALTER TABLE
that adds it. The service does not execute these files; they live outside
src/main/resources so Spring cannot load them and they are not packaged.

Flyway/Liquibase were evaluated and rejected for now. LinkedIn's internal MySQL
spec deprecates Flyway for EI/Prod in favor of Pretzel with removal planned for
February 2026, and neither tool's validate detects live schema drift, only
history/checksum consistency, so under out-of-band execution the machinery adds
little.

The baseline definitions are derived from schema.sql and are pending
verification against production SHOW CREATE TABLE.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Instant add-column is available from MySQL 8.0.12 but eligibility also
depends on table-level properties, so the note no longer implies the
operation always qualifies. Also states what an explicit algorithm
actually buys: an ineligible table fails the statement instead of
silently taking a table copy.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@ruolin59

Copy link
Copy Markdown
Collaborator Author

I now have write access to this repo, so the same 17 commits are up as #696 on an upstream branch (rufan/views-entity-type-discriminator), rebased onto current main.

Keeping this PR open — it holds the review history, and the threads here are still the right place to continue those discussions. New review is probably easier on #696, since CI has full access there.

@ruolin59

Copy link
Copy Markdown
Collaborator Author

moving to different branch/pr at #696

@ruolin59 ruolin59 closed this Aug 28, 2026

@Query(
"SELECT u FROM UserTableRow u WHERE " + PATTERN_KEY_CLAUSES + " AND " + TABLE_ROW_PREDICATE)
Iterable<UserTableRow> findAllTablesByDatabaseIdAndTableIdLikeAllIgnoreCase(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

maybe a comment for the follow up PR, but in general, there should be some query patterns for findAllTablesAndViewsByDatabaseId(AndTableId)LikeAllIgnoreCase. User initiated queries like - list tables in a db should ideally, and have historically, returned all tables and views existing in a db. Why do we only want to get only tables / only views at this layer and let caller initiate 2 calls to HTS? instead we could return all tables + all views and let caller handle usecases in a targetted manner.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ignore this comment, cross posted on the newer PR

ruolin59 added a commit that referenced this pull request Sep 10, 2026
> Continues #683, which was opened from a fork before I had write access
here. That PR carries the review history; this one is the same 17
commits on an upstream branch, rebased onto current `main`.

## Summary

This is the first PR toward supporting Iceberg views in OpenHouse. Views
will share the
`(databaseId, objectId)` key space with tables, so this change adds the
discriminator that tells
them apart and makes the existing table queries filter on it.

The new `entity_type` column runs from MySQL/H2 through HTS and the
generated client. `VIEW` means
view; `TABLE` and a legacy `NULL` both mean table. The column is
nullable and not backfilled, so
existing rows and existing table writes are unaffected. Nothing writes
`VIEW` yet, so this is inert
at runtime.

Inside HTS the discriminator is the `EntityType` enum. It stays a
`String` on the wire, because
`UserTable` generates the OpenAPI spec and `:client:hts`, and an enum
there would make a future
entity type a breaking change for already-deployed clients.
`StorageType` sets the same precedent.
A JPA `AttributeConverter` resolves a `NULL` column to `TABLE` on read,
so `entity_type` is nullable
only inside MySQL and total everywhere in Java.

## JDBC methods

Entity type is chosen by calling a different method rather than by
passing an argument.

| Scope | Methods |
|---|---|
| Neutral | `findBy…`, `existsBy…`, `deleteBy…`, `findById`,
`existsById`, `deleteById`, `renameTableId` |
| Databases | `findAllDistinctDatabaseIds` ×2 |
| Both types | `findAllByFilters` ×2,
`findAllByDatabaseIdAndTableIdLikeAllIgnoreCase` ×2 |
| Tables only *(new)* | `findTableBy…`, `findAllTablesByFilters` ×2,
`findAllTablesByDatabaseIdAndTableIdLikeAllIgnoreCase` ×2 |

Nothing existing was renamed or changed behaviourally. The shared JPQL
moved into constants
(`COMMON_FILTER_CLAUSES`, `PATTERN_KEY_CLAUSES`, `TABLE_ROW_PREDICATE`)
so each table-scoped method
composes it, and the expansions were compared to confirm the general
queries are byte-identical to
before. View methods follow the same shape and land with the code that
calls them.

Three things shaped this:

- Methods returning database names stay unfiltered. Filtering them would
hide a database that
  contains only views, even though it exists and is addressable.
- Point reads and mutations on the shared key stay neutral, since the
PUT, delete, and restore paths
  need to see a row of any type to detect a collision.
- The pattern methods kept dedicated table-scoped versions instead of
folding into
`findAllByFilters`, which matches `tableId` exactly. Merging a `LIKE`
parameter would make `_`
  behave as a wildcard, and identifiers here often contain underscores.

`findAllByDatabaseIdIgnoreCase` ×2 were dropped. The paged one was never
called, and paged
`listTables` already went through `findAllByFilters`, so
`findAllTablesByFilters` covers both.

## Callers need no type logic

HTS returns the correct rows, so none of its callers check entity type.
A view 404s from the table point
read, surfaces as `HouseTableNotFoundException`, and reads as absent —
which makes `doRefresh`,
`dropTable`, `findTableRefById`, and rename-source all correct as
written. `dropTable` matters here
because it bypasses `loadTable` to survive corrupted metadata, so a
guard on the refresh path would
have missed it.

Name occupancy — "what is at this key?" — needs to see rows of any type,
and its only callers are
`CREATE TABLE` and the rename-destination check. That lands with the
view work.

## The tables service stays out of it

An earlier revision put an `entityType` field on the internal
`HouseTable` pointer. It had no
consumer, and it was fed from an `openhouse.entityType` property that
nothing writes, so it was
always null — which then forced a null check into
`HouseTableMapper.stripOhNamespace`, a method
MapStruct applies to every String property on the mapper.

Both are gone. `stripOhNamespace` is byte-identical to `main` again, and
the whole `iceberg/` diff
is one `@Mapping(target = "entityType", ignore = true)`. The
discriminator is owned by HTS; the
tables service has no knowledge of it.

## Tests

Existing tests cover every changed call site in `UserTablesServiceImpl`
— `testGetUserTables`,
`testUserTableQuery`, `testGetUserTablesWithTablePattern`,
`testGetUserTablesWithSearchFilter`,
`testUserTableGet`, `testListDatabases`. All still pass unmodified: the
diff on that test class is
**247 insertions, 0 deletions**, and no existing assertion anywhere in
this PR was deleted or
relaxed. Since the table behaviour was meant to be unchanged, those
tests are the regression proof.

New tests were written before the implementation. They cover both-types
vs tables-only results
across the filter and pattern families, page counts with views
interleaved, the point read treating
a view as absent while the neutral read still returns it, and
unrecognised discriminators failing
closed. Page assertions check content, size, total elements, and total
pages, so filtering a
returned page instead of the query would fail them. Case handling is
asserted in Java, since H2 in
`MODE=MySQL` is case-sensitive and production MySQL is not.

| Module | Tests | Failures |
|---|---|---|
| `services:housetables` | 185 | 0 |
| `iceberg:openhouse:internalcatalog` | 82 | 0 |
| `services:tables` | 475 | 0 |
| `iceberg:openhouse:htscatalog` | 20 | 0 |
| `tables-test-fixtures_2.12` (Iceberg 1.2) | 8 | 0 |
| `tables-test-fixtures-iceberg-1.5_2.12` | 8 | 0 |

Both fixture variants compile and the 1.2 fixture's tests run, since
`HouseTableRepository` is
inherited by Spring Data proxies in published fixture code.

## Rollout

`schema.sql` uses `CREATE TABLE IF NOT EXISTS`, so production needs
`ALTER TABLE user_table_row ADD COLUMN entity_type VARCHAR(128) DEFAULT
NULL` **before deploying this pr**. No backfill needed. Deploy HTS
before the tables service, since the filtering lives in HTS.

That DDL is also recorded under `services/housetables/ddl/`, as a
baseline snapshot plus the
`ALTER`. The files are inert — outside `src/main/resources`, so Spring
cannot execute them and
Gradle does not package them — and exist only so the sequence of schema
changes is captured in the
repository. The baseline is derived from `schema.sql` and is marked
pending verification against
production `SHOW CREATE TABLE`. Migration tooling was evaluated and
deferred; Flyway is deprecated
internally in favour of Pretzel, tracked in BDP-108649.

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants