diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e26966..427c40b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ All notable changes to this project are documented here. The format follows Keep - **The index (P2).** `object_storage_object` and `object_storage_folder` kept in step with each indexed bucket: resumable full scan with a mass-removal safety stop, HEAD backlog, write-through on every service write, soft-deleted rows, folder rollups. Orphaned and missing objects from `FieldReferenceProvider`, daily usage snapshots, quota and freshness alerts, the nightly job. - **The Storage app (P3, P4).** Admin -> Storage with a responsive home; record screens with sections, child lists and a thumbnail preview; actions Rescan bucket, Check configuration, Refresh from bucket, Apply header policy (replaces app-specific backfills), Change headers and metadata, Edit tags, Copy, Move or rename, Delete, Restore, Create download link, Create upload link, folder rename and delete, Upload files, Test connection; every one a standard Review and Result with a bulk cap. - `ObjectStorageSchema`: PostgreSQL DDL generated from the table metadata. +- Foreign keys on every relation between the qbit tables (`fk__`), generated by `ObjectStorageSchema`: owned observed state (capabilities, folders, objects, versions, usage) cascades from its bucket or object, `object.folder_id` and `object_reference.object_id` are set null, and a bucket's connection, header and protection rules and operation log restrict. Each key is added by an idempotent block that first records rows whose parent is gone in `qqq_fk_quarantine` and clears (nullable column) or deletes (required column) them, so it also upgrades an existing schema. New indexes `ix_object_storage_bucket_1` (connection_id), `ix_object_storage_header_rule_1` and `ix_object_storage_protection_rule_1` (bucket_id) back the keys. Upgrade: run the generated statements again (or the host's copy of them). - Conformance suite against MinIO and Garage (Testcontainers), service, index and action tests, and the index and actions on PostgreSQL. ### Changed diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/model/ObjectStorageSchema.java b/src/main/java/com/kingsrook/qbits/objectstorage/model/ObjectStorageSchema.java index 896afe2..3e5b798 100644 --- a/src/main/java/com/kingsrook/qbits/objectstorage/model/ObjectStorageSchema.java +++ b/src/main/java/com/kingsrook/qbits/objectstorage/model/ObjectStorageSchema.java @@ -38,7 +38,9 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; ** ** The statements are idempotent (CREATE ... IF NOT EXISTS) and include the ** unique constraints and the browse indexes the design calls for - ** ((bucket, key) unique; bucket with folder, last modified, size, content type). + ** ((bucket, key) unique; bucket with folder, last modified, size, content type), + ** then the foreign keys, each in a block that also upgrades an existing schema + ** (orphans recorded in qqq_fk_quarantine first; see addForeignKey). *******************************************************************************/ public final class ObjectStorageSchema { @@ -52,14 +54,79 @@ public final class ObjectStorageSchema ObjectStorageTables.OBJECT, List.of(List.of("bucketId", "objectKey")), ObjectStorageTables.USAGE_DAILY, List.of(List.of("bucketId", "snapshotDate"))); - /** Browse and lookup indexes: table to column lists. */ + /** Browse and lookup indexes: table to column lists. New entries go at the end of + ** a table's list, so the generated names (ix_
_) of existing ones never change. + ** The child lists on record pages (folder -> objects, object -> versions and references) + ** filter on the parent id alone, so those columns lead an index of their own. */ private static final Map>> INDEXES = Map.of( ObjectStorageTables.OBJECT, List.of(List.of("bucketId", "folderId"), List.of("bucketId", "lastModified"), List.of("bucketId", "size"), List.of("bucketId", "contentType"), - List.of("bucketId", "deleted", "referenceCount"), List.of("bucketId", "headersKnown", "deleted")), - ObjectStorageTables.OBJECT_REFERENCE, List.of(List.of("bucketId", "objectId"), List.of("bucketId", "missing")), + List.of("bucketId", "deleted", "referenceCount"), List.of("bucketId", "headersKnown", "deleted"), List.of("folderId")), + ObjectStorageTables.OBJECT_REFERENCE, List.of(List.of("bucketId", "objectId"), List.of("bucketId", "missing"), List.of("objectId")), ObjectStorageTables.OPERATION, List.of(List.of("bucketId", "occurredAt"), List.of("objectKey", "occurredAt")), ObjectStorageTables.FOLDER, List.of(List.of("parentFolderId")), - ObjectStorageTables.OBJECT_VERSION, List.of(List.of("bucketId", "objectKey"))); + ObjectStorageTables.OBJECT_VERSION, List.of(List.of("bucketId", "objectKey"), List.of("objectId")), + ObjectStorageTables.BUCKET, List.of(List.of("connectionId")), + ObjectStorageTables.HEADER_RULE, List.of(List.of("bucketId")), + ObjectStorageTables.PROTECTION_RULE, List.of(List.of("bucketId"))); + + /** The table that records the rows an upgrade changed so a foreign key could be added + ** (identical DDL in every qbit, so whichever runs first creates it). */ + public static final String QUARANTINE_DDL = "CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, " + + "table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, " + + "row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)"; + + /** Every parent/child relation, enforced in the database. The qbit never deletes a + ** connection, bucket or folder row (ConfigSync only upserts; folders are only + ** created), and its only deletes are object rows (scan removal, trash retention, + ** hard-delete write-through) and reference rows (rebuilt by each reference scan), + ** through QQQ deletes with no associations. So: + ** - observed state that belongs to a parent (capabilities, folders, objects, versions, + ** usage) CASCADEs: an object delete takes its versions, and an admin deleting a + ** bucket row takes its index with it; + ** - optional pointers into the index (object.folderId, reference.objectId) SET NULL: + ** the next scan sets the folder again, and a reference to a removed object is + ** missing until the next reference scan rebuilds the rows; + ** - admin-authored config (header and protection rules), the operation audit log and + ** a bucket's connection RESTRICT (NO ACTION): deleting a bucket row that has rules + ** or history, or a connection that buckets use, is refused rather than silently + ** dropping protection or audit rows (ConfigSync would re-create a configured bucket + ** under a new id, with its rules gone). + ** Inserts are parents first everywhere (connections before buckets, a folder's parent + ** before the folder, folders before objects), so nothing needs to be deferred. + ** New entries go at the end. */ + private static final List FOREIGN_KEYS = List.of( + new ForeignKey(ObjectStorageTables.BUCKET, "connectionId", ObjectStorageTables.CONNECTION, "NO ACTION"), + new ForeignKey(ObjectStorageTables.CONNECTION_CAPABILITY, "connectionId", ObjectStorageTables.CONNECTION, "CASCADE"), + new ForeignKey(ObjectStorageTables.BUCKET_CAPABILITY, "bucketId", ObjectStorageTables.BUCKET, "CASCADE"), + new ForeignKey(ObjectStorageTables.HEADER_RULE, "bucketId", ObjectStorageTables.BUCKET, "NO ACTION"), + new ForeignKey(ObjectStorageTables.PROTECTION_RULE, "bucketId", ObjectStorageTables.BUCKET, "NO ACTION"), + new ForeignKey(ObjectStorageTables.OPERATION, "bucketId", ObjectStorageTables.BUCKET, "NO ACTION"), + new ForeignKey(ObjectStorageTables.USAGE_DAILY, "bucketId", ObjectStorageTables.BUCKET, "CASCADE"), + new ForeignKey(ObjectStorageTables.FOLDER, "bucketId", ObjectStorageTables.BUCKET, "CASCADE"), + new ForeignKey(ObjectStorageTables.FOLDER, "parentFolderId", ObjectStorageTables.FOLDER, "CASCADE"), + new ForeignKey(ObjectStorageTables.OBJECT, "bucketId", ObjectStorageTables.BUCKET, "CASCADE"), + new ForeignKey(ObjectStorageTables.OBJECT, "folderId", ObjectStorageTables.FOLDER, "SET NULL"), + new ForeignKey(ObjectStorageTables.OBJECT_VERSION, "bucketId", ObjectStorageTables.BUCKET, "CASCADE"), + new ForeignKey(ObjectStorageTables.OBJECT_VERSION, "objectId", ObjectStorageTables.OBJECT, "CASCADE"), + new ForeignKey(ObjectStorageTables.OBJECT_REFERENCE, "bucketId", ObjectStorageTables.BUCKET, "CASCADE"), + new ForeignKey(ObjectStorageTables.OBJECT_REFERENCE, "objectId", ObjectStorageTables.OBJECT, "SET NULL")); + + + + /******************************************************************************* + ** One foreign key: the child table and field, the parent table (always its id) + ** and the ON DELETE rule. + *******************************************************************************/ + public record ForeignKey(String table, String field, String parent, String onDelete) + { + /******************************************************************************* + ** The constraint name: fk__. + *******************************************************************************/ + public String name() + { + return ("fk_" + table + "_" + column(field)); + } + } @@ -99,11 +166,56 @@ public final class ObjectStorageSchema } } } + statements.add(QUARANTINE_DDL); + for(ForeignKey fk : FOREIGN_KEYS) + { + statements.add(addForeignKey(fk, tables)); + } return (statements); } + /******************************************************************************* + ** The foreign keys the schema declares. + *******************************************************************************/ + public static List foreignKeys() + { + return (FOREIGN_KEYS); + } + + + + /******************************************************************************* + ** One idempotent block that adds a foreign key and upgrades an existing schema + ** safely: rows whose parent is gone are first recorded in qqq_fk_quarantine, then + ** their reference is cleared (nullable column) or the row is deleted (required + ** column; the whole row is recorded), so adding the constraint cannot fail. A + ** no-op once the constraint exists. + *******************************************************************************/ + private static String addForeignKey(ForeignKey fk, List tables) + { + String col = column(fk.field()); + boolean required = tables.stream().filter(t -> t.getName().equals(fk.table())).findFirst() + .map(t -> Boolean.TRUE.equals(t.getField(fk.field()).getIsRequired())).orElse(false); + String orphans = "c." + col + " IS NOT NULL AND NOT EXISTS (SELECT 1 FROM " + fk.parent() + " p WHERE p.id = c." + col + ")"; + return ("DO $$\n" + + "BEGIN\n" + + " IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = '" + fk.name() + "' AND conrelid = '" + fk.table() + "'::regclass) THEN\n" + + " INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset)\n" + + " SELECT '" + fk.table() + "', '" + col + "', c.id::text, c." + col + "::text, '" + (required ? "DELETED" : "NULLED") + "', row_to_json(c)::text, " + + "'qbit-object-storage ObjectStorageSchema'\n" + + " FROM " + fk.table() + " c WHERE " + orphans + ";\n" + + (required + ? " DELETE FROM " + fk.table() + " c WHERE " + orphans + ";\n" + : " UPDATE " + fk.table() + " c SET " + col + " = NULL WHERE " + orphans + ";\n") + + " ALTER TABLE " + fk.table() + " ADD CONSTRAINT " + fk.name() + " FOREIGN KEY (" + col + ") REFERENCES " + fk.parent() + " (id) ON DELETE " + fk.onDelete() + ";\n" + + " END IF;\n" + + "END $$"); + } + + + /******************************************************************************* ** The statements as one script. *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/objectstorage/index/PostgresIndexTest.java b/src/test/java/com/kingsrook/qbits/objectstorage/index/PostgresIndexTest.java index a1bcbd3..920ba66 100644 --- a/src/test/java/com/kingsrook/qbits/objectstorage/index/PostgresIndexTest.java +++ b/src/test/java/com/kingsrook/qbits/objectstorage/index/PostgresIndexTest.java @@ -23,10 +23,14 @@ package com.kingsrook.qbits.objectstorage.index; import java.sql.Connection; import java.sql.DriverManager; +import java.sql.ResultSet; import java.sql.Statement; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; import com.kingsrook.qbits.objectstorage.ObjectStorageQBitConfig; import com.kingsrook.qbits.objectstorage.api.FieldReferenceProvider; import com.kingsrook.qbits.objectstorage.api.ObjectStorage; @@ -46,6 +50,11 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn; +import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType; +import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.model.session.QSession; import com.kingsrook.qqq.backend.core.model.session.QUser; @@ -270,4 +279,203 @@ class PostgresIndexTest .withSecurityKeyValue(ObjectStorageTables.KEY_TYPE_BUCKET, ConfigSync.bucketId("indexed"))); assertThat(new QueryAction().execute(new QueryInput(ObjectStorageTables.OBJECT)).getRecords()).extracting(r -> r.getValueString("objectKey")).containsExactly("d/ref.txt"); } + + + + /** Relations (child.column -> parent) that are deliberately not database foreign keys, + ** each with its reason. Empty: every join and table possible value is enforced. */ + private static final Map FK_EXEMPTIONS = Map.of(); + + + + /******************************************************************************* + ** The guardrail: every join and table possible-value field between the qbit's + ** tables is a foreign key in the generated schema (or exempt, with a reason), + ** with the declared ON DELETE rule, and every foreign key column leads an index. + ** A new relation without a foreign key fails here. + *******************************************************************************/ + @Test + void everyRelationIsAForeignKeyWithAnIndex() throws Exception + { + Set expected = new TreeSet<>(); + for(QJoinMetaData join : QContext.getQInstance().getJoins().values()) + { + if(ObjectStorageTables.ALL.contains(join.getLeftTable()) && ObjectStorageTables.ALL.contains(join.getRightTable()) && join.getType() == JoinType.ONE_TO_MANY) + { + for(JoinOn on : join.getJoinOns()) + { + expected.add(join.getRightTable() + "." + ObjectStorageSchemaColumns.of(on.getRightField()) + " -> " + join.getLeftTable()); + } + } + } + for(String tableName : ObjectStorageTables.ALL) + { + for(QFieldMetaData field : QContext.getQInstance().getTable(tableName).getFields().values()) + { + QPossibleValueSource pvs = field.getPossibleValueSourceName() == null ? null : QContext.getQInstance().getPossibleValueSource(field.getPossibleValueSourceName()); + if(pvs != null && pvs.getType() == QPossibleValueSourceType.TABLE && ObjectStorageTables.ALL.contains(pvs.getTableName())) + { + expected.add(tableName + "." + ObjectStorageSchemaColumns.of(field.getName()) + " -> " + pvs.getTableName()); + } + } + } + assertThat(expected).as("the qbit declares relations").hasSizeGreaterThan(10); + + Map actual = new TreeMap<>(); + List noIndex = new ArrayList<>(); + try(Connection c = DriverManager.getConnection(postgres.getJdbcUrl(), "qqq", "qqq"); Statement st = c.createStatement(); + ResultSet rs = st.executeQuery(""" + SELECT cl.relname AS child, a.attname AS col, pcl.relname AS parent, con.confdeltype AS rule, + EXISTS (SELECT 1 FROM pg_index i WHERE i.indrelid = con.conrelid AND i.indkey[0] = con.conkey[1]) AS indexed + FROM pg_constraint con + JOIN pg_class cl ON cl.oid = con.conrelid + JOIN pg_class pcl ON pcl.oid = con.confrelid + JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = con.conkey[1] + WHERE con.contype = 'f'""")) + { + while(rs.next()) + { + String key = rs.getString("child") + "." + rs.getString("col") + " -> " + rs.getString("parent"); + actual.put(key, rs.getString("rule")); + if(!rs.getBoolean("indexed")) + { + noIndex.add(key); + } + } + } + + List missing = new ArrayList<>(); + for(String relation : expected) + { + if(!actual.containsKey(relation) && !FK_EXEMPTIONS.containsKey(relation)) + { + missing.add(relation); + } + } + assertThat(missing).as("relations without a database foreign key (add one to ObjectStorageSchema, or an exemption with a reason)").isEmpty(); + assertThat(noIndex).as("foreign key columns that do not lead an index").isEmpty(); + + Map rules = Map.of("NO ACTION", "a", "RESTRICT", "r", "CASCADE", "c", "SET NULL", "n"); + for(ObjectStorageSchema.ForeignKey fk : ObjectStorageSchema.foreignKeys()) + { + String key = fk.table() + "." + ObjectStorageSchemaColumns.of(fk.field()) + " -> " + fk.parent(); + assertThat(actual.get(key)).as(fk.name() + " ON DELETE").isEqualTo(rules.get(fk.onDelete())); + assertThat(fk.name().length()).as(fk.name()).isLessThanOrEqualTo(63); + } + } + + + + /******************************************************************************* + ** Upgrading a schema created before the foreign keys: orphans are recorded in + ** qqq_fk_quarantine and cleared (nullable) or deleted (required), the keys are + ** added, and running the statements again changes nothing. + *******************************************************************************/ + @Test + void foreignKeyUpgradeQuarantinesOrphansAndIsIdempotent() throws Exception + { + Integer bucketId = ConfigSync.bucketId("indexed"); + try(Connection c = DriverManager.getConnection(postgres.getJdbcUrl(), "qqq", "qqq"); Statement st = c.createStatement()) + { + for(ObjectStorageSchema.ForeignKey fk : ObjectStorageSchema.foreignKeys()) + { + st.execute("ALTER TABLE " + fk.table() + " DROP CONSTRAINT " + fk.name()); + } + st.execute("INSERT INTO object_storage_object (bucket_id, object_key, folder_id) VALUES (" + bucketId + ", 'kept.txt', 424242)"); + st.execute("INSERT INTO object_storage_protection_rule (bucket_id, prefix_glob, mode) VALUES (434343, 'x/*', 'NO_DELETE')"); + st.execute("INSERT INTO object_storage_protection_rule (bucket_id, prefix_glob, mode) VALUES (" + bucketId + ", 'y/*', 'NO_DELETE')"); + + ObjectStorageQBitConfig forSchema = new ObjectStorageQBitConfig().withBackendName("rdbms"); + for(int pass = 0; pass < 2; pass++) + { + for(String sql : ObjectStorageSchema.postgresStatements(forSchema)) + { + st.execute(sql); + } + } + + try(ResultSet rs = st.executeQuery("SELECT table_name, column_name, missing_parent_id, action, row_data FROM qqq_fk_quarantine ORDER BY id")) + { + List rows = new ArrayList<>(); + while(rs.next()) + { + rows.add(rs.getString(1) + "." + rs.getString(2) + "=" + rs.getString(3) + " " + rs.getString(4) + (rs.getString(5).contains("x/*") ? " with row" : "")); + } + assertThat(rows).as("recorded once, although the statements ran twice") + .containsExactly("object_storage_protection_rule.bucket_id=434343 DELETED with row", "object_storage_object.folder_id=424242 NULLED"); + } + try(ResultSet rs = st.executeQuery("SELECT folder_id FROM object_storage_object WHERE object_key = 'kept.txt'")) + { + assertThat(rs.next()).isTrue(); + assertThat(rs.getObject(1)).isNull(); + } + try(ResultSet rs = st.executeQuery("SELECT prefix_glob FROM object_storage_protection_rule ORDER BY id")) + { + assertThat(rs.next()).isTrue(); + assertThat(rs.getString(1)).isEqualTo("y/*"); + assertThat(rs.next()).isFalse(); + } + try(ResultSet rs = st.executeQuery("SELECT COUNT(*) FROM pg_constraint WHERE contype = 'f' AND conname LIKE 'fk_object_storage_%'")) + { + rs.next(); + assertThat(rs.getInt(1)).isEqualTo(ObjectStorageSchema.foreignKeys().size()); + } + } + } + + + + /******************************************************************************* + ** The delete paths the qbit uses still work with the keys: removing an object + ** row (scan removal, hard-delete write-through) takes its versions and leaves + ** its references pointing nowhere, and a bucket row with an audit history or + ** rules cannot be deleted. + *******************************************************************************/ + @Test + void deletesFollowTheForeignKeyRules() throws Exception + { + rawPut("f/one.txt", 3); + ObjectIndex.scan("indexed"); + Integer bucketId = ConfigSync.bucketId("indexed"); + Integer objectId = Db.first(ObjectStorageTables.OBJECT, "objectKey", "f/one.txt").getValueInteger("id"); + try(Connection c = DriverManager.getConnection(postgres.getJdbcUrl(), "qqq", "qqq"); Statement st = c.createStatement()) + { + st.execute("INSERT INTO object_storage_object_version (bucket_id, object_id, object_key) VALUES (" + bucketId + ", " + objectId + ", 'f/one.txt')"); + } + Db.insert("media", List.of(new QRecord().withValue("storageKey", "f/one.txt"))); + ReferenceScan.run("indexed"); + assertThat(Db.first(ObjectStorageTables.OBJECT_REFERENCE, "keyOrPrefix", "f/one.txt").getValueInteger("objectId")).isEqualTo(objectId); + + try(S3Client raw = target.rawClient()) + { + raw.deleteObject(DeleteObjectRequest.builder().bucket(target.indexBucket).key("f/one.txt").build()); + } + assertThat(ObjectIndex.scan("indexed").status()).isEqualTo("OK"); + assertThat(Db.first(ObjectStorageTables.OBJECT, "objectKey", "f/one.txt")).isNull(); + assertThat(Db.query(ObjectStorageTables.OBJECT_VERSION, "objectId", objectId)).as("versions cascade").isEmpty(); + assertThat(Db.first(ObjectStorageTables.OBJECT_REFERENCE, "keyOrPrefix", "f/one.txt").getValueInteger("objectId")).as("reference set null").isNull(); + + try(Connection c = DriverManager.getConnection(postgres.getJdbcUrl(), "qqq", "qqq"); Statement st = c.createStatement()) + { + st.execute("INSERT INTO object_storage_operation (bucket_id, bucket_alias, action) VALUES (" + bucketId + ", 'indexed', 'PUT')"); + org.assertj.core.api.Assertions.assertThatThrownBy(() -> st.execute("DELETE FROM object_storage_bucket WHERE id = " + bucketId)) + .hasMessageContaining("fk_object_storage_operation_bucket_id"); + } + } + + + + /******************************************************************************* + ** Column names as the schema derives them. + *******************************************************************************/ + private static final class ObjectStorageSchemaColumns + { + /******************************************************************************* + ** The snake_case column of a field. + *******************************************************************************/ + static String of(String field) + { + return (com.kingsrook.qqq.backend.core.instances.QInstanceEnricher.inferBackendName(field)); + } + } } diff --git a/src/test/java/com/kingsrook/qbits/objectstorage/internal/TablesAndConfigTest.java b/src/test/java/com/kingsrook/qbits/objectstorage/internal/TablesAndConfigTest.java index dafb2bd..67ced67 100644 --- a/src/test/java/com/kingsrook/qbits/objectstorage/internal/TablesAndConfigTest.java +++ b/src/test/java/com/kingsrook/qbits/objectstorage/internal/TablesAndConfigTest.java @@ -129,6 +129,25 @@ class TablesAndConfigTest + /******************************************************************************* + ** The record-page child lists filter on the parent id alone (folder -> + ** objects, object -> versions and references), so the generated schema + ** gives each of those columns its own index, appended so existing generated + ** index names keep their numbers. + *******************************************************************************/ + @Test + void testChildListColumnsAreIndexed() + { + String schema = String.join("\n", com.kingsrook.qbits.objectstorage.model.ObjectStorageSchema.postgresStatements(new ObjectStorageQBitConfig().withBackendName("rdbms"))); + assertThat(schema) + .contains("ix_object_storage_object_1 ON object_storage_object (bucket_id, folder_id)") + .contains("ix_object_storage_object_7 ON object_storage_object (folder_id)") + .contains("ix_object_storage_object_version_2 ON object_storage_object_version (object_id)") + .contains("ix_object_storage_object_reference_3 ON object_storage_object_reference (object_id)"); + } + + + /******************************************************************************* ** Owner naming rules: every table is lowercase snake_case with the full qbit ** name as prefix; no table has a field that could hold a secret value; the nav