mattermost-mobile-upstream/app/database/operator/base_data_operator/index.ts
Mattermost Build f8a177ca48
Mm 70299 channel labels display phase 1 (#10113) (#10171)
* MM-70299 - display channel attributes in header, info and banner

* cover and expand channel attributes feature with UI and E2E tests

* Implement channel attributes lock and update related tests and API methods

* Enhance error logging and improve type definitions for channel attributes

* Handle error from setAccessControlGroupId in fetchAccessControlAttributeFields

* Refactor fetchAccessControlAttributeFields to include system ID handling and improve batch record persistence

* Address code review feedback: extract feature-flag constants, move DB-only helpers to query/local layers, refactor attribute chip styles, fix colour spelling, and add test coverage for value pruning and subtitle component

* Fix error handling in removeStoredFields function and delete obsolete end-to-end test file

* Add tests for removeStoredFields function and enhance property queries

* Fix removeStoredFields to recover group ID from classification anchor and clean all owned fields

* Fix batchRecords silently swallowing write errors in fetchAccessControlAttributeFields

* Remove unused visibility check for ChannelAttributeLabels in e2e tests

* Fix classification fetch cache and channel-attributes E2E lock contention

Do not stamp the classification banner cache when both access-control flags
are off, so turning a flag back on after a reload refetches fields.

Share the classification lock with the channel-attributes Detox suite and
stop unsetting ClassificationMarkings. Skip flag-on tests when the server
cannot enable ChannelAttributes.



* Scope access_control field prune to owned object types

handlePropertyFields(groupId) deletes every field in the group. The fetch
only returns system and channel fields, so same-group user/session rows
were removed on each refresh. Prune only system and channel fields, matching
removeStoredFields.



* fail channel attributes tests when feature enablement fails instead of silently reporting them as passed

* Compose all channel attribute values in banners with template resolution, mixed field support, and matching web colors

---------




(cherry picked from commit 54731fa6ac)

Co-authored-by: Pablo Vélez <pablovv2016@gmail.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: mattermost-code <matty-code@mattermost.com>
2026-09-18 08:10:30 +03:00

257 lines
10 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Database, Model, Q} from '@nozbe/watermelondb';
import {DeviceEventEmitter} from 'react-native';
import {Events} from '@constants';
import {OperationType} from '@constants/database';
import {
getRangeOfValues,
getValidRecordsForUpdate,
retrieveRecords,
} from '@database/operator/utils/general';
import {isDatabaseCorruptionError} from '@utils/database_errors';
import {logWarning} from '@utils/log';
import type {
HandleRecordsArgs,
OperationArgs,
ProcessRecordResults,
ProcessRecordsArgs,
RecordPair,
} from '@typings/database/database';
export interface BaseDataOperatorType {
database: Database;
handleRecords: <T extends Model, R extends RawValue>({buildKeyRecordBy, fieldName, transformer, createOrUpdateRawValues, deleteRawValues, tableName, prepareRecordsOnly}: HandleRecordsArgs<T, R>, description: string) => Promise<Model[]>;
processRecords: <T extends Model, R extends RawValue>({createOrUpdateRawValues, deleteRawValues, tableName, buildKeyRecordBy, fieldName}: ProcessRecordsArgs<R>) => Promise<ProcessRecordResults<T, R>>;
batchRecords: (models: Model[], description: string, propagateError?: boolean) => Promise<void>;
prepareRecords: <T extends Model, R extends RawValue>({tableName, createRaws, deleteRaws, updateRaws, transformer}: OperationArgs<T, R>) => Promise<Model[]>;
}
export default class BaseDataOperator {
database: Database;
constructor(database: Database) {
this.database = database;
}
/**
* processRecords: This method weeds out duplicates entries. It may happen that we do multiple inserts for
* the same value. Hence, prior to that we query the database and pick only those values that are 'new' from the 'Raw' array.
* @param {ProcessRecordsArgs} inputsArg
* @param {RawValue[]} inputsArg.createOrUpdateRawValues
* @param {RawValue[]} inputsArg.deleteRawValues
* @param {string} inputsArg.tableName
* @param {string} inputsArg.fieldName
* @param {(existing: Model, newElement: RawValue) => boolean} inputsArg.buildKeyRecordBy
* @returns {Promise<{ProcessRecordResults<T>}>}
*/
processRecords = async <T extends Model, R extends RawValue>({createOrUpdateRawValues = [], deleteRawValues = [], tableName, buildKeyRecordBy, fieldName, shouldUpdate}: ProcessRecordsArgs<R>): Promise<ProcessRecordResults<T, R>> => {
const getRecords = async (rawValues: R[]) => {
// We will query a table where one of its fields can match a range of values. Hence, here we are extracting all those potential values.
const columnValues: string[] = getRangeOfValues({fieldName, raws: rawValues});
if (!columnValues.length && rawValues.length) {
throw new Error(
`Invalid "fieldName" or "tableName" has been passed to the processRecords method for tableName ${tableName} fieldName ${fieldName}`,
);
}
if (!rawValues.length) {
return [];
}
const existingRecords = await retrieveRecords<T>({
database: this.database,
tableName,
condition: Q.where(fieldName, Q.oneOf(columnValues)),
});
return existingRecords;
};
const createRaws: Array<RecordPair<T, R>> = [];
const updateRaws: Array<RecordPair<T, R>> = [];
// for delete flow
const deleteRaws = await getRecords(deleteRawValues);
// for create or update flow
const createOrUpdateRaws = await getRecords(createOrUpdateRawValues);
const recordsByKeys = createOrUpdateRaws.reduce((result: Record<string, T>, record) => {
// @ts-expect-error object with string key
const key = buildKeyRecordBy?.(record) || record[fieldName];
result[key] = record;
return result;
}, {});
if (createOrUpdateRawValues.length > 0) {
for (const newElement of createOrUpdateRawValues) {
const key = buildKeyRecordBy?.(newElement) || newElement[fieldName];
const existingRecord = recordsByKeys[key];
// We found a record in the database that matches this element; hence, we'll proceed for an UPDATE operation
if (existingRecord) {
if (shouldUpdate && !shouldUpdate(existingRecord, newElement)) {
continue;
}
// Some raw value has an update_at field. We'll proceed to update only if the update_at value is different from the record's value in database
const updateRecords = getValidRecordsForUpdate<T, R>({
tableName,
existingRecord,
newValue: newElement,
});
updateRaws.push(updateRecords);
continue;
}
// This RawValue is not present in the database; hence, we need to create it
createRaws.push({record: undefined, raw: newElement});
}
}
return {
createRaws,
updateRaws,
deleteRaws,
};
};
/**
* prepareRecords: Utility method that actually calls the operators for the handlers
* @param {OperationArgs} prepareRecord
* @param {string} prepareRecord.tableName
* @param {RawValue[]} prepareRecord.createRaws
* @param {RawValue[]} prepareRecord.updateRaws
* @param {T extends Model[]} prepareRecord.deleteRaws
* @param {(TransformerArgs) => Promise<T extends Model>;} transformer
* @returns {Promise<T extends Model[]>}
*/
prepareRecords = async <T extends Model, R extends RawValue>({tableName, createRaws, deleteRaws, updateRaws, transformer}: OperationArgs<T, R>): Promise<T[]> => {
if (!this.database) {
logWarning('Database not defined in prepareRecords');
return [];
}
let preparedRecords: Array<Promise<T>> = [];
// create operation
if (createRaws?.length) {
const recordPromises = createRaws.map(
(createRecord: RecordPair<T, R>) => {
return transformer({
database: this.database,
tableName,
value: createRecord,
action: OperationType.CREATE,
});
},
);
preparedRecords = preparedRecords.concat(recordPromises);
}
// update operation
if (updateRaws?.length) {
const recordPromises = updateRaws.map(
(updateRecord: RecordPair<T, R>) => {
return transformer({
database: this.database,
tableName,
value: updateRecord,
action: OperationType.UPDATE,
});
},
);
preparedRecords = preparedRecords.concat(recordPromises);
}
const results = (await Promise.all(preparedRecords)) as T[];
if (deleteRaws?.length) {
deleteRaws.forEach((deleteRecord) => {
results.push(deleteRecord.prepareDestroyPermanently());
});
}
return results;
};
/**
* batchRecords: Accepts an instance of Database (either Default or Server) and an array of
* prepareCreate/prepareUpdate 'models' and executes the actions on the database.
* @param {Array} models
* @returns {Promise<void>}
*/
async batchRecords(models: Model[], description: string, propagateError = false): Promise<void> {
try {
if (models.length > 0) {
await this.database.write(async (writer) => {
await writer.batch(...models);
}, description);
}
} catch (e) {
logWarning('batchRecords error ', description, e as Error);
if (isDatabaseCorruptionError(e)) {
DeviceEventEmitter.emit(Events.DATABASE_CORRUPTION_DETECTED, {
database: this.database,
error: e,
source: description,
});
}
if (propagateError) {
throw e;
}
}
}
/**
* handleRecords : Utility that processes some records' data against values already present in the database so as to avoid duplicity.
* @param {HandleRecordsArgs} handleRecordsArgs
* @param {(existing: Model, newElement: RawValue) => boolean} handleRecordsArgs.buildKeyRecordBy
* @param {string} handleRecordsArgs.fieldName
* @param {(TransformerArgs) => Promise<Model>} handleRecordsArgs.composer
* @param {RawValue[]} handleRecordsArgs.createOrUpdateRawValues
* @param {RawValue[]} handleRecordsArgs.deleteRawValues
* @param {string} handleRecordsArgs.tableName
* @returns {Promise<Model[]>}
*/
async handleRecords<T extends Model, R extends RawValue>({buildKeyRecordBy, fieldName, transformer, createOrUpdateRawValues, deleteRawValues = [], tableName, prepareRecordsOnly = true, shouldUpdate}: HandleRecordsArgs<T, R>, description: string): Promise<T[]> {
if (!createOrUpdateRawValues.length && !deleteRawValues.length) {
logWarning(
`An empty "rawValues" array has been passed to the handleRecords method for tableName ${tableName}`,
);
return [];
}
const {createRaws, deleteRaws, updateRaws} = await this.processRecords<T, R>({
createOrUpdateRawValues,
deleteRawValues,
tableName,
buildKeyRecordBy,
fieldName,
shouldUpdate,
});
let models: T[] = [];
models = await this.prepareRecords<T, R>({
tableName,
createRaws,
updateRaws,
deleteRaws,
transformer,
});
if (!prepareRecordsOnly && models?.length) {
await this.batchRecords(models, description);
}
return models;
}
}