mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
chore(saas): make schema ownership explicit and enforce it (#7489)
## The problem The SaaS database has two writers and always has: the Supabase migrations in the SaaS repo, and Hibernate's `ddl-auto`. That was a convention rather than a rule, and it leaked twice. - An older `ddl-auto` run widened `team_memberships.role` to varchar(255), which needed [a dedicated migration](https://github.com/Stirling-Tools/Stirling-PDF-SaaS/blob/v3/supabase/migrations/20260804000000_fix_team_memberships_role_varchar50.sql) to repair, because RLS policies depended on the column. - `payg_instance_usage` shipped with an entity and **no migration**, and nobody noticed for months — staging already had the table from an earlier `ddl-auto` run. It surfaced only when a fresh preview branch, built from migrations alone, threw `relation does not exist`. Both are the same bug: nobody had to *say* who owned a table, so the answer got decided by accident. ## The fix `SaasSchemaOwnership` is the register — **29 migration-owned, 29 inherited** and left to Hibernate. `MigrationOwnedSchemaFilter` applies it via Hibernate's `hbm2ddl.schema_filter_provider`, wired on the **saas profile only**. Hibernate is never shown a migration-owned table, so it cannot create, alter, drop or truncate one whatever `ddl-auto` is set to. Inherited tables stay managed, so a fresh preview branch still heals itself on first boot. Self-hosted is untouched — there Hibernate rightly owns everything. **Why a filter rather than just `ddl-auto=none`:** off, and a fresh branch is missing the 29 inherited tables. On, and Hibernate can reach the other 29. The filter is what lets both be true at once. **Why per-table, not per-schema:** Hibernate's schema management runs over every mapped entity regardless of namespace. Moving SaaS tables to their own schema would *not* by itself keep Hibernate out of them — worth knowing, because that was the intuitive fix and it doesn't work. ## The part that makes it stick `SaasSchemaOwnershipTest` makes the register binding: every `@Entity` on the SaaS classpath must appear in exactly one set, so **a new entity fails the build until someone states who owns its table**. That's the forcing function that would have caught `payg_instance_usage`. I verified it bites rather than assuming it — removing a single entry fails with: ``` These entity tables are not declared in SaasSchemaOwnership, so nobody owns them. Offending tables -> entities: [policies (stirling.software.proprietary.policy.store.PolicyEntity)] ``` naming both the table and the class, which is what the next person actually needs. ## One debatable call The **validate** filter excludes them too. Letting validation through would flag drift, which is genuinely useful — but `ddl-auto=validate` fails startup, and it would fail on differences we've deliberately accepted (`ai_create_sessions` carries columns from a reverted Typst feature that nothing maps). A boot failure over a table we chose not to manage is noise. Argued in the javadoc; happy to flip it if you'd rather have the signal. ## Dependency Depends on [Stirling-PDF-SaaS#324](https://github.com/Stirling-Tools/Stirling-PDF-SaaS/pull/324), which adds migrations for the four SaaS-owned tables that had none. They're listed here as migration-owned on that basis, so #324 should land first. Companion to [#7483](https://github.com/Stirling-Tools/Stirling-PDF/pull/7483) (dev/staging profiles with per-profile `ddl-auto`). ## Verification `:saas:test` green including the 5 new tests, `spotlessCheck` green, and the mutation check above.
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
package stirling.software.saas.config;
|
||||
|
||||
import org.hibernate.boot.model.relational.Namespace;
|
||||
import org.hibernate.boot.model.relational.Sequence;
|
||||
import org.hibernate.mapping.Table;
|
||||
import org.hibernate.tool.schema.spi.SchemaFilter;
|
||||
import org.hibernate.tool.schema.spi.SchemaFilterProvider;
|
||||
|
||||
/**
|
||||
* Hides the migration-owned tables from Hibernate's schema management.
|
||||
*
|
||||
* <p>Wired on the SaaS profile only, via {@code hibernate.hbm2ddl.schema_filter_provider}.
|
||||
* Self-hosted is untouched: there Hibernate rightly owns everything.
|
||||
*
|
||||
* <p>Why a filter rather than simply turning {@code ddl-auto} off: the SaaS database has two
|
||||
* writers. The Supabase migrations own the SaaS tables, and Hibernate owns roughly thirty tables
|
||||
* inherited from the self-hosted app that no migration has ever created. Turn {@code ddl-auto} off
|
||||
* and a fresh preview branch is missing that second half; leave it on and Hibernate is free to
|
||||
* reconcile migration-owned tables, which is how {@code team_memberships.role} ended up widened to
|
||||
* varchar(255) and needed a migration to put back. A filter keeps the first half working and makes
|
||||
* the second impossible.
|
||||
*
|
||||
* <p>Note that this is a per-table filter, not a per-schema one. Hibernate's schema management runs
|
||||
* over every mapped entity regardless of namespace, so moving SaaS tables to their own schema would
|
||||
* not by itself keep Hibernate out of them. {@link SaasSchemaOwnership} is the register; this class
|
||||
* only applies it.
|
||||
*
|
||||
* <p><b>Foreign keys still cross the line, on purpose.</b> Several inherited tables reference
|
||||
* migration-owned ones — {@code folders}, {@code stored_files} and {@code file_shares} all point at
|
||||
* {@code users}/{@code teams}. Hibernate's {@code SchemaCreatorImpl.createForeignKeys} and {@code
|
||||
* AbstractSchemaMigrator.applyForeignKeys} check {@code includeTable} against the *owning* table
|
||||
* only and then emit every foreign key on it, without consulting the referenced table. So excluding
|
||||
* {@code users} does not cost the branch its referential integrity, and a branch ends up matching
|
||||
* staging. It does mean the referenced tables have to exist by the time Hibernate runs, which holds
|
||||
* because a Supabase branch applies its migrations at build time and the app connects afterwards.
|
||||
*
|
||||
* <p><b>Known gap: this cannot detect drift.</b> Filtering means Hibernate never inspects these
|
||||
* tables, and {@link #getValidateFilter()} extends that to {@code validate}, so nothing here
|
||||
* compares a migration-owned table against its entity. Combined with the register being a
|
||||
* hand-maintained list of another repo's contents (see {@link SaasSchemaOwnership}), there is
|
||||
* currently no automated signal when the register, the entities and the database disagree. That is
|
||||
* a deliberate trade for a boot that does not fail on differences we accept, not a claim that drift
|
||||
* cannot happen; a non-fatal drift report is the missing piece and belongs outside this class.
|
||||
*/
|
||||
public class MigrationOwnedSchemaFilter implements SchemaFilterProvider, SchemaFilter {
|
||||
|
||||
/**
|
||||
* The one decision this class makes. Everything Hibernate might do to a table it does not own —
|
||||
* create, alter, drop, truncate — is refused.
|
||||
*/
|
||||
@Override
|
||||
public boolean includeTable(Table table) {
|
||||
return !SaasSchemaOwnership.isMigrationOwned(table.getName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Namespaces are never filtered. The inherited tables and the migration-owned ones share {@code
|
||||
* stirling_pdf}, so excluding the namespace would take both with it.
|
||||
*/
|
||||
@Override
|
||||
public boolean includeNamespace(Namespace namespace) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sequences are left alone. Every id here is an identity column rather than a mapped generator,
|
||||
* so there is nothing for Hibernate to create; filtering them would be dead code pretending to
|
||||
* be a safeguard.
|
||||
*/
|
||||
@Override
|
||||
public boolean includeSequence(Sequence sequence) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SchemaFilter getCreateFilter() {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SchemaFilter getMigrateFilter() {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SchemaFilter getDropFilter() {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SchemaFilter getTruncatorFilter() {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation is filtered too, which is the one debatable call here.
|
||||
*
|
||||
* <p>Letting it through would give a useful signal when a migration-owned table drifts from its
|
||||
* entity. But {@code ddl-auto=validate} fails startup, and it would fail on differences we have
|
||||
* deliberately accepted — {@code ai_create_sessions} carries columns from a reverted feature
|
||||
* that nothing maps, for instance. A boot failure over a table we have chosen not to manage is
|
||||
* noise, so the rule stays uniform: Hibernate does not concern itself with these tables at all.
|
||||
*/
|
||||
@Override
|
||||
public SchemaFilter getValidateFilter() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package stirling.software.saas.config;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Which side owns each table in the SaaS database.
|
||||
*
|
||||
* <p>The SaaS schema has two writers and always has: the Supabase migrations in the
|
||||
* Stirling-PDF-SaaS repo, and Hibernate's {@code ddl-auto}. That was a convention rather than a
|
||||
* rule, and it leaked twice. An older {@code ddl-auto} run widened {@code team_memberships.role} to
|
||||
* varchar(255), which needed a dedicated migration to repair because RLS policies depended on the
|
||||
* column. Separately {@code payg_instance_usage} went months with an entity and no migration, so it
|
||||
* simply did not exist on a fresh preview branch.
|
||||
*
|
||||
* <p>This class makes the boundary explicit and {@code SaasSchemaOwnershipTest} makes it binding:
|
||||
* every {@code @Entity} the SaaS app maps must appear in exactly one of these two sets. A new
|
||||
* entity fails the build until someone states who owns its table, which is the decision that was
|
||||
* previously made by accident.
|
||||
*
|
||||
* <p>{@link MigrationOwnedSchemaFilter} enforces it at runtime: Hibernate is never shown the
|
||||
* migration-owned tables, so it cannot create, alter or drop them whatever {@code ddl-auto} says.
|
||||
* Inherited tables stay under Hibernate, so a preview branch built from migrations alone still
|
||||
* heals itself on first boot.
|
||||
*
|
||||
* <p><b>What this does not catch.</b> The register is a hand-maintained copy of what lives in
|
||||
* another repository, and only one direction is enforced. The test fails when a *new* entity
|
||||
* appears with no owner. It cannot notice a table changing sides: write a migration for {@code
|
||||
* folders} in Stirling-PDF-SaaS and nothing here changes, the test still passes, and Hibernate
|
||||
* carries on managing a table the migrations now own — which is precisely how {@code
|
||||
* team_memberships.role} got widened. Adding a migration for anything in {@link #HIBERNATE_MANAGED}
|
||||
* therefore means moving it to {@link #MIGRATION_OWNED} in the same change; nothing will remind
|
||||
* you. Making that structural rather than remembered is what moving the SaaS tables into their own
|
||||
* schema would buy, and is the reason this class is a stepping stone rather than the answer.
|
||||
*/
|
||||
public final class SaasSchemaOwnership {
|
||||
|
||||
/**
|
||||
* Created and altered by the Supabase migrations. Hibernate must not touch these: the
|
||||
* migrations carry constraints, defaults and RLS policies it knows nothing about and would
|
||||
* reconcile away.
|
||||
*/
|
||||
public static final Set<String> MIGRATION_OWNED =
|
||||
Set.of(
|
||||
"ai_create_sessions",
|
||||
"audit_events",
|
||||
"authorities",
|
||||
"billing_subscriptions",
|
||||
"job_artifact_hash",
|
||||
"legal_consent",
|
||||
"linked_instance",
|
||||
"payg_instance_usage",
|
||||
"payg_meter_event_log",
|
||||
"payg_prepaid_bundle",
|
||||
"payg_shadow_charge",
|
||||
"payg_team_extensions",
|
||||
"persistent_logins",
|
||||
"pricing_policy",
|
||||
"processing_job",
|
||||
"processing_job_step",
|
||||
"procurement_agreement_signature",
|
||||
"procurement_deal",
|
||||
"procurement_quote",
|
||||
"saas_team_extensions",
|
||||
"saas_user_extensions",
|
||||
"sessions",
|
||||
"team_invitations",
|
||||
"team_memberships",
|
||||
"teams",
|
||||
"users",
|
||||
"wallet_entitlement_snapshot",
|
||||
"wallet_ledger",
|
||||
"wallet_policy");
|
||||
|
||||
/**
|
||||
* Inherited from the self-hosted app, where {@code ddl-auto} owns the schema and no Supabase
|
||||
* migration exists. Deliberately left under Hibernate so a fresh branch gets them on first
|
||||
* boot.
|
||||
*/
|
||||
public static final Set<String> HIBERNATE_MANAGED =
|
||||
Set.of(
|
||||
"account_link_device_credential",
|
||||
"account_link_metered_signature",
|
||||
"account_link_sync_state",
|
||||
"account_link_usage_counter",
|
||||
"api_key_daily_usage",
|
||||
"api_keys",
|
||||
"file_encryption_keys",
|
||||
"file_run_events",
|
||||
"file_share_accesses",
|
||||
"file_shares",
|
||||
"folders",
|
||||
"integration_configs",
|
||||
"invite_tokens",
|
||||
"jwt_signing_keys",
|
||||
"policies",
|
||||
"policy_assets",
|
||||
"policy_completed_migrations",
|
||||
"policy_processed_files",
|
||||
"policy_source_doc_counts",
|
||||
"policy_source_doc_totals",
|
||||
"policy_sources",
|
||||
"resource_grants",
|
||||
"storage_cleanup_entries",
|
||||
"stored_file_blobs",
|
||||
"stored_files",
|
||||
"user_license_settings",
|
||||
"user_server_certificates",
|
||||
"workflow_participants",
|
||||
"workflow_sessions");
|
||||
|
||||
private SaasSchemaOwnership() {}
|
||||
|
||||
/** Case-insensitive: Hibernate hands us whatever casing the mapping used. */
|
||||
public static boolean isMigrationOwned(String tableName) {
|
||||
return tableName != null && MIGRATION_OWNED.contains(tableName.toLowerCase());
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,13 @@ spring.jpa.properties.hibernate.hbm2ddl.create_namespaces=true
|
||||
|
||||
spring.jpa.hibernate.ddl-auto=update
|
||||
|
||||
# ...but only over the tables Hibernate actually owns. The SaaS database has two writers: the
|
||||
# Supabase migrations own the SaaS tables, Hibernate owns ~30 inherited from the self-hosted app that
|
||||
# no migration has ever created. This filter hides the former from schema management, so ddl-auto can
|
||||
# still heal a fresh preview branch without being free to reconcile a migration-owned table — which
|
||||
# is how team_memberships.role ended up widened to varchar(255). Register: SaasSchemaOwnership.
|
||||
spring.jpa.properties.hibernate.hbm2ddl.schema_filter_provider=stirling.software.saas.config.MigrationOwnedSchemaFilter
|
||||
|
||||
# ---------- Supabase JWT auth ----------
|
||||
# Required: set SAAS_DB_PROJECT_REF via env.
|
||||
app.supabase.project-ref=${SAAS_DB_PROJECT_REF:}
|
||||
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
package stirling.software.saas.config;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.Properties;
|
||||
|
||||
import org.hibernate.mapping.Table;
|
||||
import org.hibernate.tool.schema.spi.SchemaFilter;
|
||||
import org.hibernate.tool.schema.spi.SchemaFilterProvider;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Covers {@link MigrationOwnedSchemaFilter} and, just as importantly, its wiring.
|
||||
*
|
||||
* <p>{@link SaasSchemaOwnershipTest} proves the register is complete; nothing proved the filter
|
||||
* applies it, or that Hibernate is even asking. A typo in the {@code
|
||||
* hibernate.hbm2ddl.schema_filter_provider} key, a stale fully-qualified name after a package move,
|
||||
* or a getter returning null would all leave every migration-owned table exposed to {@code
|
||||
* ddl-auto} with a fully green build. Hence the property assertion below, which is the only thing
|
||||
* here that would catch that.
|
||||
*/
|
||||
class MigrationOwnedSchemaFilterTest {
|
||||
|
||||
private static final String FILTER_PROPERTY =
|
||||
"spring.jpa.properties.hibernate.hbm2ddl.schema_filter_provider";
|
||||
|
||||
private final MigrationOwnedSchemaFilter filter = new MigrationOwnedSchemaFilter();
|
||||
|
||||
/** "orm" is Hibernate's own default contributor; the value is irrelevant to the filter. */
|
||||
private static Table table(String name) {
|
||||
return new Table("orm", name);
|
||||
}
|
||||
|
||||
@Test
|
||||
void migrationOwnedTablesAreHiddenFromHibernate() {
|
||||
assertThat(filter.includeTable(table("teams"))).isFalse();
|
||||
assertThat(filter.includeTable(table("users"))).isFalse();
|
||||
assertThat(filter.includeTable(table("team_memberships"))).isFalse();
|
||||
assertThat(filter.includeTable(table("payg_instance_usage"))).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void inheritedTablesStayUnderHibernate() {
|
||||
assertThat(filter.includeTable(table("folders"))).isTrue();
|
||||
assertThat(filter.includeTable(table("stored_files"))).isTrue();
|
||||
assertThat(filter.includeTable(table("api_keys"))).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUnknownTableIsLeftToHibernate() {
|
||||
// Fail-open is the right default: an unrecognised table is either brand new or from a
|
||||
// module
|
||||
// we do not know about, and SaasSchemaOwnershipTest is what stops it staying unrecognised.
|
||||
assertThat(filter.includeTable(table("no_such_table"))).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void casingDoesNotDefeatTheFilter() {
|
||||
assertThat(filter.includeTable(table("TEAMS"))).isFalse();
|
||||
assertThat(filter.includeTable(table("Team_Memberships"))).isFalse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Foreign keys from an inherited table into a migration-owned one survive the filter.
|
||||
*
|
||||
* <p>Worth pinning, because it is not obvious and it decides whether a preview branch keeps
|
||||
* referential integrity. {@code folders}, {@code stored_files} and {@code file_shares} all
|
||||
* reference {@code users}/{@code teams}, which the filter excludes. Hibernate 7.2's {@code
|
||||
* SchemaCreatorImpl.createForeignKeys} (and {@code AbstractSchemaMigrator.applyForeignKeys})
|
||||
* tests {@code includeTable} against the *owning* table only, then emits every foreign key on
|
||||
* it; the referenced table is never consulted. So the constraints are still created and a
|
||||
* branch matches staging.
|
||||
*
|
||||
* <p>The one thing this depends on is ordering: the referenced tables have to exist first. They
|
||||
* do, because a Supabase branch runs its migrations at build time and the app connects after.
|
||||
*/
|
||||
@Test
|
||||
void foreignKeysIntoMigrationOwnedTablesAreStillEmitted() {
|
||||
assertThat(filter.includeTable(table("folders"))).isTrue();
|
||||
assertThat(filter.includeTable(table("file_shares"))).isTrue();
|
||||
assertThat(filter.includeTable(table("users"))).isFalse();
|
||||
assertThat(filter.includeTable(table("teams"))).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void everySchemaActionGetsTheSameFilter() {
|
||||
assertThat(filter.getCreateFilter()).isSameAs(filter);
|
||||
assertThat(filter.getMigrateFilter()).isSameAs(filter);
|
||||
assertThat(filter.getDropFilter()).isSameAs(filter);
|
||||
assertThat(filter.getTruncatorFilter()).isSameAs(filter);
|
||||
assertThat(filter.getValidateFilter()).isSameAs(filter);
|
||||
}
|
||||
|
||||
@Test
|
||||
void namespacesAndSequencesAreNeverFiltered() {
|
||||
// Both share the stirling_pdf namespace, so filtering it would take the inherited tables
|
||||
// with it. Neither argument is read, so nulls are fine and keep the test free of Hibernate
|
||||
// bootstrap machinery.
|
||||
assertThat(filter.includeNamespace(null)).isTrue();
|
||||
assertThat(filter.includeSequence(null)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void theFilterIsActuallyWiredIntoHibernate() throws Exception {
|
||||
Properties properties = new Properties();
|
||||
try (InputStream in = getClass().getResourceAsStream("/application-saas.properties")) {
|
||||
assertThat(in)
|
||||
.as("application-saas.properties must be on the test classpath to check wiring")
|
||||
.isNotNull();
|
||||
properties.load(in);
|
||||
}
|
||||
|
||||
String configured = properties.getProperty(FILTER_PROPERTY);
|
||||
assertThat(configured)
|
||||
.as(
|
||||
"%s is unset, so Hibernate installs its default filter and every"
|
||||
+ " migration-owned table is back under ddl-auto",
|
||||
FILTER_PROPERTY)
|
||||
.isNotBlank();
|
||||
|
||||
Class<?> wired = Class.forName(configured.trim());
|
||||
assertThat(SchemaFilterProvider.class)
|
||||
.as("Hibernate only accepts a SchemaFilterProvider here")
|
||||
.isAssignableFrom(wired);
|
||||
assertThat(SchemaFilter.class).isAssignableFrom(wired);
|
||||
assertThat(wired).isEqualTo(MigrationOwnedSchemaFilter.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package stirling.software.saas.config;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.TreeSet;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.config.BeanDefinition;
|
||||
import org.springframework.boot.persistence.autoconfigure.EntityScan;
|
||||
import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider;
|
||||
import org.springframework.core.type.filter.AnnotationTypeFilter;
|
||||
import org.springframework.util.ClassUtils;
|
||||
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import stirling.software.proprietary.security.configuration.DatabaseConfig;
|
||||
|
||||
/**
|
||||
* Makes {@link SaasSchemaOwnership} binding rather than decorative.
|
||||
*
|
||||
* <p>Every {@code @Entity} the SaaS app maps has to be declared as owned by either the Supabase
|
||||
* migrations or Hibernate. Adding an entity without saying which fails here, at build time, instead
|
||||
* of months later on a preview branch that has no such table. That is not hypothetical: {@code
|
||||
* payg_instance_usage} shipped with an entity and no migration and went unnoticed until a branch
|
||||
* tried to use it.
|
||||
*
|
||||
* <p>"Maps" is meant precisely: the scan covers the packages named by the {@code @EntityScan}
|
||||
* declarations the app actually boots with, not everything under {@code stirling.software}. See
|
||||
* {@link #mappedPackages()}. Note this only enforces one direction — {@link SaasSchemaOwnership}
|
||||
* documents the drift it cannot see.
|
||||
*/
|
||||
class SaasSchemaOwnershipTest {
|
||||
|
||||
/**
|
||||
* The packages the running app actually maps, read off the two {@code @EntityScan} declarations
|
||||
* that define them rather than hardcoded.
|
||||
*
|
||||
* <p>Scanning all of {@code stirling.software} would be easier and wrong in a quiet way: it is
|
||||
* a superset, so it would force ownership declarations for entities Hibernate never sees and
|
||||
* let the register claim tables that do not exist as far as the SaaS app is concerned. Deriving
|
||||
* the list means this test measures the same set Hibernate does, and follows a package being
|
||||
* added or moved without anyone updating it here.
|
||||
*/
|
||||
private static Set<String> mappedPackages() {
|
||||
Set<String> packages = new TreeSet<>();
|
||||
for (Class<?> config : List.of(SaasJpaConfig.class, DatabaseConfig.class)) {
|
||||
EntityScan scan = config.getAnnotation(EntityScan.class);
|
||||
assertThat(scan)
|
||||
.as("%s must carry @EntityScan, or its entities are not mapped", config)
|
||||
.isNotNull();
|
||||
packages.addAll(Arrays.asList(scan.value()));
|
||||
}
|
||||
return packages;
|
||||
}
|
||||
|
||||
private static TreeMap<String, String> mappedTables() {
|
||||
ClassPathScanningCandidateComponentProvider scanner =
|
||||
new ClassPathScanningCandidateComponentProvider(false);
|
||||
scanner.addIncludeFilter(new AnnotationTypeFilter(Entity.class));
|
||||
TreeMap<String, String> byTable = new TreeMap<>();
|
||||
for (String basePackage : mappedPackages()) {
|
||||
for (BeanDefinition bd : scanner.findCandidateComponents(basePackage)) {
|
||||
String className = bd.getBeanClassName();
|
||||
Class<?> type;
|
||||
try {
|
||||
type =
|
||||
ClassUtils.forName(
|
||||
className, SaasSchemaOwnershipTest.class.getClassLoader());
|
||||
} catch (ClassNotFoundException | LinkageError e) {
|
||||
continue; // not on this module's runtime classpath; nothing to own
|
||||
}
|
||||
Table table = type.getAnnotation(Table.class);
|
||||
String name =
|
||||
table != null && !table.name().isBlank()
|
||||
? table.name()
|
||||
: camelToSnake(type.getSimpleName());
|
||||
byTable.put(name.toLowerCase(), className);
|
||||
}
|
||||
}
|
||||
return byTable;
|
||||
}
|
||||
|
||||
/** Mirrors Spring Boot's default CamelCaseToUnderscoresNamingStrategy for an unnamed @Table. */
|
||||
private static String camelToSnake(String name) {
|
||||
return name.replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase();
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyEntityTableIsOwnedByExactlyOneSide() {
|
||||
TreeMap<String, String> mapped = mappedTables();
|
||||
assertThat(mapped)
|
||||
.as("entity scan found nothing, so this test proves nothing")
|
||||
.isNotEmpty();
|
||||
// The scan is derived from @EntityScan now, so a package quietly dropped from either
|
||||
// declaration would shrink it and weaken this test rather than fail it. These four straddle
|
||||
// the two declarations, so losing either side fails here instead of silently checking less.
|
||||
assertThat(mapped.keySet())
|
||||
.as("both @EntityScan declarations must have contributed to the scan")
|
||||
.contains("users", "teams", "payg_instance_usage", "folders");
|
||||
|
||||
Set<String> undeclared = new TreeSet<>();
|
||||
Set<String> both = new TreeSet<>();
|
||||
for (String table : mapped.keySet()) {
|
||||
boolean migration = SaasSchemaOwnership.MIGRATION_OWNED.contains(table);
|
||||
boolean hibernate = SaasSchemaOwnership.HIBERNATE_MANAGED.contains(table);
|
||||
if (migration && hibernate) both.add(table);
|
||||
if (!migration && !hibernate) undeclared.add(table);
|
||||
}
|
||||
|
||||
assertThat(undeclared)
|
||||
.as(
|
||||
"""
|
||||
These entity tables are not declared in SaasSchemaOwnership, so nobody owns \
|
||||
them. Decide and add each to exactly one set:
|
||||
- MIGRATION_OWNED: also add a migration in Stirling-PDF-SaaS, or the table \
|
||||
will not exist on a fresh preview branch.
|
||||
- HIBERNATE_MANAGED: only correct for a table inherited from the \
|
||||
self-hosted app that no Supabase migration creates.
|
||||
Offending tables -> entities: %s"""
|
||||
.formatted(
|
||||
undeclared.stream()
|
||||
.map(t -> t + " (" + mapped.get(t) + ")")
|
||||
.toList()))
|
||||
.isEmpty();
|
||||
|
||||
assertThat(both)
|
||||
.as("declared as owned by both sides, which is the one thing it cannot be")
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void theTwoSetsDoNotOverlap() {
|
||||
Set<String> overlap = new TreeSet<>(SaasSchemaOwnership.MIGRATION_OWNED);
|
||||
overlap.retainAll(SaasSchemaOwnership.HIBERNATE_MANAGED);
|
||||
assertThat(overlap).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void tableNamesAreLowercaseSoLookupsCannotMiss() {
|
||||
// isMigrationOwned() lowercases its input; a capital in either set would be unreachable.
|
||||
assertThat(SaasSchemaOwnership.MIGRATION_OWNED)
|
||||
.allSatisfy(t -> assertThat(t).isEqualTo(t.toLowerCase()));
|
||||
assertThat(SaasSchemaOwnership.HIBERNATE_MANAGED)
|
||||
.allSatisfy(t -> assertThat(t).isEqualTo(t.toLowerCase()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void migrationOwnedTablesIncludeTheOnesThatBitUs() {
|
||||
// team_memberships is the table an old ddl-auto run widened; payg_instance_usage is the one
|
||||
// that had an entity and no migration. Both must be on the migrations' side of the line.
|
||||
assertThat(SaasSchemaOwnership.MIGRATION_OWNED)
|
||||
.contains("team_memberships", "payg_instance_usage", "teams", "users");
|
||||
}
|
||||
|
||||
@Test
|
||||
void isMigrationOwnedIsCaseInsensitiveAndNullSafe() {
|
||||
assertThat(SaasSchemaOwnership.isMigrationOwned("TEAM_MEMBERSHIPS")).isTrue();
|
||||
assertThat(SaasSchemaOwnership.isMigrationOwned("team_memberships")).isTrue();
|
||||
assertThat(SaasSchemaOwnership.isMigrationOwned(null)).isFalse();
|
||||
assertThat(SaasSchemaOwnership.isMigrationOwned("no_such_table")).isFalse();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user