HTS API alternative implementation: Give views a typed lifecycle in HTS (alternative to #697) - #703
Conversation
5c0e542 to
acdcb63
Compare
96d4e7c to
47ac84e
Compare
6cd4a82 to
0063cbb
Compare
Adds everything a view needs to be created, read, listed and dropped through the House Tables Service, plus a neutral entity read for name occupancy. This is an alternative implementation of the same ticket as linkedin#697. The HTTP contract and the SQL layer are deliberately identical to that PR; what differs is the wiring between them, which is rebuilt along the lines of the review feedback on linkedin#697. Frozen from linkedin#697: - All five view routes, the neutral read, and the table routes, with identical params, bodies and status codes. - Every JPQL constant and query, the typed conditional deletes that return an affected-row count, and the type-scoped rename. Rebuilt between those two ends: - putUserTable and putUserView each supply their own EntityType to one private persistence primitive, so neither named method can persist the other's type regardless of what the transport object carries. - The new point reads return Optional; only the handler turns absence into 404, so a dependency or hydration failure can never read as "the name is free". - View queries cross the service boundary as an owned UserViewQuery / PagedUserViewQuery rather than a transport UserTable, and the invalid "pattern without database" state is unconstructible. - Corrupt-discriminator hydration is translated to a module-owned, non-IllegalArgumentException failure at a housetables persistence adapter that consumes every result to exhaustion, so a corrupt row fails the whole call instead of yielding a partial list or page. - services:common keeps no ORM knowledge: a controller-scoped advice in housetables declares three @hidden mappings and everything else falls through to the shared advice, which is refactored behaviour-neutrally onto an extracted ErrorResponseBodyFactory. - PUT ingress validation returns an explicit outcome rather than throwing from a helper. - View metrics are owned by housetables. Existing table paths are reused as-is and not refactored; the resulting asymmetries between the old table paths and the new view paths are deliberate and documented. Tests: housetables 463, common 20, tables 496, jobs 52 - all passing. :client:hts regenerates cleanly with the six new operations. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Comments only; no code, behaviour, assertion or name changed. Removes javadoc that restated the name it sat on, and collapses the repeated @throws boilerplate on UserTableReadRepository and UserTablesService into a single statement of the failure contract at the type level. Load-bearing rationale is kept but shortened: the legacy-NULL arm of TABLE_ROW_PREDICATE, the collation assumption, why views never route through soft delete, why the type collision precedes version mapping, why the named service method owns the EntityType, why the read adapter consumes results to exhaustion, why deleteAll() is left unsealed, and the deliberately pinned stack-trace quirk. Also corrects one stale test javadoc that claimed to pin the collision ordering after that assertion moved to its own test. 459 comment lines removed; comments fall from 17% to 11% of the change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Replaces the scoped-advice design with the standard pattern: catch at the persistence boundary and rethrow an owned type, applied uniformly to table and view reads rather than views only. All 11 table read call sites now go through the translating repository alongside the view reads. It catches DataAccessException, and rethrows the unwrapped CorruptEntityTypeException when the cause chain holds one; any other failure is rethrown unchanged, so non-corrupt infrastructure failures behave exactly as before. Because corruption is unwrapped before it leaves the repository, the shared advice needs one @hidden handler and no ORM vocabulary. That removes the machinery the previous design needed to keep JPA types out of services:common: - UserHouseTablesExceptionHandler (controller-scoped advice) - ErrorResponseBodyFactory and the delegation in the shared advice - the ServiceAuditAspect pointcut widening - four housetables exception types and the cause finder - the mutation-failure wrapper on view writes services:common is now +22 lines in OpenHouseExceptionHandler and one new exception class; ServiceAuditAspect is byte-identical to base. CorruptEntityTypeException does not extend IllegalArgumentException, so stored-data corruption stays distinguishable from client-input error. View mutations now surface raw DataAccessException as a generic 500, matching table mutations rather than differing from them. Table behaviour is unchanged: no pre-existing test was modified, and tables (496) and jobs (52) match base counts. The one intended difference is that a corrupt discriminator on a table read now reports the offending column and value instead of the ORM toString(). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The four key-addressed generic deletes were sealed because each can remove a row of the wrong type. No-arg deleteAll() was left open only because test teardown called it, which is test convenience shaping a production contract. Seals it the same way and moves whole-store cleanup into a test-only UserTableStoreCleaner: a bulk JPQL delete, chosen over a second CrudRepository because @EnableJpaRepositories lives in main-scope configuration. It removes rows regardless of discriminator, so a planted corrupt row cannot survive teardown, matching what deleteAll() did. Only three pre-existing teardowns are affected. The job tests autowire a different repository, and the two iceberg implementations implement HtsRepository directly rather than the JDBC sub-interface, so neither is touched. No pre-existing assertion changed; services/common is untouched. Tables 496 and jobs 52 match base counts. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Three read-then-write windows survive this change because they are pre-existing and sit in paths it does not otherwise touch. Marking them so they are not mistaken for hazards the typed conditional statements already closed. Soft delete archives an unlocked snapshot and then deletes without a version predicate, so a writer committing in between makes the archive stale and loses that commit; the conditional delete closes only the type and existence window. Rename has the same missing version guard. Restore's occupancy check is a read-then-write whose real backstop is the DataIntegrityViolationException catch below it. Comments only. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
EntityTypeIngressResult existed so normalization could report failure explicitly rather than throw. Its only caller immediately unwrapped it and threw RequestValidationFailureException, so the indirection moved the throw two frames and bought nothing; the unreachable orElse fallback in the controller was the tell. The validator now throws that exception directly and returns the normalized entity, the result type is deleted, and the controller calls it inline rather than through a private unwrapping helper. Behaviour is unchanged: same two validation cases, same messages, same 400 via the existing advice. The validator stays an injectable component so the rule is still unit-tested directly. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The IS NULL arm of TABLE_ROW_PREDICATE exists to carry rows that predate the discriminator. Nothing should be adding new ones, but the converter happily wrote null, so that depended on every caller remembering to stamp a type rather than on anything enforcing it. convertToDatabaseColumn now rejects null, making the guarantee structural at the last point before the column. It throws IllegalArgumentException rather than CorruptEntityTypeException, and the two stay disjoint: an unstamped write is a caller bug, corrupt stored data is server state. Read-side parsing is unchanged. EntityType.fromName(null) still returns null and the wire field stays nullable, because the type is decided by the route and stamped before mapping. Test fixtures that persisted a legacy row through JPA now insert it by raw SQL. That is far wider than the four seedLegacyRow helpers: the shared TestTuple builder omits entityType, so every row derived from it was a null write. Raw seeding leaves the stored bytes identical, so no assertion changed; only the three lines in EntityTypeConverterTest that asserted the old pass-through behaviour were replaced, by parameterized equivalents plus a test pinning the rejection. Not HTTP-observable: a PUT that omits entityType over a legacy NULL occupant still returns 200 and migrates the column. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The adapter's javadoc said consuming every result completely was "the point", because a corrupt row found after the boundary would surface as a partial success. That overstates it. The frozen finders return Optional, Iterable and Page, all of which Hibernate hydrates during the query, so corruption already throws inside the translation; none returns a Stream. Materializing inside the boundary is defensive against a lazily-returning finder being added later, not a guard against something reachable today. The three tests making that claim drove the point home by constructing a hand-rolled iterable that fails mid-iteration, which real Spring Data does not hand back here. They are renamed to say they pin a hypothetical, and the fixture says why it exists. The translation itself is unaffected, and is what the class is for. Comments and test names only. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The type bundled four values that its only consumer unpacked one frame
later, and the guarantee claimed for it -- that an unpaged call cannot
carry paging and a paged one cannot omit it -- already comes from having
two overloads with different parameter lists.
It also overshot the comment it was implementing. That review asked to
parse the request once into a UserViewQuery "containing only the
accepted database ID, optional table pattern, and paging data", twice
calling it the smallest fix. One type, not two.
List<UserTableDto> getAllUserViews(UserViewQuery query);
Page<UserTableDto> getAllUserViews(UserViewQuery query, int page, int size, String sortBy);
That is also the shape of the table pair it sits beside. UserViewQuery
stays: its named factories are the only enumeration of the three
reachable query states.
The signature change alters no behaviour. Route defaults, the service's
tableId sort default and the HTTP contract are untouched. The
captor-based handler tests now capture the query and match the paging
arguments, asserting the same facts; a deliberate page/size swap and an
injected sort default were used to confirm they still fail.
Also states that views cannot be soft-deleted by design, so the missing
isSoftDelete parameter does not read as unimplemented. Two comments had
the causality backwards, offering the soft-deleted store's lack of a
discriminator as the reason for the decision. It is the consequence: the
column is absent because views are not soft-deleted, and it would be
added if that ever changed.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
UserTableReadRepository and its implementation were 333 lines, of which 31 did the work: catching DataAccessException, recovering the converter's CorruptEntityTypeException from the wrapper, and rethrowing everything else untouched. The rest was thirteen wrappers re-exposing finders the frozen repository already had, plus an interface to declare them, plus 640 lines of tests largely pinning that structure. The seam was also wrong rather than merely large. findTableRow and findRowForWrite returned JPA entities, so the boundary did not contain what it claimed to, and findRowForWrite served the write path from a class named read repository. The translation is now a static utility, unchanged line for line including the depth bound and the identity cycle guard. The service calls the frozen repository directly and wraps each call, and DTO mapping returns to the shapes it had before this branch touched it, so the table reads end up closer to their original form. The eager materialize helpers are dropped rather than relocated. Every frozen finder returns Optional, Iterable or Page, all hydrated during the query, so corruption already surfaces inside the translation; they guarded a Stream-returning finder that does not exist. Behaviour is unmoved, checked by mutation rather than assertion: suppressing the unwrap fails 16 tests, and wrapping an unrelated DataAccessException instead of rethrowing it fails 4, including the byte-identical generic body and the neutral read refusing to report a failure as absence. Cycle and depth coverage is restored; it had been lost when the earlier cause finder was folded into the adapter. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The read collapse left translation as a discipline requirement: 16 call sites each wrapped their repository read in translating(...), and a read added later without the wrap would return a generic 500 with no diagnostic and still compile. Sixteen wraps against eight reads is not obviously complete from inspection, and findViewRows hid four reads inside a helper the caller wrapped -- exactly the shape that hides a gap. An @around aspect on every UserTableHtsJdbcRepository method makes it structural: a corrupt discriminator surfaces with its column-and-value diagnostic whoever calls the repository, and a new read needs nothing at its call site. The interception point was verified empirically rather than reasoned about, because it can fail silently. Spring Data builds its own proxy holding PersistenceExceptionTranslationInterceptor and TransactionInterceptor; this advice lands in a second, outer proxy, so it sees the translated DataAccessException, not the converter's raw PersistenceException, and sits outside the repository transaction exactly where the call-site wraps did. That nesting is structural: no @order value can move the advice inside Spring Data's chain, so none is declared. A negative control -- disabling the aspect with the 16 wraps removed -- failed 12 tests, proving the coverage is real. RepositoryTranslationAspectTest pins both halves: that the advice runs outside PETI (so a Spring Data upgrade relocating it fails loudly rather than downgrading every corrupt read to a generic 500), and that a corrupt row carries the diagnostic with no call-site wrap anywhere. Advising writes is harmless: translating rethrows every non-corruption DataAccessException as the same instance, so the 409 conflict mapping is unchanged, verified across the cross-type PUT and occupied-rename tests. CorruptEntityTypeTranslation and its unit tests are unchanged. Verified on real MySQL 8.4: the deployment e2e suite passes 23/0, the corrupt-row split among them. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Eight tests, all added by this branch, removed after confirming their coverage survives elsewhere -- verified by mutation, not by inspection: - UserHouseTablesControllerApiContractTest (3): reflected over the @ApiResponses annotations; UserHouseTablesOpenApiContractTest pins the same matrix from the document springdoc actually emits, which is strictly stronger. A 404->410 mutation failed both. - testLegacyRowPlantedThroughTheColumnStillReadsAsTable: a strict subset of the base testStoredNullHydratesAsTable, which now seeds through the same raw SQL. - testNeutralReadIsThePathThatSurfacesCorruption: a strict subset of testGetNeutralEntityAtCorruptKeyFailsLoudly. - testViewDeleteTranslationDoesNotTurnAbsenceIntoAFailure: absence-is- false is pinned by six survivors; its name referenced a wrapper deleted earlier. - the HtsControllerTest copy of testViewPatternQueryKeepsUnderscoreAsA- SqlWildcard: underscore-as-wildcard is a SQL property; making the wildcard literal failed the surviving service test on its own, so the HTTP copy caught nothing distinct. - testTheScopedAdviceContributesNoResponsesToAnyOperation: named a scoped advice deleted three commits ago, and cannot fail for the cause it claims. Removing all 19 @hidden from OpenHouseExceptionHandler and regenerating leaves the document byte-identical, because the handlers carry no @ResponseStatus for springdoc to attribute. It was also a strict subset of testGeneratedDocumentDeclaresExactlyTheFrozenResponse- Codes. The @hidden annotations are left in place: harmless, matching the base, and defensive if a handler ever gains a @ResponseStatus. Two rationale javadocs were moved verbatim onto the survivors so no explanation was lost. No assertion was added, changed or weakened -- the seven added lines are all comments. No base test or production code touched. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The deployed collation for user_table_row.entity_type is confirmed utf8mb4_0900_ai_ci -- accent-insensitive and NO PAD. This was the open question flagged across both PR stacks; the repository comment hedged it as unconfirmed and assumed the opposite (a collation folding neither accents nor trailing spaces). No code change. The behaviour is already correct for it: a stored 'TÁBLE' matches the type predicate but fails hydration as a diagnostic 500, and a 'TABLE ' does not match at all. Both require a direct database write, since the API writes only canonical enum names, so neither is a reachable state. It also settles the EntityType.fromName trailing-space question -- the no-trim behaviour is correct under NO PAD, which is why it was reverted earlier. Comment only. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
GET /hts/views/query is dropped. Views can be listed only through the paginated /v1/hts/views/query. This is a deliberate divergence from linkedin#697, whose view query mirrored the table shape. The non-paginated table query /hts/tables/query is legacy: it still backs the tables service's /v0 and /v1 list-all, and its empty-filter form returns database names rather than tables. Views are new and have no caller -- a trace confirmed neither the tables service nor the internal catalog references any view query path -- so mirroring that shape would inherit tech debt into a greenfield surface. Paginated-only also removes the unbounded-query concern for views entirely. Removed: the getUserViews route and its endpoint constant, the non-paged handler and service methods, the two Iterable-returning view finders, and the two now-unused HTS_LIST_VIEWS_* metrics. The paginated path, the point reads, the neutral read, every table path including the non-paginated one, and all shared JPQL constants are untouched. Tests that held assertions with no paged equivalent were retargeted to the paged path rather than deleted, so no coverage was lost. The generated client now exposes five new operations rather than six; getUserViews is absent. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
0063cbb to
d900980
Compare
| * silent, never override it. | ||
| */ | ||
| @Component | ||
| public class EntityTypeIngressValidator { |
There was a problem hiding this comment.
Ingress also means API gateway layer. In which layer this validator is invoked?
There was a problem hiding this comment.
this should be the ingress for HouseTables service, aka HTS
|
|
||
| @Autowired private UserTablesMapper userTablesMapper; | ||
|
|
||
| @Autowired private EntityTypeIngressValidator entityTypeIngressValidator; |
There was a problem hiding this comment.
As this used at controller layer, can just keep the validator name as just EntityTypeValidator?
There was a problem hiding this comment.
yeah i also think it's a bit confusing which file's for which service, I'll look into a better name for this
Review feedback: "Ingress" reads as the API-gateway layer, and the name did not say which service the validator belongs to. It runs at the HTS controller layer, so name it for that service (matching the module's existing OpenHouseUserTableHtsApiValidator convention) and reword the class doc to say where it is invoked. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
| * on a corrupt discriminator, and Spring's persistence exception translation buries that inside a | ||
| * {@link DataAccessException} whose message is the wrapper's rather than the diagnostic. | ||
| */ | ||
| public final class CorruptEntityTypeTranslation { |
There was a problem hiding this comment.
Seems like translation is happing as part of JPA layer exception. Can we make this code generic so that this can be applied to other exception as well if needed?
There was a problem hiding this comment.
Kept it specific on purpose. There's exactly one exception that needs unwrapping today (CorruptEntityTypeException), and this util is actually what a much larger read-repository abstraction earlier in this PR got collapsed down to. The reusable part — the cause walk in findCorruptEntityTypeCause — is already a generic algorithm; only the target type is fixed.
What I'd rather not generalize yet is the policy in translating() (unwrap corruption, rethrow everything else as the same instance): a second exception would likely want a different policy — its own HTTP status, maybe no unwrap at all — which is better designed against a real second case than guessed now. If one shows up, generalizing the finder to findCause(Throwable, Class<T>) is a ~2-line change.
| public final class CorruptEntityTypeTranslation { | ||
|
|
||
| /** Bounds the cause walk, so a cyclic chain terminates instead of spinning. */ | ||
| private static final int CAUSE_CHAIN_MAX_DEPTH = 20; |
There was a problem hiding this comment.
How did arrive at this max depth? Should we increase the depth so that it covers important stacktrace?
There was a problem hiding this comment.
Good catch that this reads ambiguously. CAUSE_CHAIN_MAX_DEPTH bounds the cause chain (getCause() hops between nested exceptions), not stack-trace frames — so it has no effect on how much of the stack trace is captured (that's preserved via exception chaining regardless).
It isn't really load-bearing for termination either: the identity visited-set already guarantees we stop (finitely many distinct causes), so the cap is just a secondary bound on a pathologically long acyclic chain. The real corruption cause sits ~2-3 hops down (DataAccessException → optional JPA wrapper → CorruptEntityTypeException), so 20 is already generous — no need to increase. Reworded the comment in 25a5b31 to make the cause-chain-vs-stack-trace distinction explicit.
… trace Review question read CAUSE_CHAIN_MAX_DEPTH as limiting stack-trace capture. It bounds the getCause() chain (nested exceptions), which is unrelated to stack-trace frames; reword the comment to say so and to note the identity visited-set is what guarantees termination. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
| */ | ||
| @Aspect | ||
| @Component | ||
| public class UserTableRepositoryTranslationAspect { |
There was a problem hiding this comment.
Why do we need aspect for translation? Trying to understand the context behind this.
There was a problem hiding this comment.
This is addressing two #697 blocking comments that got deferred there: converter throws on corruption during hydration but nothing translates it before it leaves housetables, and common advice having to import JpaSystemException etc. The ask was to translate at the housetables persistence boundary. The aspect is that boundary. Shared advice (what #697 does) makes common import ORM types, and a per-call-site try/catch is fragile since hydration is lazy (a findAll might not throw til it's iterated, outside the try). The aspect wraps every repo method instead, so every read incl future ones gets translated at the edge and common stays ORM-free.
| */ | ||
| @EqualsAndHashCode | ||
| @ToString | ||
| public final class UserViewQuery { |
There was a problem hiding this comment.
Why do we need a separate class for mapping/validating query params specifically for tableId and databaseId?
There was a problem hiding this comment.
This is the fix Mike asked for on #697. He named UserViewQuery in these two comments. Point is to keep the transport UserTable (nullable everything + an inert entityType) out of the service's query contract; UserViewQuery only lets the 3 real states exist and rejects pattern-without-db at construction. #697 deferred it to stay consistent with the base table-query methods. Here views are a new path so we took the principled route and left tables alone.
| } | ||
|
|
||
| // Overwritten before mapping, so no transport spelling reaches the enum boundary. | ||
| UserTable ownedEntity = userTable.toBuilder().entityType(entityType.name()).build(); |
There was a problem hiding this comment.
| UserTable ownedEntity = userTable.toBuilder().entityType(entityType.name()).build(); | |
| UserTable updatedEntity = userTable.toBuilder().entityType(entityType.name()).build(); |
There was a problem hiding this comment.
Went with stampedEntity instead of updatedEntity: this method is an upsert so "updated" reads a bit ambiguous, and "stamped" matches the existing stamp-the-type language here (the ingress validator + STAMP_TABLE_TYPE). Renamed in 5eb646f.
| * null write so that the legacy population the {@code IS NULL} predicate arm carries cannot grow. | ||
| */ | ||
| @Component | ||
| public class UserTableRawSeeder { |
There was a problem hiding this comment.
Where this is used? Why JPA can't handle this usecase. Also why do we need this as well the new create will have either entity type as table or view.
There was a problem hiding this comment.
This is only used for testing (the seedLegacyRow split Mike asked for on #697). It plants a legacy NULL entity_type row the app can't create anymore since null writes are rejected, so tests can verify those rows still read back as TABLE.
| /** Removes every row regardless of discriminator, so a corrupt row cannot survive a teardown. */ | ||
| @Transactional | ||
| public void clear() { | ||
| entityManager.createQuery("DELETE FROM UserTableRow").executeUpdate(); |
There was a problem hiding this comment.
Why do we need this? There is no filter condition.
There was a problem hiding this comment.
This is only used for testing. It clears the whole store between tests, which the repo no longer exposes now that deleteAll() is sealed.
Review suggested updatedEntity; stampedEntity reads clearer in an upsert method and matches the existing stamp-the-type vocabulary in this module (the ingress validator and STAMP_TABLE_TYPE). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
| value = { | ||
| @ApiResponse(responseCode = "204", description = "User View DELETE: NO_CONTENT"), | ||
| @ApiResponse(responseCode = "400", description = "User View DELETE: BAD_REQUEST"), | ||
| @ApiResponse(responseCode = "404", description = "User View DELETE: TBL_DB_NOT_FOUND") |
There was a problem hiding this comment.
Do we need to have 500 for corrupt entity here?
There was a problem hiding this comment.
no need, we aren't checking for the specific entitytype here, only doing a query of "delete ... where ... entity_type = VIEW", so corrupt entities are ignored and a 404 will return
|
|
||
| @Around( | ||
| "execution(* com.linkedin.openhouse.housetables.repository.impl.jdbc.UserTableHtsJdbcRepository.*(..))") | ||
| public Object translateCorruption(ProceedingJoinPoint joinPoint) { |
There was a problem hiding this comment.
Did we verify if the aspect actually works?
There was a problem hiding this comment.
This is critical because the whole corruption→500 contract hinges on the aspect and this can silently fail as well.
There was a problem hiding this comment.
Yeah, RepositoryTranslationAspectTest covers exactly this. testAspectRunsOutsidePersistenceExceptionTranslationSoItSeesTheTranslatedFailure checks it two ways: it walks the real repo proxy chain and asserts the aspect sits in an outer proxy than PersistenceExceptionTranslationInterceptor, then plants a real UNKNOWN row via raw JDBC and asserts the real read comes back as an unwrapped CorruptEntityTypeException. testCorruptRowStillCarriesTheDiagnosticWithNoCallSiteWrapAnywhere does the same through the service with no call-site wrap, and testCorruptDiscriminatorResponseCarriesColumnDiagnostic in HtsControllerTest drives it over real HTTP (GET /hts/entities on a real corrupt row, 500 + diagnostic).
On the silent-fail worry: that structural assertion is the guard. If a Spring Data upgrade moved the translator so the aspect no longer sat outside it, that test fails loudly instead of every corrupt read quietly degrading to a generic 500.
abhisheknath2011
left a comment
There was a problem hiding this comment.
Thanks @ruolin59 for addressing the comments!
What this is
An alternative implementation of BDP-108627, the same ticket as #697. It is meant to be compared against that PR, not stacked on it. Both branch from
rufan/views-entity-type-discriminator.The premise: keep #697's HTTP contract and its SQL layer as they are, and rebuild the part between them — the CRUD wiring and error handling. One deliberate exception, called out below: views are paginated-only, so the non-paginated
GET /hts/views/querythat #697 mirrored from tables is dropped.What is deliberately unchanged
The HTTP surface.
GET /hts/entitiesand every pre-existing table route keep their paths, verbs, parameters, defaults, bodies and status codes. The view routes match #697's with one exception: there is no non-paginatedGET /hts/views/query(see the next section).The SQL layer. Every JPQL constant (
COMMON_FILTER_CLAUSES,PATTERN_KEY_CLAUSES,TABLE_ROW_PREDICATE,VIEW_ROW_PREDICATE,STAMP_TABLE_TYPE), every query andcountQuery, the typed conditional deletes returning an affected-row count, and the type-scoped rename are carried over verbatim.TABLE_ROW_PREDICATEkeeps itsIS NULLarm, so legacy rows still read as tables.JDBC repository surface (
UserTableHtsJdbcRepository)An accounting of the MySQL-backed repository methods, grouped by the entity type each targets. "New" and "reshaped" are relative to the base branch
rufan/views-entity-type-discriminator, which already carried the table-typed reads (the frozen tables path) but only neutral writes.View-scoped — every method is new in this PR (the repository had no view methods before):
findViewByDatabaseIdIgnoreCaseAndTableIdIgnoreCase(db, table)Optional<UserTableRow>VIEW_ROW_PREDICATEfindAllViewsByFilters(db, table, version, metadataLocation, storageType, creationTime, Pageable)Page<UserTableRow>findAllViewsByDatabaseIdAndTableIdLikeAllIgnoreCase(db, pattern, Pageable)Page<UserTableRow>deleteViewByDatabaseIdIgnoreCaseAndTableIdIgnoreCase(db, table)intVIEW_ROW_PREDICATE, affected-row countdeleteViewById(key)intPlus the constants
VIEWandVIEW_ROW_PREDICATE. There is deliberately no non-paginated view finder, nofindViewByIddefault, and no view rename: views are paginated-only and are not renameable.Table-scoped:
findTableByDatabaseIdIgnoreCaseAndTableIdIgnoreCase → Optional,findAllTablesByDatabaseIdAndTableIdLikeAllIgnoreCase(IterableandPage),findAllTablesByFilters(IterableandPage) — inherited from base, unchanged.deleteTableByDatabaseIdIgnoreCaseAndTableIdIgnoreCase → intanddeleteTableById(key) → int— new: replace the base's single neutralvoid delete…, now scoped byTABLE_ROW_PREDICATEand returning an affected-row count so the service maps missing and wrong-type alike to 404.renameTableId(…) → int— reshaped: the base's neutralvoid renameTableIdgainsTABLE_ROW_PREDICATEscoping andSTAMP_TABLE_TYPE(rewriting a legacy null or non-canonical spelling as the row moves), and returns a row count. Table-only because views are not renameable.Both (type-neutral):
findByDatabaseIdIgnoreCaseAndTableIdIgnoreCase → OptionalandexistsByDatabaseIdIgnoreCaseAndTableIdIgnoreCase → boolean— inherited; the writers read through these so they can see a row of any type and detect a collision at a shared key, which is why they stay unfiltered.findAllByDatabaseIdAndTableIdLikeAllIgnoreCase(IterableandPage),findAllByFilters(IterableandPage),findAllDistinctDatabaseIds()and its paginated form — inherited.findById/existsByIddefaults — inherited.deleteById,delete,deleteAllById,deleteAll(Iterable),deleteAll()— sealed in this PR to throwUnsupportedOperationException, forcing callers onto the typed deletes; the base's neutraldeleteBy…derived delete is removed.What is rebuilt, and why
A view write cannot persist a table.
putUserTableandputUserVieweach supply their ownEntityTypeto one private persistence primitive, so the named method — not the payload — is the authority. In #697,putViewdelegates toputUserTableand relies on the controller having stamped the type; a direct Java caller could persist the wrong one."Not found" cannot be manufactured from a failure. The new point reads return
Optional, and only the handler turns empty into a 404. A dependency or hydration failure travels as an exception and surfaces as a 500. This matters most for the neutral read, where the ticket calls out "the name is free" as the dangerous default.The service owns its view-query vocabulary. View queries cross the boundary as an owned
UserViewQuery, built once in the handler after validation, so the transportUserTable's nullability and its inertentityTypestop at the handler. The invalid "table pattern without a database" state cannot be constructed.Corruption is translated at the repository boundary, structurally. An
@Aroundaspect (UserTableRepositoryTranslationAspect) wraps everyUserTableHtsJdbcRepositorymethod: when a corrupt discriminator surfaces during hydration it is rethrown as an unwrappedCorruptEntityTypeException, and every otherDataAccessExceptionis rethrown exactly as it arrived, so non-corrupt failures behave as before. Because it is an aspect over the whole repository rather than a wrap at each call site, a read added later is covered without anyone remembering to wrap it. The interception point is load-bearing and can fail silently — Spring Data's own proxy holdsPersistenceExceptionTranslationInterceptor, and the aspect must land in the outer proxy to see the already-translatedDataAccessException— so a structural test pins it (RepositoryTranslationAspectTest) and a Spring Data upgrade that relocated the interceptor fails loudly rather than downgrading every corrupt read to a generic 500.Because corruption is unwrapped before it leaves the repository, the shared advice gains one handler and no ORM vocabulary. #697 instead teaches
services/commonto import and unwrapJpaSystemExceptionandInvalidDataAccessApiUsageException.CorruptEntityTypeExceptiondoes not extendIllegalArgumentException, so stored-data corruption stays distinguishable from client-input error.Views are paginated-only. #697 gave views a non-paginated
GET /hts/views/querymirroringGET /hts/tables/query. This PR drops it: views can be listed only through the paginatedGET /v1/hts/views/query. The non-paginated table query is legacy — it still backs the tables service's/v0and/v1list-all, and its empty-filter form returns database names rather than tables — and a trace confirmed neither the tables service nor the internal catalog references any view query path. Mirroring that shape would inherit tech debt into a greenfield surface with no caller, so on this new path we took the more principled option, which also removes the unbounded-query concern for views entirely. Tables are untouched:GET /hts/tables/querystays, because it has live callers.Smaller ones. PUT ingress validation is an injectable, unit-testable component that throws
RequestValidationFailureExceptiondirectly, rather than a private static helper in the controller. No-argdeleteAll()is sealed alongside the four key-addressed deletes, with teardown moved to a test fixture. View metrics are owned by housetables; the pre-existingHTS_/REPO_constants stay inservices/commonbecauseservices/tablesconsumes 19 of them.Footprint on shared code
ServiceAuditAspectand every other file inservices/commonare byte-identical to base. ORM imports added toservices/common: 0 (#697 adds 3). The translation mechanism itself — a smallCorruptEntityTypeTranslationutility and the aspect that applies it — lives inservices/housetables, the module that already owns JPA.Deliberate asymmetries
Two remain, both in signatures rather than behaviour:
getUserTablesignals absence by exception while the new point reads returnOptional, and table queries take the transportUserTablewhile view queries take an owned type.Both were measured rather than assumed. Unifying the absence convention costs 16 pre-existing test edits and risks a silent wire change — the current 404 carries
cause: nullbecause it wraps a message-lessNoSuchElementException, and no test pins that field. Unifying the query vocabulary means rewriting 11 private query helpers and 3 branch predicates that encode the frozen empty-filter behaviour. Both are worth doing on their own terms, with their own review; neither has view-specific content.Not adopted
.stacktrace(...)sites from the shared handler. These do not leak:ServiceAuditAspectalready strips the stack trace from the client body and keeps it only in the internal audit event, so deleting the sites would degrade the audit trail without changing any client response. The populate-then-strip indirection is real, but it is aservices/commoncontract shared by tables, jobs and housetables — a whole-platform refactor, not a views concern. The views-specific error-handling improvement Mike's review asked for was instead made through the corruption path (CorruptEntityTypeException+ the repository aspect), which gives a corrupt read a proper diagnostic 500 rather than raw ORM vocabulary.The production collation, an open question across both PR stacks, is now confirmed
utf8mb4_0900_ai_ci— accent-insensitive and NO PAD. This needs no code change: it means a corrupt'TÁBLE'matches the type predicate and fails hydration with a diagnostic 500, and a corrupt'TABLE 'is invisible to the typed routes. Both require a direct database write, since the API writes only canonical enum names, so neither is a reachable state.Testing
services:housetablesservices:commonservices:tablesservices:jobstables(526) andjobs(52) match their base counts after this branch was rebased onto the current base.:client:htsregenerates cleanly with five new operations —getEntity,getUserView,getPaginatedUserViews,putUserView,deleteView(no non-paginatedgetUserViews). The generated OpenAPI response sets match #697's for every route that still exists, including a documented500on the three GET routes that can reach a corrupt row (/hts/entities,/hts/views,/v1/hts/views/query).The change was also driven through #698's deployment e2e suite against a real MySQL 8.4 container: 23 passed, 0 skipped. That suite was written against #697's API; the only edit needed was to route its view-listing through the paginated endpoint, since the non-paginated one is intentionally gone — every other assertion passed unchanged, which is independent evidence the rest of the contract is indistinguishable to a client. The confirmed production collation (
utf8mb4_0900_ai_ci) is exercised by that run.Pre-existing tests were changed only where the frozen contract or an adopted decision forced it, and each was strengthened rather than weakened; the three teardown edits come from sealing
deleteAll(). No assertion was deleted or relaxed.A note on a pre-existing flake
UserTablesServiceTest.testUserTablePurgefailed twice under concurrent multi-module load and passed 6/6 in isolation.SoftDeletedUserTablesMapperderivesdeletedAtMsfromInstant.now(), and that field is part of the soft-deleted composite key; the test soft-deletes the same table twice and expects two rows, so two calls in the same millisecond upsert into one. The mapper, the key and that test are untouched here, and #697 carries identical code.Review process
Design-first: an implementation plan reviewed independently over two rounds, tests written before the production logic and reviewed against it, then an implementation review, followed by several rounds of simplification that removed roughly 1,400 lines of machinery in favour of the smallest thing that holds the contract. Load-bearing mechanics were verified empirically rather than assumed — most importantly the aspect's interception point, proven against Spring 5.3.25 by dumping the nested proxy chains: the aspect sits in a separate outer proxy from Spring Data's exception-translation interceptor, so no
@Ordervalue could move it, and a negative control (aspect disabled) confirmed the corrupt-row tests genuinely depend on it.