@nestjs-dash/translation 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +131 -0
- package/dist/constants.d.ts +3 -0
- package/dist/constants.js +3 -0
- package/dist/context/translation-context.middleware.d.ts +12 -0
- package/dist/context/translation-context.middleware.js +66 -0
- package/dist/context/translation-context.service.d.ts +17 -0
- package/dist/context/translation-context.service.js +52 -0
- package/dist/decorators/locale.decorator.d.ts +1 -0
- package/dist/decorators/locale.decorator.js +6 -0
- package/dist/decorators/response.decorator.d.ts +7 -0
- package/dist/decorators/response.decorator.js +7 -0
- package/dist/decorators/translatable-column.decorator.d.ts +3 -0
- package/dist/decorators/translatable-column.decorator.js +13 -0
- package/dist/decorators/translatable-property.decorator.d.ts +13 -0
- package/dist/decorators/translatable-property.decorator.js +20 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +22 -0
- package/dist/interceptors/translation.interceptor.d.ts +12 -0
- package/dist/interceptors/translation.interceptor.js +43 -0
- package/dist/metadata/translation-metadata.storage.d.ts +4 -0
- package/dist/metadata/translation-metadata.storage.js +22 -0
- package/dist/migration/cli-args.d.ts +12 -0
- package/dist/migration/cli-args.js +38 -0
- package/dist/migration/cli.d.ts +2 -0
- package/dist/migration/cli.js +66 -0
- package/dist/migration/migration-dialect.adapter.d.ts +12 -0
- package/dist/migration/migration-dialect.adapter.js +1 -0
- package/dist/migration/migration-template.d.ts +1 -0
- package/dist/migration/migration-template.js +29 -0
- package/dist/migration/mysql-migration.adapter.d.ts +7 -0
- package/dist/migration/mysql-migration.adapter.js +17 -0
- package/dist/migration/postgres-migration.adapter.d.ts +7 -0
- package/dist/migration/postgres-migration.adapter.js +17 -0
- package/dist/migration/translatable-migration.generator.d.ts +14 -0
- package/dist/migration/translatable-migration.generator.js +173 -0
- package/dist/query/mysql-query.adapter.d.ts +6 -0
- package/dist/query/mysql-query.adapter.js +12 -0
- package/dist/query/postgres-query.adapter.d.ts +6 -0
- package/dist/query/postgres-query.adapter.js +12 -0
- package/dist/query/translation-query.adapter.d.ts +5 -0
- package/dist/query/translation-query.adapter.js +1 -0
- package/dist/query/translation-query.service.d.ts +15 -0
- package/dist/query/translation-query.service.js +69 -0
- package/dist/services/translation.service.d.ts +15 -0
- package/dist/services/translation.service.js +109 -0
- package/dist/testing/fixtures.d.ts +21 -0
- package/dist/testing/fixtures.js +53 -0
- package/dist/transformers/translation-response.transformer.d.ts +11 -0
- package/dist/transformers/translation-response.transformer.js +75 -0
- package/dist/translatable.module.d.ts +10 -0
- package/dist/translatable.module.js +108 -0
- package/dist/types.d.ts +100 -0
- package/dist/types.js +1 -0
- package/package.json +50 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function renderMigrationFile(className: string, upStatements: string[], downStatements: string[], driver?: string): string;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export function renderMigrationFile(className, upStatements, downStatements, driver) {
|
|
2
|
+
const mysqlWarning = driver === 'mysql'
|
|
3
|
+
? '// Note: MySQL DDL statements are not transactional. If this migration fails partway through, verify the table/column state manually before re-running.\n'
|
|
4
|
+
: '';
|
|
5
|
+
return `import { MigrationInterface, QueryRunner } from 'typeorm';
|
|
6
|
+
|
|
7
|
+
${mysqlWarning}export class ${className} implements MigrationInterface {
|
|
8
|
+
name = '${className}';
|
|
9
|
+
|
|
10
|
+
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
11
|
+
${renderStatements(upStatements)}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
15
|
+
${renderStatements(downStatements)}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
`;
|
|
19
|
+
}
|
|
20
|
+
function renderStatements(statements) {
|
|
21
|
+
if (statements.length === 0)
|
|
22
|
+
return '';
|
|
23
|
+
return statements
|
|
24
|
+
.map((statement) => ` await queryRunner.query(\`${escapeForTemplateLiteral(statement)}\`);`)
|
|
25
|
+
.join('\n');
|
|
26
|
+
}
|
|
27
|
+
function escapeForTemplateLiteral(sql) {
|
|
28
|
+
return sql.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
|
|
29
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { MigrationDialectAdapter } from './migration-dialect.adapter.js';
|
|
2
|
+
export declare class MysqlMigrationDialectAdapter implements MigrationDialectAdapter {
|
|
3
|
+
readonly driver = "mysql";
|
|
4
|
+
buildJsonExpression(columnRef: string, locale: string): string;
|
|
5
|
+
extractJsonExpression(columnRef: string, locale: string): string;
|
|
6
|
+
setNotNullExpression(qualifiedTable: string, columnRef: string, columnType: string): string;
|
|
7
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export class MysqlMigrationDialectAdapter {
|
|
2
|
+
constructor() {
|
|
3
|
+
this.driver = 'mysql';
|
|
4
|
+
}
|
|
5
|
+
buildJsonExpression(columnRef, locale) {
|
|
6
|
+
return `JSON_OBJECT('${escapeLiteral(locale)}', ${columnRef})`;
|
|
7
|
+
}
|
|
8
|
+
extractJsonExpression(columnRef, locale) {
|
|
9
|
+
return `JSON_UNQUOTE(JSON_EXTRACT(${columnRef}, '$.${escapeLiteral(locale)}'))`;
|
|
10
|
+
}
|
|
11
|
+
setNotNullExpression(qualifiedTable, columnRef, columnType) {
|
|
12
|
+
return `ALTER TABLE ${qualifiedTable} MODIFY COLUMN ${columnRef} ${columnType} NOT NULL`;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function escapeLiteral(value) {
|
|
16
|
+
return value.replace(/'/g, "''");
|
|
17
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { MigrationDialectAdapter } from './migration-dialect.adapter.js';
|
|
2
|
+
export declare class PostgresMigrationDialectAdapter implements MigrationDialectAdapter {
|
|
3
|
+
readonly driver = "postgres";
|
|
4
|
+
buildJsonExpression(columnRef: string, locale: string): string;
|
|
5
|
+
extractJsonExpression(columnRef: string, locale: string): string;
|
|
6
|
+
setNotNullExpression(qualifiedTable: string, columnRef: string): string;
|
|
7
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export class PostgresMigrationDialectAdapter {
|
|
2
|
+
constructor() {
|
|
3
|
+
this.driver = 'postgres';
|
|
4
|
+
}
|
|
5
|
+
buildJsonExpression(columnRef, locale) {
|
|
6
|
+
return `jsonb_build_object('${escapeLiteral(locale)}', ${columnRef})`;
|
|
7
|
+
}
|
|
8
|
+
extractJsonExpression(columnRef, locale) {
|
|
9
|
+
return `${columnRef} ->> '${escapeLiteral(locale)}'`;
|
|
10
|
+
}
|
|
11
|
+
setNotNullExpression(qualifiedTable, columnRef) {
|
|
12
|
+
return `ALTER TABLE ${qualifiedTable} ALTER COLUMN ${columnRef} SET NOT NULL`;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function escapeLiteral(value) {
|
|
16
|
+
return value.replace(/'/g, "''");
|
|
17
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { TranslatableMigrationOptions, TranslatableMigrationPlan } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Diffs entities decorated with `@TranslatableColumn()` against the real
|
|
4
|
+
* database schema and computes the migration needed to bring the two in
|
|
5
|
+
* sync without losing data. Read-only: performs no writes to the database
|
|
6
|
+
* or the filesystem.
|
|
7
|
+
*/
|
|
8
|
+
export declare function planTranslatableMigrations(options: TranslatableMigrationOptions): Promise<TranslatableMigrationPlan>;
|
|
9
|
+
/**
|
|
10
|
+
* Plans the migration (see {@link planTranslatableMigrations}) and, unless
|
|
11
|
+
* `dryRun` is set or there is nothing to migrate, writes it to a TypeORM
|
|
12
|
+
* migration file under `outputDir`.
|
|
13
|
+
*/
|
|
14
|
+
export declare function generateTranslatableMigrations(options: TranslatableMigrationOptions): Promise<TranslatableMigrationPlan>;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { getTranslatableColumns } from '../metadata/translation-metadata.storage.js';
|
|
4
|
+
import { MysqlMigrationDialectAdapter } from './mysql-migration.adapter.js';
|
|
5
|
+
import { PostgresMigrationDialectAdapter } from './postgres-migration.adapter.js';
|
|
6
|
+
import { renderMigrationFile } from './migration-template.js';
|
|
7
|
+
const adapters = [
|
|
8
|
+
new PostgresMigrationDialectAdapter(),
|
|
9
|
+
new MysqlMigrationDialectAdapter(),
|
|
10
|
+
];
|
|
11
|
+
/**
|
|
12
|
+
* Diffs entities decorated with `@TranslatableColumn()` against the real
|
|
13
|
+
* database schema and computes the migration needed to bring the two in
|
|
14
|
+
* sync without losing data. Read-only: performs no writes to the database
|
|
15
|
+
* or the filesystem.
|
|
16
|
+
*/
|
|
17
|
+
export async function planTranslatableMigrations(options) {
|
|
18
|
+
const { dataSource } = options;
|
|
19
|
+
const adapter = getAdapter(normalizeDriverType(dataSource.options.type));
|
|
20
|
+
const initializedByUs = !dataSource.isInitialized;
|
|
21
|
+
if (initializedByUs)
|
|
22
|
+
await dataSource.initialize();
|
|
23
|
+
const queryRunner = dataSource.createQueryRunner();
|
|
24
|
+
await queryRunner.connect();
|
|
25
|
+
try {
|
|
26
|
+
const entityMetadatas = resolveEntityMetadatas(dataSource, options.entities);
|
|
27
|
+
const columns = [];
|
|
28
|
+
const upStatements = [];
|
|
29
|
+
const downStatements = [];
|
|
30
|
+
for (const entityMetadata of sortByTableName(entityMetadatas)) {
|
|
31
|
+
if (typeof entityMetadata.target === 'string')
|
|
32
|
+
continue;
|
|
33
|
+
const translatableColumns = sortByPropertyKey(getTranslatableColumns(entityMetadata.target));
|
|
34
|
+
if (translatableColumns.length === 0)
|
|
35
|
+
continue;
|
|
36
|
+
const table = await queryRunner.getTable(entityMetadata.tableName);
|
|
37
|
+
if (!table) {
|
|
38
|
+
for (const columnMeta of translatableColumns) {
|
|
39
|
+
columns.push({
|
|
40
|
+
table: entityMetadata.tableName,
|
|
41
|
+
column: String(columnMeta.propertyKey),
|
|
42
|
+
action: 'skip',
|
|
43
|
+
reason: 'table does not exist yet',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const qualifiedTable = dataSource.driver.escape(entityMetadata.tableName);
|
|
49
|
+
for (const columnMeta of translatableColumns) {
|
|
50
|
+
const propertyName = String(columnMeta.propertyKey);
|
|
51
|
+
const entityColumn = entityMetadata.findColumnWithPropertyName(propertyName);
|
|
52
|
+
if (!entityColumn)
|
|
53
|
+
continue;
|
|
54
|
+
const dbColumnName = entityColumn.databaseName;
|
|
55
|
+
const columnRef = dataSource.driver.escape(dbColumnName);
|
|
56
|
+
const declaredType = dataSource.driver.normalizeType(entityColumn);
|
|
57
|
+
const existingColumn = table.findColumnByName(dbColumnName);
|
|
58
|
+
if (!existingColumn) {
|
|
59
|
+
// Adding a NOT NULL column outright fails on Postgres/MySQL when the
|
|
60
|
+
// table already has rows, unless a DEFAULT lets existing rows be
|
|
61
|
+
// backfilled automatically. So the column is always added nullable
|
|
62
|
+
// first, and NOT NULL is only enforced afterwards when a default
|
|
63
|
+
// guarantees every row already has a value.
|
|
64
|
+
const defaultExpr = dataSource.driver.normalizeDefault(entityColumn);
|
|
65
|
+
const defaultClause = defaultExpr !== undefined ? ` DEFAULT ${defaultExpr}` : '';
|
|
66
|
+
const canEnforceNotNull = !entityColumn.isNullable && defaultExpr !== undefined;
|
|
67
|
+
upStatements.push(`ALTER TABLE ${qualifiedTable} ADD ${columnRef} ${declaredType}${defaultClause}`);
|
|
68
|
+
if (canEnforceNotNull) {
|
|
69
|
+
upStatements.push(adapter.setNotNullExpression(qualifiedTable, columnRef, declaredType));
|
|
70
|
+
}
|
|
71
|
+
downStatements.push(`ALTER TABLE ${qualifiedTable} DROP COLUMN ${columnRef}`);
|
|
72
|
+
columns.push({
|
|
73
|
+
table: entityMetadata.tableName,
|
|
74
|
+
column: dbColumnName,
|
|
75
|
+
action: 'create',
|
|
76
|
+
...(!entityColumn.isNullable && !canEnforceNotNull
|
|
77
|
+
? {
|
|
78
|
+
reason: 'added as nullable: declaring NOT NULL without a column default cannot be enforced safely on a table that may already have rows',
|
|
79
|
+
}
|
|
80
|
+
: {}),
|
|
81
|
+
});
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (existingColumn.type.toLowerCase() === declaredType.toLowerCase()) {
|
|
85
|
+
columns.push({
|
|
86
|
+
table: entityMetadata.tableName,
|
|
87
|
+
column: dbColumnName,
|
|
88
|
+
action: 'skip',
|
|
89
|
+
reason: 'already the declared JSON type',
|
|
90
|
+
});
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const sourceLocale = columnMeta.sourceLocale ?? options.defaultLocale;
|
|
94
|
+
const tmpColumnRef = dataSource.driver.escape(`${dbColumnName}__translatable_tmp`);
|
|
95
|
+
const originalFullType = dataSource.driver.createFullType(existingColumn);
|
|
96
|
+
// The temp column is always added nullable, backfilled, then (if the
|
|
97
|
+
// declared/original column is NOT NULL) locked down afterwards -- an
|
|
98
|
+
// ALTER ... ADD COLUMN ... NOT NULL fails outright on a non-empty
|
|
99
|
+
// table without a default.
|
|
100
|
+
upStatements.push(`ALTER TABLE ${qualifiedTable} ADD ${tmpColumnRef} ${declaredType}`, `UPDATE ${qualifiedTable} SET ${tmpColumnRef} = ${adapter.buildJsonExpression(columnRef, sourceLocale)}`);
|
|
101
|
+
if (!entityColumn.isNullable) {
|
|
102
|
+
upStatements.push(adapter.setNotNullExpression(qualifiedTable, tmpColumnRef, declaredType));
|
|
103
|
+
}
|
|
104
|
+
upStatements.push(`ALTER TABLE ${qualifiedTable} DROP COLUMN ${columnRef}`, `ALTER TABLE ${qualifiedTable} RENAME COLUMN ${tmpColumnRef} TO ${columnRef}`);
|
|
105
|
+
downStatements.push(`ALTER TABLE ${qualifiedTable} ADD ${tmpColumnRef} ${originalFullType}`, `UPDATE ${qualifiedTable} SET ${tmpColumnRef} = ${adapter.extractJsonExpression(columnRef, sourceLocale)}`);
|
|
106
|
+
if (!existingColumn.isNullable) {
|
|
107
|
+
downStatements.push(adapter.setNotNullExpression(qualifiedTable, tmpColumnRef, originalFullType));
|
|
108
|
+
}
|
|
109
|
+
downStatements.push(`ALTER TABLE ${qualifiedTable} DROP COLUMN ${columnRef}`, `ALTER TABLE ${qualifiedTable} RENAME COLUMN ${tmpColumnRef} TO ${columnRef}`);
|
|
110
|
+
columns.push({
|
|
111
|
+
table: entityMetadata.tableName,
|
|
112
|
+
column: dbColumnName,
|
|
113
|
+
action: 'convert',
|
|
114
|
+
sourceLocale,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const timestamp = Date.now();
|
|
119
|
+
const baseName = options.name ?? 'TranslatableColumns';
|
|
120
|
+
return {
|
|
121
|
+
className: `${baseName}${timestamp}`,
|
|
122
|
+
fileName: `${timestamp}-${baseName}.ts`,
|
|
123
|
+
upStatements,
|
|
124
|
+
downStatements,
|
|
125
|
+
columns,
|
|
126
|
+
written: false,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
await queryRunner.release();
|
|
131
|
+
if (initializedByUs)
|
|
132
|
+
await dataSource.destroy();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Plans the migration (see {@link planTranslatableMigrations}) and, unless
|
|
137
|
+
* `dryRun` is set or there is nothing to migrate, writes it to a TypeORM
|
|
138
|
+
* migration file under `outputDir`.
|
|
139
|
+
*/
|
|
140
|
+
export async function generateTranslatableMigrations(options) {
|
|
141
|
+
const plan = await planTranslatableMigrations(options);
|
|
142
|
+
if (options.dryRun || plan.upStatements.length === 0)
|
|
143
|
+
return plan;
|
|
144
|
+
const driverType = normalizeDriverType(options.dataSource.options.type);
|
|
145
|
+
const outputDir = options.outputDir ?? './migrations';
|
|
146
|
+
const filePath = path.join(outputDir, plan.fileName);
|
|
147
|
+
const contents = renderMigrationFile(plan.className, plan.upStatements, plan.downStatements, driverType);
|
|
148
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
149
|
+
await fs.writeFile(filePath, contents, 'utf8');
|
|
150
|
+
return { ...plan, filePath, written: true };
|
|
151
|
+
}
|
|
152
|
+
function resolveEntityMetadatas(dataSource, entities) {
|
|
153
|
+
if (!entities)
|
|
154
|
+
return dataSource.entityMetadatas;
|
|
155
|
+
const targets = new Set(entities);
|
|
156
|
+
return dataSource.entityMetadatas.filter((metadata) => targets.has(metadata.target));
|
|
157
|
+
}
|
|
158
|
+
function sortByTableName(entityMetadatas) {
|
|
159
|
+
return [...entityMetadatas].sort((a, b) => a.tableName.localeCompare(b.tableName));
|
|
160
|
+
}
|
|
161
|
+
function sortByPropertyKey(columns) {
|
|
162
|
+
return [...columns].sort((a, b) => String(a.propertyKey).localeCompare(String(b.propertyKey)));
|
|
163
|
+
}
|
|
164
|
+
function normalizeDriverType(type) {
|
|
165
|
+
return type === 'mariadb' ? 'mysql' : type;
|
|
166
|
+
}
|
|
167
|
+
function getAdapter(driver) {
|
|
168
|
+
const adapter = adapters.find((candidate) => candidate.driver === driver);
|
|
169
|
+
if (!adapter) {
|
|
170
|
+
throw new Error(`Translatable migration generation is not supported for driver "${driver}".`);
|
|
171
|
+
}
|
|
172
|
+
return adapter;
|
|
173
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { LocalizedExpressionOptions } from '../types.js';
|
|
2
|
+
import type { TranslationQueryAdapter } from './translation-query.adapter.js';
|
|
3
|
+
export declare class MysqlTranslationQueryAdapter implements TranslationQueryAdapter {
|
|
4
|
+
readonly driver = "mysql";
|
|
5
|
+
localizedExpression(options: Required<LocalizedExpressionOptions>, parameters: Record<string, unknown>, paramPrefix: string): string;
|
|
6
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export class MysqlTranslationQueryAdapter {
|
|
2
|
+
constructor() {
|
|
3
|
+
this.driver = 'mysql';
|
|
4
|
+
}
|
|
5
|
+
localizedExpression(options, parameters, paramPrefix) {
|
|
6
|
+
const localeParam = `${paramPrefix}LocalePath`;
|
|
7
|
+
const fallbackParam = `${paramPrefix}FallbackLocalePath`;
|
|
8
|
+
parameters[localeParam] = `$.${options.locale}`;
|
|
9
|
+
parameters[fallbackParam] = `$.${options.fallbackLocale}`;
|
|
10
|
+
return `COALESCE(JSON_UNQUOTE(JSON_EXTRACT(${options.alias}.${options.property}, :${localeParam})), JSON_UNQUOTE(JSON_EXTRACT(${options.alias}.${options.property}, :${fallbackParam})))`;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { LocalizedExpressionOptions } from '../types.js';
|
|
2
|
+
import type { TranslationQueryAdapter } from './translation-query.adapter.js';
|
|
3
|
+
export declare class PostgresTranslationQueryAdapter implements TranslationQueryAdapter {
|
|
4
|
+
readonly driver = "postgres";
|
|
5
|
+
localizedExpression(options: Required<LocalizedExpressionOptions>, parameters: Record<string, unknown>, paramPrefix: string): string;
|
|
6
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export class PostgresTranslationQueryAdapter {
|
|
2
|
+
constructor() {
|
|
3
|
+
this.driver = 'postgres';
|
|
4
|
+
}
|
|
5
|
+
localizedExpression(options, parameters, paramPrefix) {
|
|
6
|
+
const localeParam = `${paramPrefix}Locale`;
|
|
7
|
+
const fallbackParam = `${paramPrefix}FallbackLocale`;
|
|
8
|
+
parameters[localeParam] = options.locale;
|
|
9
|
+
parameters[fallbackParam] = options.fallbackLocale;
|
|
10
|
+
return `COALESCE(${options.alias}.${options.property} ->> :${localeParam}, ${options.alias}.${options.property} ->> :${fallbackParam})`;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { LocalizedExpressionOptions } from '../types.js';
|
|
2
|
+
export interface TranslationQueryAdapter {
|
|
3
|
+
readonly driver: string;
|
|
4
|
+
localizedExpression(options: Required<LocalizedExpressionOptions>, parameters: Record<string, unknown>, paramPrefix: string): string;
|
|
5
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
|
2
|
+
import { TranslationContextService } from '../context/translation-context.service.js';
|
|
3
|
+
import type { LocalizedExpressionOptions } from '../types.js';
|
|
4
|
+
export declare class TranslationQueryService {
|
|
5
|
+
private readonly context;
|
|
6
|
+
private readonly adapters;
|
|
7
|
+
private paramCounter;
|
|
8
|
+
constructor(context: TranslationContextService);
|
|
9
|
+
addSelect<Entity extends ObjectLiteral>(builder: SelectQueryBuilder<Entity>, options: LocalizedExpressionOptions, outputAlias: string): SelectQueryBuilder<Entity>;
|
|
10
|
+
orderBy<Entity extends ObjectLiteral>(builder: SelectQueryBuilder<Entity>, options: LocalizedExpressionOptions, direction?: 'ASC' | 'DESC'): SelectQueryBuilder<Entity>;
|
|
11
|
+
private nextParamPrefix;
|
|
12
|
+
private withDefaults;
|
|
13
|
+
private getAdapter;
|
|
14
|
+
private assertIdentifier;
|
|
15
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
8
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
9
|
+
};
|
|
10
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
11
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
12
|
+
};
|
|
13
|
+
import { Inject, Injectable } from '@nestjs/common';
|
|
14
|
+
import { TranslationContextService } from '../context/translation-context.service.js';
|
|
15
|
+
import { MysqlTranslationQueryAdapter } from './mysql-query.adapter.js';
|
|
16
|
+
import { PostgresTranslationQueryAdapter } from './postgres-query.adapter.js';
|
|
17
|
+
let TranslationQueryService = class TranslationQueryService {
|
|
18
|
+
constructor(context) {
|
|
19
|
+
this.context = context;
|
|
20
|
+
this.adapters = [
|
|
21
|
+
new PostgresTranslationQueryAdapter(),
|
|
22
|
+
new MysqlTranslationQueryAdapter(),
|
|
23
|
+
];
|
|
24
|
+
this.paramCounter = 0;
|
|
25
|
+
}
|
|
26
|
+
addSelect(builder, options, outputAlias) {
|
|
27
|
+
const parameters = {};
|
|
28
|
+
const adapter = this.getAdapter(builder.connection.options.type);
|
|
29
|
+
const expression = adapter.localizedExpression(this.withDefaults(options), parameters, this.nextParamPrefix(options));
|
|
30
|
+
return builder.addSelect(expression, outputAlias).setParameters(parameters);
|
|
31
|
+
}
|
|
32
|
+
orderBy(builder, options, direction = 'ASC') {
|
|
33
|
+
const parameters = {};
|
|
34
|
+
const adapter = this.getAdapter(builder.connection.options.type);
|
|
35
|
+
const expression = adapter.localizedExpression(this.withDefaults(options), parameters, this.nextParamPrefix(options));
|
|
36
|
+
return builder.addOrderBy(expression, direction).setParameters(parameters);
|
|
37
|
+
}
|
|
38
|
+
nextParamPrefix(options) {
|
|
39
|
+
return `translation_${options.alias}_${options.property}_${this.paramCounter++}`;
|
|
40
|
+
}
|
|
41
|
+
withDefaults(options) {
|
|
42
|
+
this.assertIdentifier(options.alias);
|
|
43
|
+
this.assertIdentifier(options.property);
|
|
44
|
+
return {
|
|
45
|
+
...options,
|
|
46
|
+
locale: options.locale ?? this.context.locale,
|
|
47
|
+
fallbackLocale: options.fallbackLocale ?? this.context.fallbackLocale,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
getAdapter(driver) {
|
|
51
|
+
const normalized = driver === 'mariadb' ? 'mysql' : driver;
|
|
52
|
+
const adapter = this.adapters.find((candidate) => candidate.driver === normalized);
|
|
53
|
+
if (!adapter) {
|
|
54
|
+
throw new Error(`Translation SQL expressions are not supported for driver "${driver}".`);
|
|
55
|
+
}
|
|
56
|
+
return adapter;
|
|
57
|
+
}
|
|
58
|
+
assertIdentifier(identifier) {
|
|
59
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
|
|
60
|
+
throw new Error(`Unsafe SQL identifier "${identifier}".`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
TranslationQueryService = __decorate([
|
|
65
|
+
Injectable(),
|
|
66
|
+
__param(0, Inject(TranslationContextService)),
|
|
67
|
+
__metadata("design:paramtypes", [TranslationContextService])
|
|
68
|
+
], TranslationQueryService);
|
|
69
|
+
export { TranslationQueryService };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { TranslationContextService } from '../context/translation-context.service.js';
|
|
2
|
+
import type { TranslatableModuleOptions, TranslationFallbackStrategy, TranslationMap } from '../types.js';
|
|
3
|
+
export declare class TranslationService {
|
|
4
|
+
private readonly options;
|
|
5
|
+
private readonly context;
|
|
6
|
+
constructor(options: TranslatableModuleOptions, context: TranslationContextService);
|
|
7
|
+
resolve<T>(value: TranslationMap<T> | T | null | undefined, locale?: string, fallbackLocale?: string, strategy?: TranslationFallbackStrategy): T | null;
|
|
8
|
+
set<T extends object, V>(entity: T, property: keyof T, locale: string, value: V | null): T;
|
|
9
|
+
setMany<T extends object, V>(entity: T, property: keyof T, values: TranslationMap<V>): T;
|
|
10
|
+
remove<T extends object>(entity: T, property: keyof T, locale: string): T;
|
|
11
|
+
private handleMissing;
|
|
12
|
+
private assertSupportedLocale;
|
|
13
|
+
private assertTranslatable;
|
|
14
|
+
private assertTranslationMap;
|
|
15
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
8
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
9
|
+
};
|
|
10
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
11
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
12
|
+
};
|
|
13
|
+
import { Inject, Injectable } from '@nestjs/common';
|
|
14
|
+
import { TRANSLATABLE_MODULE_OPTIONS } from '../constants.js';
|
|
15
|
+
import { TranslationContextService } from '../context/translation-context.service.js';
|
|
16
|
+
import { getTranslatableColumns } from '../metadata/translation-metadata.storage.js';
|
|
17
|
+
let TranslationService = class TranslationService {
|
|
18
|
+
constructor(options, context) {
|
|
19
|
+
this.options = options;
|
|
20
|
+
this.context = context;
|
|
21
|
+
}
|
|
22
|
+
resolve(value, locale = this.context.locale, fallbackLocale = this.context.fallbackLocale, strategy = this.options.fallbackStrategy ?? 'default-locale') {
|
|
23
|
+
if (value == null)
|
|
24
|
+
return null;
|
|
25
|
+
if (typeof value !== 'object' || Array.isArray(value))
|
|
26
|
+
return value;
|
|
27
|
+
const translations = value;
|
|
28
|
+
const baseLocale = locale.split('-')[0];
|
|
29
|
+
const candidates = [
|
|
30
|
+
locale,
|
|
31
|
+
baseLocale !== locale ? baseLocale : undefined,
|
|
32
|
+
fallbackLocale,
|
|
33
|
+
this.options.defaultLocale,
|
|
34
|
+
].filter((item) => Boolean(item));
|
|
35
|
+
for (const candidate of candidates) {
|
|
36
|
+
const translated = translations[candidate];
|
|
37
|
+
if (translated != null)
|
|
38
|
+
return translated;
|
|
39
|
+
}
|
|
40
|
+
return this.handleMissing(translations, strategy, locale);
|
|
41
|
+
}
|
|
42
|
+
set(entity, property, locale, value) {
|
|
43
|
+
this.assertSupportedLocale(locale);
|
|
44
|
+
this.assertTranslatable(entity, property);
|
|
45
|
+
const existing = this.assertTranslationMap(entity, property);
|
|
46
|
+
const translations = existing ? { ...existing } : {};
|
|
47
|
+
Object.assign(translations, { [locale]: value });
|
|
48
|
+
entity[property] = translations;
|
|
49
|
+
return entity;
|
|
50
|
+
}
|
|
51
|
+
setMany(entity, property, values) {
|
|
52
|
+
for (const [locale, value] of Object.entries(values)) {
|
|
53
|
+
this.set(entity, property, locale, value);
|
|
54
|
+
}
|
|
55
|
+
return entity;
|
|
56
|
+
}
|
|
57
|
+
remove(entity, property, locale) {
|
|
58
|
+
this.assertTranslatable(entity, property);
|
|
59
|
+
const current = this.assertTranslationMap(entity, property);
|
|
60
|
+
if (current) {
|
|
61
|
+
const copy = { ...current };
|
|
62
|
+
delete copy[locale];
|
|
63
|
+
entity[property] = copy;
|
|
64
|
+
}
|
|
65
|
+
return entity;
|
|
66
|
+
}
|
|
67
|
+
handleMissing(translations, strategy, locale) {
|
|
68
|
+
if (strategy === 'first-available') {
|
|
69
|
+
return Object.values(translations).find((item) => item != null) ?? null;
|
|
70
|
+
}
|
|
71
|
+
if (strategy === 'throw') {
|
|
72
|
+
throw new Error(`No translation is available for locale "${locale}".`);
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
assertSupportedLocale(locale) {
|
|
77
|
+
if (this.options.strictLocales !== false && !this.options.supportedLocales.includes(locale)) {
|
|
78
|
+
throw new Error(`Unsupported locale "${locale}".`);
|
|
79
|
+
}
|
|
80
|
+
if (locale.includes('.') || locale.includes('$')) {
|
|
81
|
+
throw new Error(`Locale "${locale}" contains unsafe database path characters.`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
assertTranslatable(entity, property) {
|
|
85
|
+
const fields = getTranslatableColumns(entity.constructor);
|
|
86
|
+
if (!fields.some((field) => field.propertyKey === property)) {
|
|
87
|
+
throw new Error(`Property "${String(property)}" is not translatable.`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
assertTranslationMap(entity, property) {
|
|
91
|
+
const existing = entity[property];
|
|
92
|
+
if (existing == null)
|
|
93
|
+
return undefined;
|
|
94
|
+
if (typeof existing === 'object' && !Array.isArray(existing)) {
|
|
95
|
+
return existing;
|
|
96
|
+
}
|
|
97
|
+
throw new Error(`Property "${String(property)}" no longer holds a translation map (found a ` +
|
|
98
|
+
`${typeof existing}). This usually means the entity was already localized ` +
|
|
99
|
+
'(for example by TranslationResponseTransformer with mutateResponses enabled) ' +
|
|
100
|
+
'before being reused; re-fetch the entity before calling set/remove.');
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
TranslationService = __decorate([
|
|
104
|
+
Injectable(),
|
|
105
|
+
__param(0, Inject(TRANSLATABLE_MODULE_OPTIONS)),
|
|
106
|
+
__param(1, Inject(TranslationContextService)),
|
|
107
|
+
__metadata("design:paramtypes", [Object, TranslationContextService])
|
|
108
|
+
], TranslationService);
|
|
109
|
+
export { TranslationService };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type TranslationMap } from '../index.js';
|
|
2
|
+
export declare class BaseFixture {
|
|
3
|
+
name: TranslationMap;
|
|
4
|
+
}
|
|
5
|
+
export declare class ProductFixture extends BaseFixture {
|
|
6
|
+
details: TranslationMap;
|
|
7
|
+
price: number;
|
|
8
|
+
}
|
|
9
|
+
export declare class LegacyFixture {
|
|
10
|
+
name: TranslationMap;
|
|
11
|
+
description: TranslationMap;
|
|
12
|
+
}
|
|
13
|
+
export declare class ProductResponseDto {
|
|
14
|
+
id: string;
|
|
15
|
+
name: unknown;
|
|
16
|
+
price: string;
|
|
17
|
+
}
|
|
18
|
+
export declare class PaginatedDto {
|
|
19
|
+
data: ProductResponseDto[];
|
|
20
|
+
meta: unknown;
|
|
21
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
8
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
9
|
+
};
|
|
10
|
+
import { TranslatableColumn, TranslatableProperty } from '../index.js';
|
|
11
|
+
export class BaseFixture {
|
|
12
|
+
}
|
|
13
|
+
__decorate([
|
|
14
|
+
TranslatableColumn({ column: { type: 'jsonb', nullable: true } }),
|
|
15
|
+
__metadata("design:type", Object)
|
|
16
|
+
], BaseFixture.prototype, "name", void 0);
|
|
17
|
+
export class ProductFixture extends BaseFixture {
|
|
18
|
+
constructor() {
|
|
19
|
+
super(...arguments);
|
|
20
|
+
this.price = 10;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
__decorate([
|
|
24
|
+
TranslatableColumn({
|
|
25
|
+
column: { type: 'jsonb', nullable: true },
|
|
26
|
+
translation: { fallbackLocale: 'fr' },
|
|
27
|
+
}),
|
|
28
|
+
__metadata("design:type", Object)
|
|
29
|
+
], ProductFixture.prototype, "details", void 0);
|
|
30
|
+
export class LegacyFixture {
|
|
31
|
+
}
|
|
32
|
+
__decorate([
|
|
33
|
+
TranslatableColumn({ column: { type: 'jsonb', nullable: false } }),
|
|
34
|
+
__metadata("design:type", Object)
|
|
35
|
+
], LegacyFixture.prototype, "name", void 0);
|
|
36
|
+
__decorate([
|
|
37
|
+
TranslatableColumn({
|
|
38
|
+
column: { type: 'jsonb', nullable: true },
|
|
39
|
+
translation: { sourceLocale: 'de' },
|
|
40
|
+
}),
|
|
41
|
+
__metadata("design:type", Object)
|
|
42
|
+
], LegacyFixture.prototype, "description", void 0);
|
|
43
|
+
// Stands in for a nestjs-paginate / Swagger response DTO: a class distinct
|
|
44
|
+
// from the TypeORM entity, constructed by a mapper or class-transformer
|
|
45
|
+
// rather than hydrated by TypeORM.
|
|
46
|
+
export class ProductResponseDto {
|
|
47
|
+
}
|
|
48
|
+
__decorate([
|
|
49
|
+
TranslatableProperty({ fallbackLocale: 'fr' }),
|
|
50
|
+
__metadata("design:type", Object)
|
|
51
|
+
], ProductResponseDto.prototype, "name", void 0);
|
|
52
|
+
export class PaginatedDto {
|
|
53
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { TranslationContextService } from '../context/translation-context.service.js';
|
|
2
|
+
import { TranslationService } from '../services/translation.service.js';
|
|
3
|
+
import type { TranslatableModuleOptions, TranslationResponseMode } from '../types.js';
|
|
4
|
+
export declare class TranslationResponseTransformer {
|
|
5
|
+
private readonly options;
|
|
6
|
+
private readonly context;
|
|
7
|
+
private readonly translations;
|
|
8
|
+
constructor(options: TranslatableModuleOptions, context: TranslationContextService, translations: TranslationService);
|
|
9
|
+
transform<T>(value: T, mode?: TranslationResponseMode, locale?: string): T;
|
|
10
|
+
private visit;
|
|
11
|
+
}
|