mirror of
https://github.com/Unleash/unleash.git
synced 2025-08-09 13:47:13 +02:00
feat: shared flags
This commit is contained in:
parent
a2723ec0c0
commit
f90aef2167
@ -74,7 +74,7 @@ export default class FeatureToggleClientStore
|
||||
'features.name as name',
|
||||
'features.description as description',
|
||||
'features.type as type',
|
||||
'features.project as project',
|
||||
'feature_project.project_id as project',
|
||||
'features.stale as stale',
|
||||
'features.impression_data as impression_data',
|
||||
'features.last_seen_at as last_seen_at',
|
||||
@ -100,6 +100,11 @@ export default class FeatureToggleClientStore
|
||||
] as (string | Raw<any>)[];
|
||||
|
||||
let query = this.db('features')
|
||||
.join(
|
||||
'feature_project',
|
||||
'feature_project.feature_name',
|
||||
'features.name',
|
||||
)
|
||||
.modify(FeatureToggleStore.filterByArchived, archived)
|
||||
.leftJoin(
|
||||
this.db('feature_strategies')
|
||||
@ -173,7 +178,10 @@ export default class FeatureToggleClientStore
|
||||
featureQuery.project &&
|
||||
!featureQuery.project.includes(ALL_PROJECTS)
|
||||
) {
|
||||
query = query.whereIn('project', featureQuery.project);
|
||||
query = query.whereIn(
|
||||
'feature_project.project_id',
|
||||
featureQuery.project,
|
||||
);
|
||||
}
|
||||
if (featureQuery.namePrefix) {
|
||||
query = query.where(
|
||||
|
@ -71,12 +71,17 @@ export class DependentFeaturesReadModel implements IDependentFeaturesReadModel {
|
||||
return [];
|
||||
}
|
||||
const rows = await this.db('features')
|
||||
.innerJoin(
|
||||
'feature_project',
|
||||
'feature_project.feature_name',
|
||||
'features.name',
|
||||
)
|
||||
.leftJoin(
|
||||
'dependent_features',
|
||||
'features.name',
|
||||
'dependent_features.child',
|
||||
)
|
||||
.where('features.project', result[0].project)
|
||||
.where('feature_project.project_id', result[0].project)
|
||||
.andWhere('features.name', '!=', child)
|
||||
.andWhere('dependent_features.child', null)
|
||||
.andWhere('features.archived_at', null)
|
||||
|
@ -108,7 +108,11 @@ export class DependentFeaturesService {
|
||||
this.dependentFeaturesReadModel.getChildren([child]),
|
||||
this.dependentFeaturesReadModel.getParents(parent),
|
||||
this.featuresReadModel.featureExists(parent),
|
||||
this.featuresReadModel.featuresInTheSameProject(child, parent),
|
||||
this.featuresReadModel.featuresInTheSameProject(
|
||||
child,
|
||||
parent,
|
||||
projectId,
|
||||
),
|
||||
]);
|
||||
|
||||
if (grandchildren.length > 0) {
|
||||
|
@ -117,7 +117,7 @@ class FeatureSearchStore implements IFeatureSearchStore {
|
||||
'features.description as description',
|
||||
'features.type as type',
|
||||
'features.archived_at as archived_at',
|
||||
'features.project as project',
|
||||
'feature_project.project_id as project',
|
||||
'features.created_at as created_at',
|
||||
'features.stale as stale',
|
||||
'features.last_seen_at as last_seen_at',
|
||||
@ -144,6 +144,12 @@ class FeatureSearchStore implements IFeatureSearchStore {
|
||||
const lastSeenQuery = 'last_seen_at_metrics.last_seen_at';
|
||||
selectColumns.push(`${lastSeenQuery} as env_last_seen_at`);
|
||||
|
||||
query.innerJoin(
|
||||
'feature_project',
|
||||
'features.name',
|
||||
'feature_project.feature_name',
|
||||
);
|
||||
|
||||
if (userId) {
|
||||
query.leftJoin(`favorite_features`, function () {
|
||||
this.on(
|
||||
@ -532,7 +538,7 @@ class FeatureSearchStore implements IFeatureSearchStore {
|
||||
name: 'features.name',
|
||||
type: 'features.type',
|
||||
stale: 'features.stale',
|
||||
project: 'features.project',
|
||||
project: 'feature_project.project_id',
|
||||
};
|
||||
|
||||
let rankingSql = 'order by ';
|
||||
@ -786,9 +792,14 @@ const applyQueryParams = (
|
||||
const segmentConditions = queryParams.filter(
|
||||
(param) => param.field === 'segment',
|
||||
);
|
||||
const genericConditions = queryParams.filter(
|
||||
(param) => !['tag', 'stale'].includes(param.field),
|
||||
);
|
||||
const genericConditions = queryParams
|
||||
.filter((param) => !['tag', 'stale'].includes(param.field))
|
||||
.map((params) =>
|
||||
params.field === 'project'
|
||||
? { ...params, field: 'feature_project.project_id' }
|
||||
: params,
|
||||
);
|
||||
|
||||
applyGenericQueryParams(query, genericConditions);
|
||||
|
||||
applyStaleConditions(query, staleConditions);
|
||||
|
@ -87,11 +87,11 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
|
||||
return this.features.filter((f) => names.includes(f.name));
|
||||
}
|
||||
|
||||
async getProjectId(name: string | undefined): Promise<string | undefined> {
|
||||
async getProjectIds(name: string | undefined): Promise<string[]> {
|
||||
if (name === undefined) {
|
||||
return Promise.resolve(undefined);
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
return Promise.resolve(this.get(name).then((f) => f.project));
|
||||
return Promise.resolve(this.get(name).then((f) => [f.project]));
|
||||
}
|
||||
|
||||
private getFilterQuery(query: Partial<IFeatureToggleStoreQuery>) {
|
||||
|
@ -8,6 +8,7 @@ export class FakeFeaturesReadModel implements IFeaturesReadModel {
|
||||
featuresInTheSameProject(
|
||||
featureA: string,
|
||||
featureB: string,
|
||||
project: string,
|
||||
): Promise<boolean> {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
@ -304,14 +304,14 @@ export class FeatureToggleService {
|
||||
featureName,
|
||||
projectId,
|
||||
}: IFeatureContext): Promise<void> {
|
||||
const id = await this.featureToggleStore.getProjectId(featureName);
|
||||
const ids = await this.featureToggleStore.getProjectIds(featureName);
|
||||
|
||||
if (id !== projectId) {
|
||||
if (ids.includes(projectId)) {
|
||||
throw new NotFoundError(
|
||||
`There's no feature named "${featureName}" in project "${projectId}"${
|
||||
id === undefined
|
||||
ids.length > 0
|
||||
? '.'
|
||||
: `, but there's a feature with that name in project "${id}"`
|
||||
: `, but there's a feature with that name in project "${ids}"`
|
||||
}`,
|
||||
);
|
||||
}
|
||||
@ -1092,12 +1092,14 @@ export class FeatureToggleService {
|
||||
environmentVariants,
|
||||
userId,
|
||||
}: IGetFeatureParams): Promise<FeatureToggleView> {
|
||||
console.log('get feature');
|
||||
if (projectId) {
|
||||
await this.validateFeatureBelongsToProject({
|
||||
featureName,
|
||||
projectId,
|
||||
});
|
||||
}
|
||||
console.log('get feature found');
|
||||
|
||||
let dependencies: IDependency[] = [];
|
||||
let children: string[] = [];
|
||||
@ -2170,10 +2172,6 @@ export class FeatureToggleService {
|
||||
);
|
||||
}
|
||||
|
||||
async getProjectId(name: string): Promise<string | undefined> {
|
||||
return this.featureToggleStore.getProjectId(name);
|
||||
}
|
||||
|
||||
async updateFeatureStrategyProject(
|
||||
featureName: string,
|
||||
newProjectId: string,
|
||||
|
@ -322,16 +322,20 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
|
||||
* @deprecated
|
||||
* @param name
|
||||
*/
|
||||
async getProjectId(name: string): Promise<string> {
|
||||
return this.db
|
||||
.first(['project'])
|
||||
.from(TABLE)
|
||||
.where({ name })
|
||||
.then((r) => (r ? r.project : undefined))
|
||||
.catch((e) => {
|
||||
this.logger.error(e);
|
||||
return undefined;
|
||||
});
|
||||
async getProjectIds(name: string): Promise<string[]> {
|
||||
return (
|
||||
this.db
|
||||
.select(['project_id'])
|
||||
.from('feature_project')
|
||||
// .join('feature_project', 'feature_project.feature_name', 'feature.name')
|
||||
// .where({ name })
|
||||
.where('feature_project.feature_name', name)
|
||||
);
|
||||
// .then((r) => (r ? r.project : undefined))
|
||||
// .catch((e) => {
|
||||
// this.logger.error(e);
|
||||
// return undefined;
|
||||
// });
|
||||
}
|
||||
|
||||
async exists(name: string): Promise<boolean> {
|
||||
@ -480,6 +484,11 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
|
||||
.insert(this.insertToRow(project, data))
|
||||
.returning(FEATURE_COLUMNS);
|
||||
|
||||
await this.db('feature_project').insert({
|
||||
feature_name: data.name,
|
||||
project_id: project,
|
||||
});
|
||||
|
||||
return this.rowToFeature(row[0]);
|
||||
} catch (err) {
|
||||
this.logger.error('Could not insert feature, error: ', err);
|
||||
|
@ -20,10 +20,13 @@ export class FeaturesReadModel implements IFeaturesReadModel {
|
||||
async featuresInTheSameProject(
|
||||
featureA: string,
|
||||
featureB: string,
|
||||
project: string,
|
||||
): Promise<boolean> {
|
||||
const rows = await this.db('features')
|
||||
.countDistinct('project as count')
|
||||
.whereIn('name', [featureA, featureB]);
|
||||
return Number(rows[0].count) === 1;
|
||||
const rows = await this.db('feature_project')
|
||||
.whereIn('feature_name', [featureA, featureB])
|
||||
.andWhere('project_id', project)
|
||||
.countDistinct('feature_name as count');
|
||||
|
||||
return Number(rows[0].count) === 2;
|
||||
}
|
||||
}
|
||||
|
@ -27,15 +27,15 @@ afterAll(async () => {
|
||||
});
|
||||
|
||||
test('should not crash for unknown toggle', async () => {
|
||||
const project = await featureToggleStore.getProjectId(
|
||||
const project = await featureToggleStore.getProjectIds(
|
||||
'missing-toggle-name',
|
||||
);
|
||||
expect(project).toBe(undefined);
|
||||
expect(project).toBe([]);
|
||||
});
|
||||
|
||||
test('should not crash for undefined toggle name', async () => {
|
||||
const project = await featureToggleStore.getProjectId(undefined);
|
||||
expect(project).toBe(undefined);
|
||||
const project = await featureToggleStore.getProjectIds(undefined);
|
||||
expect(project).toBe([]);
|
||||
});
|
||||
|
||||
describe('potentially_stale marking', () => {
|
||||
|
@ -23,7 +23,7 @@ export interface IFeatureToggleStore extends Store<FeatureToggle, string> {
|
||||
|
||||
setLastSeen(data: LastSeenInput[]): Promise<void>;
|
||||
|
||||
getProjectId(name: string | undefined): Promise<string | undefined>;
|
||||
getProjectIds(name: string | undefined): Promise<string[]>;
|
||||
|
||||
create(project: string, data: FeatureToggleInsert): Promise<FeatureToggle>;
|
||||
|
||||
|
@ -3,5 +3,6 @@ export interface IFeaturesReadModel {
|
||||
featuresInTheSameProject(
|
||||
featureA: string,
|
||||
featureB: string,
|
||||
project: string,
|
||||
): Promise<boolean>;
|
||||
}
|
||||
|
@ -87,7 +87,9 @@ const rbacMiddleware = (
|
||||
)
|
||||
) {
|
||||
const { featureName } = params;
|
||||
projectId = await featureToggleStore.getProjectId(featureName);
|
||||
projectId = (
|
||||
await featureToggleStore.getProjectIds(featureName)
|
||||
)[0];
|
||||
} else if (
|
||||
projectId === undefined &&
|
||||
permissionsArray.some(
|
||||
|
25
src/migrations/20250515074146-multiple-feature-projects.js
Normal file
25
src/migrations/20250515074146-multiple-feature-projects.js
Normal file
@ -0,0 +1,25 @@
|
||||
exports.up = (db, callback) => {
|
||||
db.runSql(
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS feature_project (
|
||||
feature_name VARCHAR(255) NOT NULL,
|
||||
project_id VARCHAR(255) NOT NULL,
|
||||
PRIMARY KEY (feature_name, project_id),
|
||||
FOREIGN KEY (feature_name) REFERENCES features(name) ON DELETE CASCADE,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO feature_project (feature_name, project_id)
|
||||
SELECT name, project
|
||||
FROM features
|
||||
WHERE project IS NOT NULL;
|
||||
`,
|
||||
callback,
|
||||
);
|
||||
};
|
||||
|
||||
exports.down = (db, callback) => {
|
||||
db.runSql(
|
||||
`DROP TABLE IF EXISTS feature_project`,
|
||||
callback,
|
||||
);
|
||||
};
|
Loading…
Reference in New Issue
Block a user