Refactor executable IDs to use strings #262
This commit unifies executable ID structure across categories and scripts, paving the way for more complex ID solutions for #262. It also refactors related code to adapt to the changes. Key changes: - Change numeric IDs to string IDs for categories - Use named types for string IDs to improve code clarity - Add unit tests to verify ID uniqueness Other supporting changes: - Separate concerns in entities for data access and executables by using separate abstractions (`Identifiable` and `RepositoryEntity`) - Simplify usage and construction of entities. - Remove `BaseEntity` for simplicity. - Move creation of categories/scripts to domain layer - Refactor CategoryCollection for better validation logic isolation - Rename some categories to keep the names (used as pseudo-IDs) unique on Windows.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { OperatingSystem } from './OperatingSystem';
|
||||
import type { IApplication } from './IApplication';
|
||||
import type { ICategoryCollection } from './ICategoryCollection';
|
||||
import type { ICategoryCollection } from './Collection/ICategoryCollection';
|
||||
import type { ProjectDetails } from './Project/ProjectDetails';
|
||||
|
||||
export class Application implements IApplication {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { getEnumValues, assertInRange } from '@/application/Common/Enum';
|
||||
import { RecommendationLevel } from './Executables/Script/RecommendationLevel';
|
||||
import { OperatingSystem } from './OperatingSystem';
|
||||
import type { IEntity } from '../infrastructure/Entity/IEntity';
|
||||
import type { Category } from './Executables/Category/Category';
|
||||
import type { Script } from './Executables/Script/Script';
|
||||
import type { IScriptingDefinition } from './IScriptingDefinition';
|
||||
import { RecommendationLevel } from '../Executables/Script/RecommendationLevel';
|
||||
import { OperatingSystem } from '../OperatingSystem';
|
||||
import { validateCategoryCollection } from './Validation/CompositeCategoryCollectionValidator';
|
||||
import type { ExecutableId } from '../Executables/Identifiable';
|
||||
import type { Category } from '../Executables/Category/Category';
|
||||
import type { Script } from '../Executables/Script/Script';
|
||||
import type { IScriptingDefinition } from '../IScriptingDefinition';
|
||||
import type { ICategoryCollection } from './ICategoryCollection';
|
||||
import type { CategoryCollectionValidator } from './Validation/CategoryCollectionValidator';
|
||||
|
||||
export class CategoryCollection implements ICategoryCollection {
|
||||
public readonly os: OperatingSystem;
|
||||
@@ -22,22 +24,24 @@ export class CategoryCollection implements ICategoryCollection {
|
||||
|
||||
constructor(
|
||||
parameters: CategoryCollectionInitParameters,
|
||||
validate: CategoryCollectionValidator = validateCategoryCollection,
|
||||
) {
|
||||
this.os = parameters.os;
|
||||
this.actions = parameters.actions;
|
||||
this.scripting = parameters.scripting;
|
||||
|
||||
this.queryable = makeQueryable(this.actions);
|
||||
assertInRange(this.os, OperatingSystem);
|
||||
ensureValid(this.queryable);
|
||||
ensureNoDuplicates(this.queryable.allCategories);
|
||||
ensureNoDuplicates(this.queryable.allScripts);
|
||||
validate({
|
||||
allScripts: this.queryable.allScripts,
|
||||
allCategories: this.queryable.allCategories,
|
||||
operatingSystem: this.os,
|
||||
});
|
||||
}
|
||||
|
||||
public getCategory(categoryId: number): Category {
|
||||
const category = this.queryable.allCategories.find((c) => c.id === categoryId);
|
||||
public getCategory(executableId: ExecutableId): Category {
|
||||
const category = this.queryable.allCategories.find((c) => c.executableId === executableId);
|
||||
if (!category) {
|
||||
throw new Error(`Missing category with ID: "${categoryId}"`);
|
||||
throw new Error(`Missing category with ID: "${executableId}"`);
|
||||
}
|
||||
return category;
|
||||
}
|
||||
@@ -48,10 +52,10 @@ export class CategoryCollection implements ICategoryCollection {
|
||||
return scripts ?? [];
|
||||
}
|
||||
|
||||
public getScript(scriptId: string): Script {
|
||||
const script = this.queryable.allScripts.find((s) => s.id === scriptId);
|
||||
public getScript(executableId: ExecutableId): Script {
|
||||
const script = this.queryable.allScripts.find((s) => s.executableId === executableId);
|
||||
if (!script) {
|
||||
throw new Error(`missing script: ${scriptId}`);
|
||||
throw new Error(`Missing script: ${executableId}`);
|
||||
}
|
||||
return script;
|
||||
}
|
||||
@@ -65,21 +69,6 @@ export class CategoryCollection implements ICategoryCollection {
|
||||
}
|
||||
}
|
||||
|
||||
function ensureNoDuplicates<TKey>(entities: ReadonlyArray<IEntity<TKey>>) {
|
||||
const isUniqueInArray = (id: TKey, index: number, array: readonly TKey[]) => array
|
||||
.findIndex((otherId) => otherId === id) !== index;
|
||||
const duplicatedIds = entities
|
||||
.map((entity) => entity.id)
|
||||
.filter((id, index, array) => !isUniqueInArray(id, index, array))
|
||||
.filter(isUniqueInArray);
|
||||
if (duplicatedIds.length > 0) {
|
||||
const duplicatedIdsText = duplicatedIds.map((id) => `"${id}"`).join(',');
|
||||
throw new Error(
|
||||
`Duplicate entities are detected with following id(s): ${duplicatedIdsText}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface CategoryCollectionInitParameters {
|
||||
readonly os: OperatingSystem;
|
||||
readonly actions: ReadonlyArray<Category>;
|
||||
@@ -92,35 +81,12 @@ interface QueryableCollection {
|
||||
readonly scriptsByLevel: Map<RecommendationLevel, readonly Script[]>;
|
||||
}
|
||||
|
||||
function ensureValid(application: QueryableCollection) {
|
||||
ensureValidCategories(application.allCategories);
|
||||
ensureValidScripts(application.allScripts);
|
||||
}
|
||||
|
||||
function ensureValidCategories(allCategories: readonly Category[]) {
|
||||
if (!allCategories.length) {
|
||||
throw new Error('must consist of at least one category');
|
||||
}
|
||||
}
|
||||
|
||||
function ensureValidScripts(allScripts: readonly Script[]) {
|
||||
if (!allScripts.length) {
|
||||
throw new Error('must consist of at least one script');
|
||||
}
|
||||
const missingRecommendationLevels = getEnumValues(RecommendationLevel)
|
||||
.filter((level) => allScripts.every((script) => script.level !== level));
|
||||
if (missingRecommendationLevels.length > 0) {
|
||||
throw new Error('none of the scripts are recommended as'
|
||||
+ ` "${missingRecommendationLevels.map((level) => RecommendationLevel[level]).join(', "')}".`);
|
||||
}
|
||||
}
|
||||
|
||||
function flattenApplication(
|
||||
function flattenCategoryHierarchy(
|
||||
categories: ReadonlyArray<Category>,
|
||||
): [Category[], Script[]] {
|
||||
const [subCategories, subScripts] = (categories || [])
|
||||
// Parse children
|
||||
.map((category) => flattenApplication(category.subCategories))
|
||||
.map((category) => flattenCategoryHierarchy(category.subcategories))
|
||||
// Flatten results
|
||||
.reduce(([previousCategories, previousScripts], [currentCategories, currentScripts]) => {
|
||||
return [
|
||||
@@ -143,7 +109,7 @@ function flattenApplication(
|
||||
function makeQueryable(
|
||||
actions: ReadonlyArray<Category>,
|
||||
): QueryableCollection {
|
||||
const flattened = flattenApplication(actions);
|
||||
const flattened = flattenCategoryHierarchy(actions);
|
||||
return {
|
||||
allCategories: flattened[0],
|
||||
allScripts: flattened[1],
|
||||
@@ -3,6 +3,7 @@ import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel';
|
||||
import type { Script } from '@/domain/Executables/Script/Script';
|
||||
import type { Category } from '@/domain/Executables/Category/Category';
|
||||
import type { ExecutableId } from '../Executables/Identifiable';
|
||||
|
||||
export interface ICategoryCollection {
|
||||
readonly scripting: IScriptingDefinition;
|
||||
@@ -12,8 +13,8 @@ export interface ICategoryCollection {
|
||||
readonly actions: ReadonlyArray<Category>;
|
||||
|
||||
getScriptsByLevel(level: RecommendationLevel): ReadonlyArray<Script>;
|
||||
getCategory(categoryId: number): Category;
|
||||
getScript(scriptId: string): Script;
|
||||
getCategory(categoryId: ExecutableId): Category;
|
||||
getScript(scriptId: ExecutableId): Script;
|
||||
getAllScripts(): ReadonlyArray<Script>;
|
||||
getAllCategories(): ReadonlyArray<Category>;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Category } from '@/domain/Executables/Category/Category';
|
||||
import type { Script } from '@/domain/Executables/Script/Script';
|
||||
import type { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
|
||||
export interface CategoryCollectionValidationContext {
|
||||
readonly allScripts: readonly Script[];
|
||||
readonly allCategories: readonly Category[];
|
||||
readonly operatingSystem: OperatingSystem;
|
||||
}
|
||||
|
||||
export interface CategoryCollectionValidator {
|
||||
(
|
||||
context: CategoryCollectionValidationContext,
|
||||
): void;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ensurePresenceOfAtLeastOneScript } from './Rules/EnsurePresenceOfAtLeastOneScript';
|
||||
import { ensurePresenceOfAtLeastOneCategory } from './Rules/EnsurePresenceOfAtLeastOneCategory';
|
||||
import { ensureUniqueIdsAcrossExecutables } from './Rules/EnsureUniqueIdsAcrossExecutables';
|
||||
import { ensureKnownOperatingSystem } from './Rules/EnsureKnownOperatingSystem';
|
||||
import type { CategoryCollectionValidationContext, CategoryCollectionValidator } from './CategoryCollectionValidator';
|
||||
|
||||
export type CompositeCategoryCollectionValidator = CategoryCollectionValidator & {
|
||||
(
|
||||
...args: [
|
||||
...Parameters<CategoryCollectionValidator>,
|
||||
(readonly CategoryCollectionValidator[])?,
|
||||
]
|
||||
): void;
|
||||
};
|
||||
|
||||
export const validateCategoryCollection: CompositeCategoryCollectionValidator = (
|
||||
context: CategoryCollectionValidationContext,
|
||||
validators: readonly CategoryCollectionValidator[] = DefaultValidators,
|
||||
) => {
|
||||
if (!validators.length) {
|
||||
throw new Error('No validators provided.');
|
||||
}
|
||||
for (const validate of validators) {
|
||||
validate(context);
|
||||
}
|
||||
};
|
||||
|
||||
const DefaultValidators: readonly CategoryCollectionValidator[] = [
|
||||
ensureKnownOperatingSystem,
|
||||
ensurePresenceOfAtLeastOneScript,
|
||||
ensurePresenceOfAtLeastOneCategory,
|
||||
ensureUniqueIdsAcrossExecutables,
|
||||
];
|
||||
@@ -0,0 +1,9 @@
|
||||
import { assertInRange } from '@/application/Common/Enum';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import type { CategoryCollectionValidator } from '../CategoryCollectionValidator';
|
||||
|
||||
export const ensureKnownOperatingSystem: CategoryCollectionValidator = (
|
||||
context,
|
||||
) => {
|
||||
assertInRange(context.operatingSystem, OperatingSystem);
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { getEnumValues } from '@/application/Common/Enum';
|
||||
import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel';
|
||||
import type { Script } from '@/domain/Executables/Script/Script';
|
||||
import type { CategoryCollectionValidator } from '../CategoryCollectionValidator';
|
||||
|
||||
export const ensurePresenceOfAllRecommendationLevels: CategoryCollectionValidator = (
|
||||
context,
|
||||
) => {
|
||||
const unrepresentedRecommendationLevels = getUnrepresentedRecommendationLevels(
|
||||
context.allScripts,
|
||||
);
|
||||
if (unrepresentedRecommendationLevels.length === 0) {
|
||||
return;
|
||||
}
|
||||
const formattedRecommendationLevels = unrepresentedRecommendationLevels
|
||||
.map((level) => getDisplayName(level))
|
||||
.join(', ');
|
||||
throw new Error(`Missing recommendation levels: ${formattedRecommendationLevels}.`);
|
||||
};
|
||||
|
||||
function getUnrepresentedRecommendationLevels(
|
||||
scripts: readonly Script[],
|
||||
): (RecommendationLevel | undefined)[] {
|
||||
const expectedLevels = [
|
||||
undefined,
|
||||
...getEnumValues(RecommendationLevel),
|
||||
];
|
||||
return expectedLevels.filter(
|
||||
(level) => scripts.every((script) => script.level !== level),
|
||||
);
|
||||
}
|
||||
|
||||
function getDisplayName(level: RecommendationLevel | undefined): string {
|
||||
return level === undefined ? 'None' : RecommendationLevel[level];
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { CategoryCollectionValidator } from '../CategoryCollectionValidator';
|
||||
|
||||
export const ensurePresenceOfAtLeastOneCategory: CategoryCollectionValidator = (
|
||||
context,
|
||||
) => {
|
||||
if (!context.allCategories.length) {
|
||||
throw new Error('Collection must have at least one category');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { CategoryCollectionValidator } from '../CategoryCollectionValidator';
|
||||
|
||||
export const ensurePresenceOfAtLeastOneScript: CategoryCollectionValidator = (
|
||||
context,
|
||||
) => {
|
||||
if (!context.allScripts.length) {
|
||||
throw new Error('Collection must have at least one script');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Identifiable } from '@/domain/Executables/Identifiable';
|
||||
import type { CategoryCollectionValidator } from '../CategoryCollectionValidator';
|
||||
|
||||
export const ensureUniqueIdsAcrossExecutables: CategoryCollectionValidator = (
|
||||
context,
|
||||
) => {
|
||||
const allExecutables: readonly Identifiable[] = [
|
||||
...context.allCategories,
|
||||
...context.allScripts,
|
||||
];
|
||||
ensureNoDuplicateIds(allExecutables);
|
||||
};
|
||||
|
||||
function ensureNoDuplicateIds(
|
||||
executables: readonly Identifiable[],
|
||||
) {
|
||||
const duplicateExecutables = getExecutablesWithDuplicateIds(executables);
|
||||
if (duplicateExecutables.length === 0) {
|
||||
return;
|
||||
}
|
||||
const formattedDuplicateIds = duplicateExecutables.map(
|
||||
(executable) => `"${executable.executableId}"`,
|
||||
).join(', ');
|
||||
throw new Error(`Duplicate executable IDs found: ${formattedDuplicateIds}`);
|
||||
}
|
||||
|
||||
function getExecutablesWithDuplicateIds(
|
||||
executables: readonly Identifiable[],
|
||||
): Identifiable[] {
|
||||
return executables
|
||||
.filter(
|
||||
(executable, index, array) => {
|
||||
const otherIndex = array.findIndex(
|
||||
(otherExecutable) => haveIdenticalIds(executable, otherExecutable),
|
||||
);
|
||||
return otherIndex !== index;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function haveIdenticalIds(first: Identifiable, second: Identifiable): boolean {
|
||||
return first.executableId === second.executableId;
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { Script } from '../Script/Script';
|
||||
import type { Executable } from '../Executable';
|
||||
|
||||
export interface Category extends Executable<number> {
|
||||
readonly id: number;
|
||||
export interface Category extends Executable {
|
||||
readonly name: string;
|
||||
readonly subCategories: ReadonlyArray<Category>;
|
||||
readonly subcategories: ReadonlyArray<Category>;
|
||||
readonly scripts: ReadonlyArray<Script>;
|
||||
includes(script: Script): boolean;
|
||||
getAllScriptsRecursively(): ReadonlyArray<Script>;
|
||||
|
||||
@@ -1,29 +1,51 @@
|
||||
import { BaseEntity } from '@/infrastructure/Entity/BaseEntity';
|
||||
import type { Category } from './Category';
|
||||
import type { Script } from '../Script/Script';
|
||||
import type { Category } from '@/domain/Executables/Category/Category';
|
||||
import type { Script } from '@/domain/Executables/Script/Script';
|
||||
import type { ExecutableId } from '../Identifiable';
|
||||
|
||||
export class CollectionCategory extends BaseEntity<number> implements Category {
|
||||
private allSubScripts?: ReadonlyArray<Script> = undefined;
|
||||
export type CategoryFactory = (
|
||||
parameters: CategoryInitParameters,
|
||||
) => Category;
|
||||
|
||||
export interface CategoryInitParameters {
|
||||
readonly executableId: ExecutableId;
|
||||
readonly name: string;
|
||||
readonly docs: ReadonlyArray<string>;
|
||||
readonly subcategories: ReadonlyArray<Category>;
|
||||
readonly scripts: ReadonlyArray<Script>;
|
||||
}
|
||||
|
||||
export const createCategory: CategoryFactory = (
|
||||
parameters,
|
||||
) => {
|
||||
return new CollectionCategory(parameters);
|
||||
};
|
||||
|
||||
class CollectionCategory implements Category {
|
||||
public readonly executableId: ExecutableId;
|
||||
|
||||
public readonly name: string;
|
||||
|
||||
public readonly docs: ReadonlyArray<string>;
|
||||
|
||||
public readonly subCategories: ReadonlyArray<Category>;
|
||||
public readonly subcategories: ReadonlyArray<Category>;
|
||||
|
||||
public readonly scripts: ReadonlyArray<Script>;
|
||||
|
||||
private allSubScripts?: ReadonlyArray<Script> = undefined;
|
||||
|
||||
constructor(parameters: CategoryInitParameters) {
|
||||
super(parameters.id);
|
||||
validateParameters(parameters);
|
||||
this.executableId = parameters.executableId;
|
||||
this.name = parameters.name;
|
||||
this.docs = parameters.docs;
|
||||
this.subCategories = parameters.subcategories;
|
||||
this.subcategories = parameters.subcategories;
|
||||
this.scripts = parameters.scripts;
|
||||
}
|
||||
|
||||
public includes(script: Script): boolean {
|
||||
return this.getAllScriptsRecursively().some((childScript) => childScript.id === script.id);
|
||||
return this
|
||||
.getAllScriptsRecursively()
|
||||
.some((childScript) => childScript.executableId === script.executableId);
|
||||
}
|
||||
|
||||
public getAllScriptsRecursively(): readonly Script[] {
|
||||
@@ -34,22 +56,17 @@ export class CollectionCategory extends BaseEntity<number> implements Category {
|
||||
}
|
||||
}
|
||||
|
||||
export interface CategoryInitParameters {
|
||||
readonly id: number;
|
||||
readonly name: string;
|
||||
readonly docs: ReadonlyArray<string>;
|
||||
readonly subcategories: ReadonlyArray<Category>;
|
||||
readonly scripts: ReadonlyArray<Script>;
|
||||
}
|
||||
|
||||
function parseScriptsRecursively(category: Category): ReadonlyArray<Script> {
|
||||
return [
|
||||
...category.scripts,
|
||||
...category.subCategories.flatMap((c) => c.getAllScriptsRecursively()),
|
||||
...category.subcategories.flatMap((c) => c.getAllScriptsRecursively()),
|
||||
];
|
||||
}
|
||||
|
||||
function validateParameters(parameters: CategoryInitParameters) {
|
||||
if (!parameters.executableId) {
|
||||
throw new Error('missing ID');
|
||||
}
|
||||
if (!parameters.name) {
|
||||
throw new Error('missing name');
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { IEntity } from '@/infrastructure/Entity/IEntity';
|
||||
import type { Documentable } from './Documentable';
|
||||
import type { Identifiable } from './Identifiable';
|
||||
|
||||
export interface Executable<TExecutableKey>
|
||||
extends Documentable, IEntity<TExecutableKey> {
|
||||
export interface Executable
|
||||
extends Documentable, Identifiable {
|
||||
}
|
||||
|
||||
5
src/domain/Executables/Identifiable.ts
Normal file
5
src/domain/Executables/Identifiable.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type ExecutableId = string;
|
||||
|
||||
export interface Identifiable {
|
||||
readonly executableId: ExecutableId;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { Executable } from '../Executable';
|
||||
import type { Documentable } from '../Documentable';
|
||||
import type { ScriptCode } from './Code/ScriptCode';
|
||||
|
||||
export interface Script extends Executable<string>, Documentable {
|
||||
export interface Script extends Executable, Documentable {
|
||||
readonly name: string;
|
||||
readonly level?: RecommendationLevel;
|
||||
readonly code: ScriptCode;
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
import { BaseEntity } from '@/infrastructure/Entity/BaseEntity';
|
||||
import { RecommendationLevel } from './RecommendationLevel';
|
||||
import type { Script } from './Script';
|
||||
import type { ScriptCode } from './Code/ScriptCode';
|
||||
import type { Script } from './Script';
|
||||
import type { ExecutableId } from '../Identifiable';
|
||||
|
||||
export interface ScriptInitParameters {
|
||||
readonly executableId: ExecutableId;
|
||||
readonly name: string;
|
||||
readonly code: ScriptCode;
|
||||
readonly docs: ReadonlyArray<string>;
|
||||
readonly level?: RecommendationLevel;
|
||||
}
|
||||
|
||||
export type ScriptFactory = (
|
||||
parameters: ScriptInitParameters,
|
||||
) => Script;
|
||||
|
||||
export const createScript: ScriptFactory = (parameters) => {
|
||||
return new CollectionScript(parameters);
|
||||
};
|
||||
|
||||
class CollectionScript implements Script {
|
||||
public readonly executableId: ExecutableId;
|
||||
|
||||
export class CollectionScript extends BaseEntity<string> implements Script {
|
||||
public readonly name: string;
|
||||
|
||||
public readonly code: ScriptCode;
|
||||
@@ -13,7 +31,7 @@ export class CollectionScript extends BaseEntity<string> implements Script {
|
||||
public readonly level?: RecommendationLevel;
|
||||
|
||||
constructor(parameters: ScriptInitParameters) {
|
||||
super(parameters.name);
|
||||
this.executableId = parameters.executableId;
|
||||
this.name = parameters.name;
|
||||
this.code = parameters.code;
|
||||
this.docs = parameters.docs;
|
||||
@@ -26,13 +44,6 @@ export class CollectionScript extends BaseEntity<string> implements Script {
|
||||
}
|
||||
}
|
||||
|
||||
export interface ScriptInitParameters {
|
||||
readonly name: string;
|
||||
readonly code: ScriptCode;
|
||||
readonly docs: ReadonlyArray<string>;
|
||||
readonly level?: RecommendationLevel;
|
||||
}
|
||||
|
||||
function validateLevel(level?: RecommendationLevel) {
|
||||
if (level !== undefined && !(level in RecommendationLevel)) {
|
||||
throw new Error(`invalid level: ${level}`);
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ICategoryCollection } from './ICategoryCollection';
|
||||
import type { ICategoryCollection } from './Collection/ICategoryCollection';
|
||||
import type { ProjectDetails } from './Project/ProjectDetails';
|
||||
import type { OperatingSystem } from './OperatingSystem';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user