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:
undergroundwires
2024-08-03 16:54:14 +02:00
parent 6fbc81675f
commit ded55a66d6
124 changed files with 2286 additions and 1331 deletions

View File

@@ -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 {

View File

@@ -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],

View File

@@ -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>;
}

View File

@@ -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;
}

View File

@@ -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,
];

View File

@@ -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);
};

View File

@@ -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];
}

View File

@@ -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');
}
};

View File

@@ -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');
}
};

View File

@@ -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;
}

View File

@@ -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>;

View File

@@ -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');
}

View File

@@ -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 {
}

View File

@@ -0,0 +1,5 @@
export type ExecutableId = string;
export interface Identifiable {
readonly executableId: ExecutableId;
}

View File

@@ -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;

View File

@@ -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}`);

View File

@@ -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';