@restforgejs/platform 5.0.9 → 5.1.4
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/build-info.json +2 -2
- package/cli/consumer-deploy.js +1 -1
- package/cli/consumer.js +1 -1
- package/generators/cli/data/pull.js +95 -0
- package/generators/cli/data/push.js +85 -0
- package/generators/cli/fast-track.js +950 -0
- package/generators/cli/payload/sync.js +18 -2
- package/generators/cli/schema/introspect.js +10 -10
- package/generators/lib/data/db-executor.js +440 -0
- package/generators/lib/data/dialect-kit.js +56 -0
- package/generators/lib/data/envelope.js +220 -0
- package/generators/lib/data/pull-runner.js +407 -0
- package/generators/lib/data/push-runner.js +382 -0
- package/generators/lib/data/sdf-reader.js +132 -0
- package/generators/lib/data/table-order.js +126 -0
- package/generators/lib/data/value-codec.js +188 -0
- package/generators/lib/migrate/field-type-resolver.js +18 -5
- package/generators/lib/payload/payload-runner.js +724 -39
- package/generators/lib/templates/dashboard-catalog.js +1 -1
- package/generators/lib/templates/db-connection-env.js +1 -1
- package/generators/lib/templates/dbschema-catalog.js +1 -1
- package/generators/lib/templates/field-validation-catalog.js +1 -1
- package/generators/lib/templates/mysql-template.js +1 -1
- package/generators/lib/templates/oracle-template.js +1 -1
- package/generators/lib/templates/postgres-template.js +1 -1
- package/generators/lib/templates/query-declarative-catalog.js +1 -1
- package/generators/lib/templates/sqlite-template.js +1 -1
- package/integrity-manifest.json +18 -18
- package/package.json +1 -1
- package/scripts/verify-integrity.js +1 -1
- package/server.js +1 -1
- package/src/components/handlers/adjust_handler.js +1 -1
- package/src/components/handlers/audit_handler.js +1 -1
- package/src/components/handlers/delete_handler.js +1 -1
- package/src/components/handlers/export_handler.js +1 -1
- package/src/components/handlers/import_handler.js +1 -1
- package/src/components/handlers/insert_handler.js +1 -1
- package/src/components/handlers/update_handler.js +1 -1
- package/src/components/handlers/upload_handler.js +1 -1
- package/src/components/handlers/workflow_handler.js +1 -1
- package/src/components/integrations/webhook.js +1 -1
- package/src/consumers/baseConsumer.js +1 -1
- package/src/consumers/declarativeMapper.js +1 -1
- package/src/consumers/handlers/apiHandler.js +1 -1
- package/src/consumers/handlers/consoleHandler.js +1 -1
- package/src/consumers/handlers/databaseHandler.js +1 -1
- package/src/consumers/handlers/index.js +1 -1
- package/src/consumers/handlers/kafkaHandler.js +1 -1
- package/src/consumers/index.js +1 -1
- package/src/consumers/messageTransformer.js +1 -1
- package/src/consumers/validator.js +1 -1
- package/src/core/db/dialect/base-dialect.js +1 -1
- package/src/core/db/dialect/index.js +1 -1
- package/src/core/db/dialect/mysql-dialect.js +1 -1
- package/src/core/db/dialect/oracle-dialect.js +1 -1
- package/src/core/db/dialect/postgres-dialect.js +1 -1
- package/src/core/db/dialect/sqlite-dialect.js +1 -1
- package/src/core/db/flatten-helper.js +1 -1
- package/src/core/db/query-builder-error.js +1 -1
- package/src/core/db/query-builder.js +1 -1
- package/src/core/db/relation-helper.js +1 -1
- package/src/core/handlers/delete_handler.js +1 -1
- package/src/core/handlers/insert_handler.js +1 -1
- package/src/core/handlers/update_handler.js +1 -1
- package/src/core/models/base-model.js +1 -1
- package/src/core/utils/cache-manager.js +1 -1
- package/src/core/utils/component-engine.js +1 -1
- package/src/core/utils/context-builder.js +1 -1
- package/src/core/utils/datetime-formatter.js +1 -1
- package/src/core/utils/datetime-parser.js +1 -1
- package/src/core/utils/db.js +1 -1
- package/src/core/utils/logger.js +1 -1
- package/src/core/utils/payload-loader.js +1 -1
- package/src/core/utils/security-checks.js +1 -1
- package/src/middleware/body-options.js +1 -1
- package/src/middleware/cors.js +1 -1
- package/src/middleware/idempotency.js +1 -1
- package/src/middleware/rate-limiter.js +1 -1
- package/src/middleware/request-logger.js +1 -1
- package/src/middleware/security-headers.js +1 -1
- package/src/models/base-model-mysql.js +1 -1
- package/src/models/base-model-oracle.js +1 -1
- package/src/models/base-model-sqlite.js +1 -1
- package/src/models/base-model.js +1 -1
- package/src/pro/caching/redis-client.js +1 -1
- package/src/pro/caching/redis-helper.js +1 -1
- package/src/pro/consumers/baseConsumer.js +1 -1
- package/src/pro/consumers/declarativeMapper.js +1 -1
- package/src/pro/consumers/handlers/apiHandler.js +1 -1
- package/src/pro/consumers/handlers/consoleHandler.js +1 -1
- package/src/pro/consumers/handlers/databaseHandler.js +1 -1
- package/src/pro/consumers/handlers/index.js +1 -1
- package/src/pro/consumers/handlers/kafkaHandler.js +1 -1
- package/src/pro/consumers/index.js +1 -1
- package/src/pro/consumers/messageTransformer.js +1 -1
- package/src/pro/consumers/validator.js +1 -1
- package/src/pro/database/base-model-mysql.js +1 -1
- package/src/pro/database/base-model-oracle.js +1 -1
- package/src/pro/database/base-model-sqlite.js +1 -1
- package/src/pro/database/db-mysql.js +1 -1
- package/src/pro/database/db-oracle.js +1 -1
- package/src/pro/database/db-sqlite.js +1 -1
- package/src/pro/excel/excel-generator.js +1 -1
- package/src/pro/excel/excel-parser.js +1 -1
- package/src/pro/excel/export-service.js +1 -1
- package/src/pro/excel/export_handler.js +1 -1
- package/src/pro/excel/import-service.js +1 -1
- package/src/pro/excel/import-validator.js +1 -1
- package/src/pro/excel/import_handler.js +1 -1
- package/src/pro/excel/upsert-builder.js +1 -1
- package/src/pro/idgen/idgen-routes.js +1 -1
- package/src/pro/integrations/lookup-resolver.js +1 -1
- package/src/pro/integrations/upload-handler-v2.js +1 -1
- package/src/pro/integrations/upload-handler.js +1 -1
- package/src/pro/integrations/webhook.js +1 -1
- package/src/pro/locking/lock-routes.js +1 -1
- package/src/pro/locking/resource-lock-manager.js +1 -1
- package/src/pro/messaging/kafkaConsumerService.js +1 -1
- package/src/pro/messaging/kafkaService.js +1 -1
- package/src/pro/messaging/messagehubService.js +1 -1
- package/src/pro/messaging/rabbitmqService.js +1 -1
- package/src/pro/scheduler/job-manager.js +1 -1
- package/src/pro/scheduler/job-routes.js +1 -1
- package/src/pro/scheduler/job-validator.js +1 -1
- package/src/pro/storage/base-storage-provider.js +1 -1
- package/src/pro/storage/file-metadata-helper.js +1 -1
- package/src/pro/storage/index.js +1 -1
- package/src/pro/storage/local-storage-provider.js +1 -1
- package/src/pro/storage/s3-storage-provider.js +1 -1
- package/src/pro/storage/upload-cleanup-job.js +1 -1
- package/src/pro/storage/upload-cleanup-scheduler.js +1 -1
- package/src/pro/storage/upload-pending-tracker.js +1 -1
- package/src/pro/websocket/broadcast-helper.js +1 -1
- package/src/pro/websocket/index.js +1 -1
- package/src/pro/websocket/livesync-server.js +1 -1
- package/src/pro/websocket/ws-broadcaster.js +1 -1
- package/src/services/export-service.js +1 -1
- package/src/services/import-service.js +1 -1
- package/src/services/kafkaConsumerService.js +1 -1
- package/src/services/kafkaService.js +1 -1
- package/src/services/messagehubService.js +1 -1
- package/src/services/rabbitmqService.js +1 -1
- package/src/utils/cache-invalidation-registry.js +1 -1
- package/src/utils/cache-manager.js +1 -1
- package/src/utils/component-engine.js +1 -1
- package/src/utils/config-extractor.js +1 -1
- package/src/utils/consumerLogger.js +1 -1
- package/src/utils/context-builder.js +1 -1
- package/src/utils/dashboard-helpers.js +1 -1
- package/src/utils/dateHelper.js +1 -1
- package/src/utils/datetime-formatter.js +1 -1
- package/src/utils/datetime-parser.js +1 -1
- package/src/utils/db-bootstrap.js +1 -1
- package/src/utils/db-mysql.js +1 -1
- package/src/utils/db-oracle.js +1 -1
- package/src/utils/db-sqlite.js +1 -1
- package/src/utils/db.js +1 -1
- package/src/utils/demo-generator.js +1 -1
- package/src/utils/excel-generator.js +1 -1
- package/src/utils/excel-parser.js +1 -1
- package/src/utils/file-watcher.js +1 -1
- package/src/utils/id-generator.js +1 -1
- package/src/utils/idempotency-manager.js +1 -1
- package/src/utils/import-validator.js +1 -1
- package/src/utils/license-client.js +1 -1
- package/src/utils/lock-manager.js +1 -1
- package/src/utils/logger.js +1 -1
- package/src/utils/lookup-resolver.js +1 -1
- package/src/utils/payload-loader.js +1 -1
- package/src/utils/processor-response.js +1 -1
- package/src/utils/rabbitmq.js +1 -1
- package/src/utils/redis-client.js +1 -1
- package/src/utils/redis-helper.js +1 -1
- package/src/utils/request-scope.js +1 -1
- package/src/utils/security-checks.js +1 -1
- package/src/utils/service-resolver.js +1 -1
- package/src/utils/shutdown-coordinator.js +1 -1
- package/src/utils/trusted-keys.js +1 -1
- package/src/utils/upload-handler.js +1 -1
- package/src/utils/upsert-builder.js +1 -1
- package/src/utils/workflow-hook-executor.js +1 -1
|
@@ -93,6 +93,456 @@ if (isCompiledBinary) {
|
|
|
93
93
|
process.removeAllListeners('warning');
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
// ============================================================================
|
|
97
|
+
// FOREIGN KEY EXPANSION (payload sync --expand-fk)
|
|
98
|
+
// ============================================================================
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Parse string --fk-columns menjadi array spec {table, column, raw}.
|
|
102
|
+
*
|
|
103
|
+
* Kontrak: setiap entri WAJIB berbentuk `table.column` (qualified), dipisah
|
|
104
|
+
* koma. Bentuk polos (tanpa `table.`) ditolak agar tidak ada penebakan tabel
|
|
105
|
+
* referensi pada kasus multi-FK. Entri duplikat (table.column sama,
|
|
106
|
+
* case-insensitive) di-dedupe dengan mempertahankan urutan kemunculan pertama.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} fkColumnsStr - Nilai flag --fk-columns
|
|
109
|
+
* @returns {Array<{table: string, column: string, raw: string}>}
|
|
110
|
+
* @throws {Error} bila kosong atau ada entri yang tidak berbentuk table.column
|
|
111
|
+
*/
|
|
112
|
+
function parseFkColumns(fkColumnsStr) {
|
|
113
|
+
if (typeof fkColumnsStr !== 'string' || fkColumnsStr.trim().length === 0) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
'--fk-columns is required when --expand-fk is set ' +
|
|
116
|
+
'(format: table.column,table.column)'
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const entries = fkColumnsStr.split(',').map(s => s.trim()).filter(Boolean);
|
|
121
|
+
if (entries.length === 0) {
|
|
122
|
+
throw new Error('--fk-columns has no usable entries after parsing');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const specs = [];
|
|
126
|
+
const seen = new Set();
|
|
127
|
+
for (const raw of entries) {
|
|
128
|
+
const parts = raw.split('.');
|
|
129
|
+
if (parts.length !== 2 || !parts[0].trim() || !parts[1].trim()) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`Invalid --fk-columns entry "${raw}": each entry must be qualified as table.column`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
const table = parts[0].trim();
|
|
135
|
+
const column = parts[1].trim();
|
|
136
|
+
const key = `${table.toLowerCase()}.${column.toLowerCase()}`;
|
|
137
|
+
if (seen.has(key)) continue;
|
|
138
|
+
seen.add(key);
|
|
139
|
+
specs.push({ table, column, raw });
|
|
140
|
+
}
|
|
141
|
+
return specs;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Derive alias tabel secara deterministik: inisial tiap kata yang dipisah
|
|
146
|
+
* underscore, lowercase. Contoh: `visitors`->`v`, `visitor_categories`->`vc`,
|
|
147
|
+
* `stock_inbound`->`si`. Schema prefix (mis. `public.`) diabaikan.
|
|
148
|
+
*
|
|
149
|
+
* @param {string} tableName
|
|
150
|
+
* @returns {string}
|
|
151
|
+
*/
|
|
152
|
+
function deriveTableAlias(tableName) {
|
|
153
|
+
const bare = tableName.includes('.') ? tableName.split('.').pop() : tableName;
|
|
154
|
+
const parts = String(bare).split('_').filter(Boolean);
|
|
155
|
+
const initials = parts.map(p => p[0]).join('').toLowerCase();
|
|
156
|
+
return initials || 't';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Pilih kolom display dari tabel referensi secara heuristik untuk mode
|
|
161
|
+
* auto-resolve (`--expand-fk` tanpa `--fk-columns`).
|
|
162
|
+
*
|
|
163
|
+
* Urutan prioritas (deterministik):
|
|
164
|
+
* 1. Kolom "name" : token `name` / `nama`
|
|
165
|
+
* 2. Kolom "code" : token `code` / `kode`
|
|
166
|
+
* 3. Primary key tabel referensi (fallback terakhir)
|
|
167
|
+
*
|
|
168
|
+
* Dalam tiap kelompok token, skor: exact match (3) > berakhiran `_<token>` (2)
|
|
169
|
+
* > mengandung token (1). Skor tertinggi menang; bila seri, kolom yang muncul
|
|
170
|
+
* lebih dulu di urutan kolom database dipertahankan. Kolom audit default
|
|
171
|
+
* di-exclude dari kandidat name/code.
|
|
172
|
+
*
|
|
173
|
+
* @param {string[]} refColumns - Kolom fisik tabel referensi (urutan database)
|
|
174
|
+
* @param {string|null} primaryKey - Primary key tabel referensi
|
|
175
|
+
* @returns {string|null} Nama kolom terpilih, atau null bila tidak ada kandidat
|
|
176
|
+
*/
|
|
177
|
+
function pickDisplayColumn(refColumns, primaryKey) {
|
|
178
|
+
const cols = (refColumns || []).filter(c => !DEFAULT_AUDIT_COLUMNS.includes(c));
|
|
179
|
+
|
|
180
|
+
const bestByTokens = (tokens) => {
|
|
181
|
+
let best = null;
|
|
182
|
+
let bestScore = 0;
|
|
183
|
+
for (const c of cols) {
|
|
184
|
+
const lc = String(c).toLowerCase();
|
|
185
|
+
let score = 0;
|
|
186
|
+
for (const t of tokens) {
|
|
187
|
+
if (lc === t) score = Math.max(score, 3);
|
|
188
|
+
else if (lc.endsWith(`_${t}`)) score = Math.max(score, 2);
|
|
189
|
+
else if (lc.includes(t)) score = Math.max(score, 1);
|
|
190
|
+
}
|
|
191
|
+
if (score > bestScore) {
|
|
192
|
+
bestScore = score;
|
|
193
|
+
best = c;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return best;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
return bestByTokens(['name', 'nama'])
|
|
200
|
+
|| bestByTokens(['code', 'kode'])
|
|
201
|
+
|| primaryKey
|
|
202
|
+
|| null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Bangun konfigurasi JOIN dari foreign key. Pure (tanpa I/O) agar mudah di-test.
|
|
207
|
+
* Mengembalikan { updatedPayload, sqlRelPath, sqlContent } atau throw Error pada
|
|
208
|
+
* kondisi invalid (tabel referensi tidak ada, kolom tidak ditemukan, FK ganda
|
|
209
|
+
* ke tabel sama, collision output yang tidak dapat di-resolve).
|
|
210
|
+
*
|
|
211
|
+
* Aturan output:
|
|
212
|
+
* - SELECT base = payload.fieldName yang merupakan kolom fisik base table
|
|
213
|
+
* (kolom non-fisik mis. hasil JOIN run sebelumnya dibuang agar idempotent)
|
|
214
|
+
* - kolom referensi di-surface dengan prefix alias; bila nama output bentrok
|
|
215
|
+
* (dengan kolom base atau kolom referensi lain), output di-prefix nama tabel
|
|
216
|
+
* referensi via `AS` (mis. `w.city AS warehouse_city`)
|
|
217
|
+
* - LEFT JOIN per tabel referensi yang dipakai, kondisi diturunkan dari FK
|
|
218
|
+
* - datatablesQuery & viewQuery -> `file:query/<table>-join.sql`
|
|
219
|
+
* - datatablesWhere: pertahankan existing, hapus output lama, sisipkan output
|
|
220
|
+
* referensi sebelum entri `"all"`
|
|
221
|
+
* - fieldValidation TIDAK disentuh (kolom JOIN bukan kolom fisik)
|
|
222
|
+
*
|
|
223
|
+
* @param {Object} payload - Payload saat ini (punya tableName + fieldName)
|
|
224
|
+
* @param {Array<{table,column,raw}>} fkSpec - hasil parseFkColumns
|
|
225
|
+
* @param {Array} foreignKeys - hasil db.getForeignKeys(tableName)
|
|
226
|
+
* @param {Object} refColumnsMap - { refTableLower: string[] kolom fisik referensi }
|
|
227
|
+
* @param {string[]} baseColumns - kolom fisik base table (non-audit)
|
|
228
|
+
* @returns {{updatedPayload: Object, sqlRelPath: string, sqlContent: string}}
|
|
229
|
+
*/
|
|
230
|
+
function buildForeignKeyExpansion(payload, fkSpec, foreignKeys, refColumnsMap, baseColumns) {
|
|
231
|
+
const tableName = payload.tableName;
|
|
232
|
+
|
|
233
|
+
// Base fields = fieldName yang benar-benar kolom fisik base table.
|
|
234
|
+
const basePhysicalLower = new Set((baseColumns || []).map(c => String(c).toLowerCase()));
|
|
235
|
+
const declaredFields = Array.isArray(payload.fieldName) ? payload.fieldName : [];
|
|
236
|
+
const baseFields = declaredFields.filter(f => basePhysicalLower.has(String(f).toLowerCase()));
|
|
237
|
+
|
|
238
|
+
// Map refTable(lower) -> FK; deteksi FK ganda ke tabel referensi yang sama.
|
|
239
|
+
const fkByRefTable = new Map();
|
|
240
|
+
const dupRefTables = new Set();
|
|
241
|
+
for (const fk of (foreignKeys || [])) {
|
|
242
|
+
const rt = fk && fk.references && fk.references.table ? String(fk.references.table) : '';
|
|
243
|
+
if (!rt) continue;
|
|
244
|
+
const key = rt.toLowerCase();
|
|
245
|
+
if (fkByRefTable.has(key)) dupRefTables.add(key);
|
|
246
|
+
else fkByRefTable.set(key, fk);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (fkByRefTable.size === 0) {
|
|
250
|
+
throw new Error(
|
|
251
|
+
`Table "${tableName}" has no foreign keys; --expand-fk cannot resolve any reference`
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Alias unik: base dulu, lalu tiap tabel referensi sesuai urutan kemunculan.
|
|
256
|
+
const usedAliases = new Set();
|
|
257
|
+
const uniqueAlias = (base) => {
|
|
258
|
+
let a = base, n = 1;
|
|
259
|
+
while (usedAliases.has(a)) { n++; a = base + n; }
|
|
260
|
+
usedAliases.add(a);
|
|
261
|
+
return a;
|
|
262
|
+
};
|
|
263
|
+
const baseAlias = uniqueAlias(deriveTableAlias(tableName));
|
|
264
|
+
|
|
265
|
+
const refTableAlias = new Map(); // refKeyLower -> { alias, fk, refTableName }
|
|
266
|
+
const refSelections = []; // { alias, column, refTableName }
|
|
267
|
+
for (const spec of fkSpec) {
|
|
268
|
+
const key = spec.table.toLowerCase();
|
|
269
|
+
if (!fkByRefTable.has(key)) {
|
|
270
|
+
const available = [...fkByRefTable.keys()].join(', ') || '(none)';
|
|
271
|
+
throw new Error(
|
|
272
|
+
`Foreign key reference table "${spec.table}" not found for "${tableName}". ` +
|
|
273
|
+
`Referenced tables available: ${available}`
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
if (dupRefTables.has(key)) {
|
|
277
|
+
throw new Error(
|
|
278
|
+
`Table "${tableName}" has multiple foreign keys referencing "${spec.table}"; ` +
|
|
279
|
+
'cannot disambiguate by table name'
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const fk = fkByRefTable.get(key);
|
|
284
|
+
const refTableName = fk.references.table;
|
|
285
|
+
const refCols = refColumnsMap[key] || refColumnsMap[refTableName] || [];
|
|
286
|
+
const refColsLower = refCols.map(c => String(c).toLowerCase());
|
|
287
|
+
if (!refColsLower.includes(spec.column.toLowerCase())) {
|
|
288
|
+
throw new Error(
|
|
289
|
+
`Column "${spec.column}" not found in referenced table "${spec.table}". ` +
|
|
290
|
+
`Available columns: ${refCols.join(', ') || '(none)'}`
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (!refTableAlias.has(key)) {
|
|
295
|
+
refTableAlias.set(key, {
|
|
296
|
+
alias: uniqueAlias(deriveTableAlias(refTableName)),
|
|
297
|
+
fk,
|
|
298
|
+
refTableName
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
refSelections.push({ alias: refTableAlias.get(key).alias, column: spec.column, refTableName });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Output name + collision auto-prefix. Hitung occurrence nama tentatif
|
|
305
|
+
// (base fields + kolom referensi apa adanya); nama yang muncul >1 kali
|
|
306
|
+
// di-prefix nama tabel referensi.
|
|
307
|
+
const nameCount = new Map();
|
|
308
|
+
const bump = (n) => nameCount.set(n, (nameCount.get(n) || 0) + 1);
|
|
309
|
+
for (const f of baseFields) bump(f);
|
|
310
|
+
for (const r of refSelections) bump(r.column);
|
|
311
|
+
|
|
312
|
+
const finalNames = new Set(baseFields);
|
|
313
|
+
const refOutputs = []; // { alias, column, output, refTableName }
|
|
314
|
+
for (const r of refSelections) {
|
|
315
|
+
let output = r.column;
|
|
316
|
+
if ((nameCount.get(r.column) || 0) > 1) {
|
|
317
|
+
output = `${r.refTableName}_${r.column}`.toLowerCase();
|
|
318
|
+
}
|
|
319
|
+
if (finalNames.has(output)) {
|
|
320
|
+
throw new Error(
|
|
321
|
+
`Output column "${output}" collides after auto-prefix in expansion for "${tableName}"; ` +
|
|
322
|
+
'rename the source column or adjust the schema'
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
finalNames.add(output);
|
|
326
|
+
refOutputs.push({ alias: r.alias, column: r.column, output, refTableName: r.refTableName });
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// SQL build.
|
|
330
|
+
const baseSelect = baseFields.map(c => `${baseAlias}.${c}`);
|
|
331
|
+
const refSelect = refOutputs.map(r =>
|
|
332
|
+
r.output === r.column ? `${r.alias}.${r.column}` : `${r.alias}.${r.column} AS ${r.output}`
|
|
333
|
+
);
|
|
334
|
+
const selectItems = [...baseSelect, ...refSelect];
|
|
335
|
+
|
|
336
|
+
const sqlLines = selectItems.map((item, idx) => {
|
|
337
|
+
const prefix = idx === 0 ? 'SELECT ' : ' ';
|
|
338
|
+
const suffix = idx === selectItems.length - 1 ? '' : ',';
|
|
339
|
+
return `${prefix}${item}${suffix}`;
|
|
340
|
+
});
|
|
341
|
+
sqlLines.push(`FROM ${tableName} ${baseAlias}`);
|
|
342
|
+
for (const [, info] of refTableAlias) {
|
|
343
|
+
const localCols = info.fk.columns || [];
|
|
344
|
+
const refCols = (info.fk.references && info.fk.references.columns) || [];
|
|
345
|
+
const pairCount = Math.max(localCols.length, refCols.length);
|
|
346
|
+
const conds = [];
|
|
347
|
+
for (let i = 0; i < pairCount; i++) {
|
|
348
|
+
conds.push(`${info.alias}.${refCols[i]} = ${baseAlias}.${localCols[i]}`);
|
|
349
|
+
}
|
|
350
|
+
sqlLines.push(`LEFT JOIN ${info.refTableName} ${info.alias} ON ${conds.join(' AND ')}`);
|
|
351
|
+
}
|
|
352
|
+
const sqlContent = `${sqlLines.join('\n')}\n`;
|
|
353
|
+
|
|
354
|
+
// Mutasi payload.
|
|
355
|
+
const baseFilename = tableName.replace(/[._]/g, '-');
|
|
356
|
+
const sqlRelPath = `query/${baseFilename}-join.sql`;
|
|
357
|
+
const refOutputNames = refOutputs.map(r => r.output);
|
|
358
|
+
|
|
359
|
+
const updatedPayload = { ...payload };
|
|
360
|
+
updatedPayload.fieldName = [
|
|
361
|
+
...baseFields,
|
|
362
|
+
...refOutputNames
|
|
363
|
+
];
|
|
364
|
+
updatedPayload.datatablesQuery = `file:${sqlRelPath}`;
|
|
365
|
+
updatedPayload.viewQuery = `file:${sqlRelPath}`;
|
|
366
|
+
|
|
367
|
+
let where = Array.isArray(updatedPayload.datatablesWhere)
|
|
368
|
+
? updatedPayload.datatablesWhere.slice()
|
|
369
|
+
: [];
|
|
370
|
+
where = where.filter(w => !refOutputNames.includes(w));
|
|
371
|
+
const allIdx = where.indexOf('all');
|
|
372
|
+
const insertAt = allIdx >= 0 ? allIdx : where.length;
|
|
373
|
+
where.splice(insertAt, 0, ...refOutputNames);
|
|
374
|
+
updatedPayload.datatablesWhere = where;
|
|
375
|
+
|
|
376
|
+
return { updatedPayload, sqlRelPath, sqlContent };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ============================================================================
|
|
380
|
+
// SEARCHABLE COLUMNS (datatablesWhere)
|
|
381
|
+
// ============================================================================
|
|
382
|
+
|
|
383
|
+
// Pola nama kolom yang tidak dijadikan searchable secara default (id + audit/
|
|
384
|
+
// timestamp). Dipakai hanya untuk MEMILIH kolom default, bukan sebagai aturan
|
|
385
|
+
// keras tentang kolom apa yang boleh ada di datatablesWhere.
|
|
386
|
+
const SEARCHABLE_EXCLUDE_PATTERNS = ['_id', '_at', '_by', 'created', 'updated', 'deleted'];
|
|
387
|
+
|
|
388
|
+
// Tipe data string yang valid untuk pencarian LIKE.
|
|
389
|
+
const SEARCHABLE_STRING_DATA_TYPES = ['character varying', 'varchar', 'text', 'character', 'char'];
|
|
390
|
+
const SEARCHABLE_STRING_UDT = ['varchar', 'text', 'bpchar'];
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Kumpulkan nama kolom bertipe string (varchar/text/char) dari detailedColumns.
|
|
394
|
+
*
|
|
395
|
+
* detailedColumns sebaiknya sudah di-enrich dengan boolean
|
|
396
|
+
* (`enrichDetailedColumnsWithBoolean`) agar kolom boolean-marker pada dialect
|
|
397
|
+
* varchar (mysql/oracle/sqlite) ter-set `data_type='boolean'` dan TIDAK salah
|
|
398
|
+
* dihitung sebagai string. Hanya kolom bertipe string yang boleh masuk
|
|
399
|
+
* `datatablesWhere`, sebab runtime membungkus kolom searchable dengan
|
|
400
|
+
* `upper(col) LIKE upper(?)` yang gagal pada tipe non-text (mis. PostgreSQL
|
|
401
|
+
* `function upper(integer) does not exist`).
|
|
402
|
+
*
|
|
403
|
+
* @param {Array} detailedColumns - Array dari getDetailedColumnInfo() (enriched)
|
|
404
|
+
* @returns {Set<string>} nama kolom bertipe string
|
|
405
|
+
*/
|
|
406
|
+
function collectStringColumns(detailedColumns) {
|
|
407
|
+
const set = new Set();
|
|
408
|
+
for (const col of (detailedColumns || [])) {
|
|
409
|
+
const dt = String(col.data_type || '').toLowerCase();
|
|
410
|
+
const udt = String(col.udt_name || '').toLowerCase();
|
|
411
|
+
if (SEARCHABLE_STRING_DATA_TYPES.includes(dt) || SEARCHABLE_STRING_UDT.includes(udt)) {
|
|
412
|
+
set.add(col.column_name);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return set;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Predikat default untuk pemilihan kolom searchable baru: kolom tidak cocok pola
|
|
420
|
+
* exclude nama (id/audit) DAN bertipe string.
|
|
421
|
+
*
|
|
422
|
+
* @param {string} fieldName
|
|
423
|
+
* @param {Set<string>} stringColumns - hasil collectStringColumns
|
|
424
|
+
* @returns {boolean}
|
|
425
|
+
*/
|
|
426
|
+
function isDefaultSearchableColumn(fieldName, stringColumns) {
|
|
427
|
+
const lower = String(fieldName).toLowerCase();
|
|
428
|
+
if (SEARCHABLE_EXCLUDE_PATTERNS.some(p => lower.includes(p))) return false;
|
|
429
|
+
return stringColumns.has(fieldName);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ============================================================================
|
|
433
|
+
// DEFAULT SCOPE (is_active) — built-in untuk payload generate & sync
|
|
434
|
+
// ============================================================================
|
|
435
|
+
|
|
436
|
+
// Nama kolom penanda aktif. Match exact lowercase (snake_case), konsisten dengan
|
|
437
|
+
// konvensi penamaan RESTForge.
|
|
438
|
+
const IS_ACTIVE_COLUMN = 'is_active';
|
|
439
|
+
|
|
440
|
+
// Action yang menerima filter is_active otomatis (sesuai catalogs/rdf/default-scope.md;
|
|
441
|
+
// datatables & first sengaja tidak terpengaruh).
|
|
442
|
+
const DEFAULT_SCOPE_ACTIONS = ['lookup', 'read'];
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Terapkan/lepas defaultScope berbasis kolom `is_active` secara in-place.
|
|
446
|
+
*
|
|
447
|
+
* - hasIsActive true : pastikan defaultScope.lookup.is_active = true dan
|
|
448
|
+
* defaultScope.read.is_active = true (merge, pertahankan key scope lain).
|
|
449
|
+
* - hasIsActive false : hapus key `is_active` dari lookup/read; bersihkan object
|
|
450
|
+
* action yang menjadi kosong; hapus `defaultScope` bila kosong total.
|
|
451
|
+
*
|
|
452
|
+
* Hanya menyentuh key `is_active` agar kustomisasi scope lain (mis. tenant_id)
|
|
453
|
+
* tidak ikut hilang. Pemanggil menentukan `hasIsActive` dari keberadaan kolom
|
|
454
|
+
* `is_active` di fieldName payload (sekaligus menjamin aturan validator
|
|
455
|
+
* "kolom defaultScope harus ada di fieldName" terpenuhi).
|
|
456
|
+
*
|
|
457
|
+
* @param {Object} payload - Payload yang dimutasi
|
|
458
|
+
* @param {boolean} hasIsActive - Apakah kolom is_active ada di fieldName
|
|
459
|
+
* @returns {void}
|
|
460
|
+
*/
|
|
461
|
+
function applyIsActiveDefaultScope(payload, hasIsActive) {
|
|
462
|
+
if (hasIsActive) {
|
|
463
|
+
const existing = (payload.defaultScope && typeof payload.defaultScope === 'object'
|
|
464
|
+
&& !Array.isArray(payload.defaultScope))
|
|
465
|
+
? payload.defaultScope
|
|
466
|
+
: {};
|
|
467
|
+
for (const action of DEFAULT_SCOPE_ACTIONS) {
|
|
468
|
+
const prev = (existing[action] && typeof existing[action] === 'object'
|
|
469
|
+
&& !Array.isArray(existing[action]))
|
|
470
|
+
? existing[action]
|
|
471
|
+
: {};
|
|
472
|
+
existing[action] = { ...prev, [IS_ACTIVE_COLUMN]: true };
|
|
473
|
+
}
|
|
474
|
+
payload.defaultScope = existing;
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// hasIsActive false -> lepas key is_active.
|
|
479
|
+
const ds = payload.defaultScope;
|
|
480
|
+
if (!ds || typeof ds !== 'object' || Array.isArray(ds)) return;
|
|
481
|
+
for (const action of DEFAULT_SCOPE_ACTIONS) {
|
|
482
|
+
if (ds[action] && typeof ds[action] === 'object' && !Array.isArray(ds[action])) {
|
|
483
|
+
delete ds[action][IS_ACTIVE_COLUMN];
|
|
484
|
+
if (Object.keys(ds[action]).length === 0) delete ds[action];
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
if (Object.keys(ds).length === 0) delete payload.defaultScope;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ============================================================================
|
|
491
|
+
// ADVISORIES (informational) — untuk payload validate
|
|
492
|
+
// ============================================================================
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Bangun daftar advisory informational untuk sebuah payload. Advisory bersifat
|
|
496
|
+
* SARAN tindak lanjut, BUKAN error: tidak memengaruhi verdict (OK/DRIFT/ERROR)
|
|
497
|
+
* maupun exit code.
|
|
498
|
+
*
|
|
499
|
+
* Jenis advisory:
|
|
500
|
+
* - fk : tabel punya foreign key tetapi payload belum men-surface-nya
|
|
501
|
+
* sebagai JOIN (tidak ada `viewQuery`). Saran: `payload sync --expand-fk`.
|
|
502
|
+
* - scope : kolom `is_active` ada di fieldName tetapi `defaultScope` belum
|
|
503
|
+
* memuat `is_active` di lookup & read. Saran: `payload sync`.
|
|
504
|
+
*
|
|
505
|
+
* @param {Object} payload
|
|
506
|
+
* @param {Object} info
|
|
507
|
+
* @param {Array} info.foreignKeys - hasil db.getForeignKeys(tableName)
|
|
508
|
+
* @param {boolean} info.hasIsActive - apakah is_active ada di fieldName
|
|
509
|
+
* @returns {Array<{type: string, table: string, message: string, command: string}>}
|
|
510
|
+
*/
|
|
511
|
+
function buildPayloadAdvisories(payload, info) {
|
|
512
|
+
const advisories = [];
|
|
513
|
+
const table = payload.tableName;
|
|
514
|
+
|
|
515
|
+
// a. Foreign key belum di-surface sebagai JOIN.
|
|
516
|
+
const fkCount = Array.isArray(info.foreignKeys) ? info.foreignKeys.length : 0;
|
|
517
|
+
const usesJoin = typeof payload.viewQuery === 'string' && payload.viewQuery.trim().length > 0;
|
|
518
|
+
if (fkCount > 0 && !usesJoin) {
|
|
519
|
+
advisories.push({
|
|
520
|
+
type: 'fk',
|
|
521
|
+
table,
|
|
522
|
+
message: `has ${fkCount} foreign key(s) not surfaced as JOIN`,
|
|
523
|
+
command: `npx restforge payload sync --table=${table} --expand-fk`
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// b. is_active ada tetapi defaultScope belum lengkap.
|
|
528
|
+
if (info.hasIsActive) {
|
|
529
|
+
const ds = payload.defaultScope;
|
|
530
|
+
const scopeComplete = ds && typeof ds === 'object' && !Array.isArray(ds)
|
|
531
|
+
&& ds.lookup && ds.lookup[IS_ACTIVE_COLUMN] === true
|
|
532
|
+
&& ds.read && ds.read[IS_ACTIVE_COLUMN] === true;
|
|
533
|
+
if (!scopeComplete) {
|
|
534
|
+
advisories.push({
|
|
535
|
+
type: 'scope',
|
|
536
|
+
table,
|
|
537
|
+
message: 'has is_active but defaultScope is incomplete',
|
|
538
|
+
command: `npx restforge payload sync --table=${table}`
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return advisories;
|
|
544
|
+
}
|
|
545
|
+
|
|
96
546
|
|
|
97
547
|
/**
|
|
98
548
|
* Class untuk Payload Generator (non-interactive only)
|
|
@@ -576,7 +1026,10 @@ class PayloadGenerator {
|
|
|
576
1026
|
|
|
577
1027
|
let result;
|
|
578
1028
|
if (args.sync) {
|
|
579
|
-
result = await validator.runSync(targetTable, this
|
|
1029
|
+
result = await validator.runSync(targetTable, this, {
|
|
1030
|
+
expandFk: args.expandFk === true,
|
|
1031
|
+
fkColumns: args.fkColumns || null
|
|
1032
|
+
});
|
|
580
1033
|
} else if (args.diff) {
|
|
581
1034
|
result = await validator.runDiff(targetTable);
|
|
582
1035
|
} else {
|
|
@@ -699,12 +1152,33 @@ class PayloadGenerator {
|
|
|
699
1152
|
const sqlContent = `${sqlLines.join('\n')}\nFROM ${payloadData.tableName} a\n`;
|
|
700
1153
|
payloadData.datatablesQuery = `file:query/${sqlFilename}`;
|
|
701
1154
|
|
|
702
|
-
//
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
1155
|
+
// Introspeksi tipe & constraint sekali, dipakai untuk searchable columns,
|
|
1156
|
+
// dateTimeFields, fieldValidation, dan uniqueConstraints.
|
|
1157
|
+
let enrichedColumns = null;
|
|
1158
|
+
let columnTypes = null;
|
|
1159
|
+
let constraints = [];
|
|
1160
|
+
if (this.db.pool) {
|
|
1161
|
+
columnTypes = await this.db.getColumnTypes(args.table);
|
|
1162
|
+
const detailedColumns = await this.db.getDetailedColumnInfo(args.table);
|
|
1163
|
+
constraints = await this.db.getConstraints(args.table);
|
|
1164
|
+
const booleanColumns = typeof this.db.getBooleanColumns === 'function'
|
|
1165
|
+
? await this.db.getBooleanColumns(args.table)
|
|
1166
|
+
: [];
|
|
1167
|
+
enrichedColumns = this.enrichDetailedColumnsWithConstraints(detailedColumns, constraints);
|
|
1168
|
+
enrichedColumns = this.enrichDetailedColumnsWithBoolean(enrichedColumns, booleanColumns);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// Generate searchable columns: HANYA kolom bertipe string yang masuk
|
|
1172
|
+
// datatablesWhere (lihat collectStringColumns). Kolom non-string
|
|
1173
|
+
// (integer/numeric/boolean/date/timestamp) di-exclude agar runtime tidak
|
|
1174
|
+
// membentuk upper(col) LIKE upper(?) pada tipe non-text. Pola nama id/audit
|
|
1175
|
+
// tetap di-exclude. Fallback name-only dipakai bila DB tidak terkoneksi.
|
|
1176
|
+
const stringColumns = enrichedColumns ? collectStringColumns(enrichedColumns) : null;
|
|
1177
|
+
payloadData.datatablesWhere = payloadData.fieldName.filter(field =>
|
|
1178
|
+
stringColumns
|
|
1179
|
+
? isDefaultSearchableColumn(field, stringColumns)
|
|
1180
|
+
: !SEARCHABLE_EXCLUDE_PATTERNS.some(p => field.toLowerCase().includes(p))
|
|
1181
|
+
);
|
|
708
1182
|
payloadData.datatablesWhere.push('all');
|
|
709
1183
|
|
|
710
1184
|
// Enable all actions
|
|
@@ -718,22 +1192,18 @@ class PayloadGenerator {
|
|
|
718
1192
|
read: true
|
|
719
1193
|
};
|
|
720
1194
|
|
|
1195
|
+
// Built-in defaultScope: bila tabel punya kolom is_active, tambahkan filter
|
|
1196
|
+
// { is_active: true } untuk action lookup & read (lihat catalogs/rdf/default-scope.md).
|
|
1197
|
+
applyIsActiveDefaultScope(payloadData, payloadData.fieldName.includes(IS_ACTIVE_COLUMN));
|
|
1198
|
+
|
|
721
1199
|
// Generate dateTimeFields and fieldValidation
|
|
722
|
-
if (
|
|
723
|
-
const columnTypes = await this.db.getColumnTypes(args.table);
|
|
1200
|
+
if (enrichedColumns) {
|
|
724
1201
|
const dateTimeFields = this.generateDateTimeFields(columnTypes, payloadData.fieldName);
|
|
725
1202
|
if (Object.keys(dateTimeFields).length > 0) {
|
|
726
1203
|
payloadData.dateTimeFields = dateTimeFields;
|
|
727
1204
|
}
|
|
728
1205
|
|
|
729
1206
|
// Generate fieldValidation for special fields
|
|
730
|
-
const detailedColumns = await this.db.getDetailedColumnInfo(args.table);
|
|
731
|
-
const constraints = await this.db.getConstraints(args.table);
|
|
732
|
-
const booleanColumns = typeof this.db.getBooleanColumns === 'function'
|
|
733
|
-
? await this.db.getBooleanColumns(args.table)
|
|
734
|
-
: [];
|
|
735
|
-
let enrichedColumns = this.enrichDetailedColumnsWithConstraints(detailedColumns, constraints);
|
|
736
|
-
enrichedColumns = this.enrichDetailedColumnsWithBoolean(enrichedColumns, booleanColumns);
|
|
737
1207
|
const fieldValidation = this.generateFieldValidation(enrichedColumns, payloadData.fieldName, payloadData.primaryKey);
|
|
738
1208
|
if (fieldValidation.length > 0) {
|
|
739
1209
|
payloadData.fieldValidation = fieldValidation;
|
|
@@ -947,6 +1417,7 @@ class SchemaValidator {
|
|
|
947
1417
|
console.log();
|
|
948
1418
|
|
|
949
1419
|
const results = [];
|
|
1420
|
+
const advisories = [];
|
|
950
1421
|
let ok = 0, drift = 0, error = 0;
|
|
951
1422
|
|
|
952
1423
|
for (const { fileName, payload } of payloads) {
|
|
@@ -954,6 +1425,20 @@ class SchemaValidator {
|
|
|
954
1425
|
results.push({ fileName, comparison });
|
|
955
1426
|
this.printComparisonResult(comparison, fileName, false);
|
|
956
1427
|
|
|
1428
|
+
// Kumpulkan advisory informational (tidak memengaruhi verdict/exit code).
|
|
1429
|
+
// Dilewati bila tabel tidak ada di database (status error).
|
|
1430
|
+
if (comparison.status !== 'error') {
|
|
1431
|
+
let foreignKeys = [];
|
|
1432
|
+
if (typeof this.db.getForeignKeys === 'function') {
|
|
1433
|
+
try {
|
|
1434
|
+
foreignKeys = await this.db.getForeignKeys(payload.tableName);
|
|
1435
|
+
} catch (_e) { foreignKeys = []; }
|
|
1436
|
+
}
|
|
1437
|
+
const hasIsActive = Array.isArray(payload.fieldName)
|
|
1438
|
+
&& payload.fieldName.includes(IS_ACTIVE_COLUMN);
|
|
1439
|
+
advisories.push(...buildPayloadAdvisories(payload, { foreignKeys, hasIsActive }));
|
|
1440
|
+
}
|
|
1441
|
+
|
|
957
1442
|
if (comparison.status === 'ok') ok++;
|
|
958
1443
|
else if (comparison.status === 'drift') drift++;
|
|
959
1444
|
else error++;
|
|
@@ -970,9 +1455,19 @@ class SchemaValidator {
|
|
|
970
1455
|
if (error > 0) console.log(` Error : ${error}`);
|
|
971
1456
|
console.log();
|
|
972
1457
|
|
|
1458
|
+
// Advisories informational (saran tindak lanjut FK & scope), bukan error.
|
|
1459
|
+
if (advisories.length > 0) {
|
|
1460
|
+
console.log(' Advisories (informational, not errors):');
|
|
1461
|
+
for (const a of advisories) {
|
|
1462
|
+
console.log(` [i] ${a.table}: ${a.message}`);
|
|
1463
|
+
console.log(` -> ${a.command}`);
|
|
1464
|
+
}
|
|
1465
|
+
console.log();
|
|
1466
|
+
}
|
|
1467
|
+
|
|
973
1468
|
if (drift > 0 || error > 0) {
|
|
974
|
-
console.log(
|
|
975
|
-
console.log(
|
|
1469
|
+
console.log(" Use 'npx restforge payload diff' for detailed comparison.");
|
|
1470
|
+
console.log(" Use 'npx restforge payload sync' to update payload files automatically.");
|
|
976
1471
|
console.log();
|
|
977
1472
|
}
|
|
978
1473
|
|
|
@@ -1042,7 +1537,7 @@ class SchemaValidator {
|
|
|
1042
1537
|
console.log();
|
|
1043
1538
|
|
|
1044
1539
|
if (drift > 0) {
|
|
1045
|
-
console.log(
|
|
1540
|
+
console.log(" Use 'npx restforge payload sync' to update payload files automatically.");
|
|
1046
1541
|
console.log();
|
|
1047
1542
|
}
|
|
1048
1543
|
|
|
@@ -1091,15 +1586,43 @@ class SchemaValidator {
|
|
|
1091
1586
|
* File lama di-archive sebelum overwrite.
|
|
1092
1587
|
* @param {string|null} tableName - Nama table spesifik, atau null untuk semua
|
|
1093
1588
|
* @param {PayloadGenerator} generator - Instance PayloadGenerator untuk re-generate
|
|
1589
|
+
* @param {Object} [options]
|
|
1590
|
+
* @param {boolean} [options.expandFk] - Aktifkan FK JOIN expansion
|
|
1591
|
+
* @param {string} [options.fkColumns] - Daftar kolom referensi `table.column,...`
|
|
1094
1592
|
* @returns {Promise<Object>} { total, synced, skipped, error }
|
|
1095
1593
|
*/
|
|
1096
|
-
async runSync(tableName, generator) {
|
|
1594
|
+
async runSync(tableName, generator, options = {}) {
|
|
1595
|
+
const expandFk = options.expandFk === true;
|
|
1596
|
+
const fkColumns = options.fkColumns || null;
|
|
1597
|
+
|
|
1097
1598
|
console.log();
|
|
1098
1599
|
console.log('='.repeat(60));
|
|
1099
1600
|
console.log('SCHEMA SYNC - Update Payload from Database');
|
|
1100
1601
|
console.log('='.repeat(60));
|
|
1101
1602
|
console.log();
|
|
1102
1603
|
|
|
1604
|
+
// Mode expand-fk butuh target tabel tunggal dan --fk-columns yang valid.
|
|
1605
|
+
// Validasi upfront agar gagal cepat dengan pesan jelas.
|
|
1606
|
+
if (expandFk) {
|
|
1607
|
+
if (!tableName) {
|
|
1608
|
+
console.log(' [ERROR] --expand-fk requires --table=<table>');
|
|
1609
|
+
console.log();
|
|
1610
|
+
return { total: 0, synced: 0, skipped: 0, error: 1 };
|
|
1611
|
+
}
|
|
1612
|
+
// --fk-columns bersifat opsional: bila diisi, validasi format upfront;
|
|
1613
|
+
// bila kosong, mode auto-resolve (kolom display dipilih per FK saat
|
|
1614
|
+
// applyForeignKeyExpansion).
|
|
1615
|
+
if (fkColumns) {
|
|
1616
|
+
try {
|
|
1617
|
+
parseFkColumns(fkColumns);
|
|
1618
|
+
} catch (e) {
|
|
1619
|
+
console.log(` [ERROR] ${e.message}`);
|
|
1620
|
+
console.log();
|
|
1621
|
+
return { total: 0, synced: 0, skipped: 0, error: 1 };
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1103
1626
|
let payloads;
|
|
1104
1627
|
if (tableName) {
|
|
1105
1628
|
const found = this.findPayloadForTable(tableName);
|
|
@@ -1136,6 +1659,53 @@ class SchemaValidator {
|
|
|
1136
1659
|
continue;
|
|
1137
1660
|
}
|
|
1138
1661
|
|
|
1662
|
+
// Mode expand-fk: terapkan konfigurasi JOIN dari foreign key. Berbeda
|
|
1663
|
+
// dari sync biasa, mode ini tetap memproses meski kolom fisik sudah
|
|
1664
|
+
// in-sync (status 'ok'); bila ada drift kolom fisik, rekonsiliasi dulu
|
|
1665
|
+
// lewat regeneratePayload baru ekspansi FK diterapkan di atasnya.
|
|
1666
|
+
if (expandFk) {
|
|
1667
|
+
let workingPayload = payload;
|
|
1668
|
+
if (comparison.status === 'drift') {
|
|
1669
|
+
try {
|
|
1670
|
+
workingPayload = await this.regeneratePayload(payload, comparison, { fileName });
|
|
1671
|
+
} catch (e) {
|
|
1672
|
+
console.log(` [ERROR] ${fileName} - physical reconcile failed: ${e.message}`);
|
|
1673
|
+
errorCount++;
|
|
1674
|
+
continue;
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
let expansion;
|
|
1679
|
+
try {
|
|
1680
|
+
expansion = await this.applyForeignKeyExpansion(workingPayload, fkColumns, { fileName });
|
|
1681
|
+
} catch (e) {
|
|
1682
|
+
console.log(` [ERROR] ${fileName} - expand-fk failed: ${e.message}`);
|
|
1683
|
+
errorCount++;
|
|
1684
|
+
continue;
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
const archivePath = this.archiveFile(filePath);
|
|
1688
|
+
console.log(` [ARCHIVE] ${fileName} -> ${path.basename(archivePath)}`);
|
|
1689
|
+
try {
|
|
1690
|
+
// Tulis file SQL JOIN.
|
|
1691
|
+
const sqlAbsPath = path.join(this.outputDir, expansion.sqlRelPath);
|
|
1692
|
+
const sqlDir = path.dirname(sqlAbsPath);
|
|
1693
|
+
if (!fs.existsSync(sqlDir)) fs.mkdirSync(sqlDir, { recursive: true });
|
|
1694
|
+
fs.writeFileSync(sqlAbsPath, expansion.sqlContent, 'utf8');
|
|
1695
|
+
|
|
1696
|
+
fs.writeFileSync(filePath, JSON.stringify(expansion.updatedPayload, null, 4), 'utf8');
|
|
1697
|
+
console.log(` [QUERY] ${path.basename(sqlAbsPath)} written (${expansion.sqlRelPath})`);
|
|
1698
|
+
console.log(` [SYNCED] ${fileName} - FK expansion applied (${expansion.updatedPayload.fieldName.length} fields)`);
|
|
1699
|
+
synced++;
|
|
1700
|
+
} catch (e) {
|
|
1701
|
+
// Restore payload lama dari archive jika gagal menulis.
|
|
1702
|
+
fs.renameSync(archivePath, filePath);
|
|
1703
|
+
console.log(` [ERROR] ${fileName} - sync failed: ${e.message}`);
|
|
1704
|
+
errorCount++;
|
|
1705
|
+
}
|
|
1706
|
+
continue;
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1139
1709
|
if (comparison.status === 'ok') {
|
|
1140
1710
|
console.log(` [SKIP] ${fileName} - already in sync`);
|
|
1141
1711
|
skipped++;
|
|
@@ -1177,6 +1747,86 @@ class SchemaValidator {
|
|
|
1177
1747
|
return { total: payloads.length, synced, skipped, error: errorCount };
|
|
1178
1748
|
}
|
|
1179
1749
|
|
|
1750
|
+
/**
|
|
1751
|
+
* Terapkan FK JOIN expansion pada sebuah payload. Orkestrasi I/O
|
|
1752
|
+
* (introspeksi FK + kolom tabel referensi) lalu delegasi ke pure builder
|
|
1753
|
+
* `buildForeignKeyExpansion`. Tidak menulis file; caller (runSync) yang
|
|
1754
|
+
* menulis SQL + payload.
|
|
1755
|
+
*
|
|
1756
|
+
* Mode kolom:
|
|
1757
|
+
* - Eksplisit: `fkColumnsStr` diisi (`table.column,...`) -> dipakai apa adanya.
|
|
1758
|
+
* - Auto-resolve: `fkColumnsStr` kosong/null -> untuk SETIAP foreign key,
|
|
1759
|
+
* pilih satu kolom display dari tabel referensi via `pickDisplayColumn`
|
|
1760
|
+
* (heuristik name/nama -> code/kode -> primary key).
|
|
1761
|
+
*
|
|
1762
|
+
* @param {Object} payload - Payload saat ini (sudah direkonsiliasi kolom fisik bila perlu)
|
|
1763
|
+
* @param {string|null} fkColumnsStr - Nilai --fk-columns (`table.column,...`) atau null untuk auto
|
|
1764
|
+
* @param {Object} [ctx]
|
|
1765
|
+
* @param {string} [ctx.fileName] - Nama file payload untuk konteks pesan
|
|
1766
|
+
* @returns {Promise<{updatedPayload: Object, sqlRelPath: string, sqlContent: string}>}
|
|
1767
|
+
* @throws {Error} bila introspeksi/validasi gagal
|
|
1768
|
+
*/
|
|
1769
|
+
async applyForeignKeyExpansion(payload, fkColumnsStr, ctx = {}) {
|
|
1770
|
+
const tableName = payload.tableName;
|
|
1771
|
+
|
|
1772
|
+
const foreignKeys = typeof this.db.getForeignKeys === 'function'
|
|
1773
|
+
? await this.db.getForeignKeys(tableName)
|
|
1774
|
+
: [];
|
|
1775
|
+
if (!Array.isArray(foreignKeys) || foreignKeys.length === 0) {
|
|
1776
|
+
throw new Error(
|
|
1777
|
+
`Table "${tableName}" has no foreign keys; --expand-fk cannot resolve any reference`
|
|
1778
|
+
);
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
// Kolom fisik base table (buang kolom audit default agar tidak masuk SELECT base).
|
|
1782
|
+
const baseColumns = (await this.db.getColumns(tableName))
|
|
1783
|
+
.filter(c => !DEFAULT_AUDIT_COLUMNS.includes(c));
|
|
1784
|
+
|
|
1785
|
+
const isAuto = typeof fkColumnsStr !== 'string' || fkColumnsStr.trim().length === 0;
|
|
1786
|
+
const refColumnsMap = {};
|
|
1787
|
+
let fkSpec;
|
|
1788
|
+
|
|
1789
|
+
if (isAuto) {
|
|
1790
|
+
// Auto-resolve: satu kolom display per foreign key.
|
|
1791
|
+
fkSpec = [];
|
|
1792
|
+
for (const fk of foreignKeys) {
|
|
1793
|
+
const rt = fk && fk.references && fk.references.table;
|
|
1794
|
+
if (!rt) continue;
|
|
1795
|
+
const key = String(rt).toLowerCase();
|
|
1796
|
+
if (!refColumnsMap[key]) {
|
|
1797
|
+
refColumnsMap[key] = await this.db.getColumns(rt);
|
|
1798
|
+
}
|
|
1799
|
+
const refPk = typeof this.db.getPrimaryKey === 'function'
|
|
1800
|
+
? await this.db.getPrimaryKey(rt)
|
|
1801
|
+
: null;
|
|
1802
|
+
const display = pickDisplayColumn(refColumnsMap[key], refPk);
|
|
1803
|
+
if (!display) {
|
|
1804
|
+
throw new Error(
|
|
1805
|
+
`Could not auto-resolve a display column for referenced table "${rt}" ` +
|
|
1806
|
+
'(no name/code column and no primary key found). Provide --fk-columns explicitly.'
|
|
1807
|
+
);
|
|
1808
|
+
}
|
|
1809
|
+
fkSpec.push({ table: rt, column: display, raw: `${rt}.${display}` });
|
|
1810
|
+
console.log(` [AUTO] ${tableName} -> ${rt}.${display} (display column)`);
|
|
1811
|
+
}
|
|
1812
|
+
} else {
|
|
1813
|
+
// Eksplisit: parse spec lalu fetch kolom tabel referensi yang disebut.
|
|
1814
|
+
fkSpec = parseFkColumns(fkColumnsStr);
|
|
1815
|
+
const neededTables = new Set(fkSpec.map(s => s.table.toLowerCase()));
|
|
1816
|
+
for (const fk of foreignKeys) {
|
|
1817
|
+
const rt = fk && fk.references && fk.references.table;
|
|
1818
|
+
if (!rt) continue;
|
|
1819
|
+
const key = String(rt).toLowerCase();
|
|
1820
|
+
if (!neededTables.has(key)) continue;
|
|
1821
|
+
if (!refColumnsMap[key]) {
|
|
1822
|
+
refColumnsMap[key] = await this.db.getColumns(rt);
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
return buildForeignKeyExpansion(payload, fkSpec, foreignKeys, refColumnsMap, baseColumns);
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1180
1830
|
/**
|
|
1181
1831
|
* Regenerate payload berdasarkan perubahan yang terdeteksi.
|
|
1182
1832
|
* Mempertahankan konfigurasi payload lama (action, filters, dll),
|
|
@@ -1202,6 +1852,17 @@ class SchemaValidator {
|
|
|
1202
1852
|
: [];
|
|
1203
1853
|
const primaryKey = await this.db.getPrimaryKey(tableName) || oldPayload.primaryKey;
|
|
1204
1854
|
|
|
1855
|
+
// Enrich kolom (constraints + boolean) sekali; dipakai untuk searchable
|
|
1856
|
+
// columns (datatablesWhere) dan fieldValidation. tempGenerator menyediakan
|
|
1857
|
+
// method enrich/generate (instance method tanpa state).
|
|
1858
|
+
const tempGenerator = new PayloadGenerator();
|
|
1859
|
+
const booleanColumns = typeof this.db.getBooleanColumns === 'function'
|
|
1860
|
+
? await this.db.getBooleanColumns(tableName)
|
|
1861
|
+
: [];
|
|
1862
|
+
let enrichedColumns = tempGenerator.enrichDetailedColumnsWithConstraints(detailedColumns, constraints);
|
|
1863
|
+
enrichedColumns = tempGenerator.enrichDetailedColumnsWithBoolean(enrichedColumns, booleanColumns);
|
|
1864
|
+
const stringColumns = collectStringColumns(enrichedColumns);
|
|
1865
|
+
|
|
1205
1866
|
// Bangun fieldName baru: pertahankan urutan field lama, tambahkan field baru di akhir.
|
|
1206
1867
|
// Kolom yang sebelumnya dicantumkan user di payload (termasuk kolom audit default)
|
|
1207
1868
|
// dipertahankan. Kolom audit default yang tidak pernah ada di payload tidak
|
|
@@ -1225,26 +1886,42 @@ class SchemaValidator {
|
|
|
1225
1886
|
updatedPayload.primaryKey = primaryKey;
|
|
1226
1887
|
updatedPayload.fieldName = newFieldName;
|
|
1227
1888
|
|
|
1228
|
-
// Re-generate datatablesQuery
|
|
1889
|
+
// Re-generate datatablesQuery.
|
|
1890
|
+
// Guard `file:` reference: bila datatablesQuery existing menunjuk ke file
|
|
1891
|
+
// SQL external (mis. hasil --expand-fk: file:query/<table>-join.sql), JANGAN
|
|
1892
|
+
// timpa dengan SQL inline. Kolom JOIN dikelola di file SQL tersebut, dan
|
|
1893
|
+
// menimpanya akan menghapus konfigurasi JOIN sekaligus viewQuery. Hanya
|
|
1894
|
+
// datatablesQuery inline (atau kosong) yang diregenerasi.
|
|
1229
1895
|
const fieldsStr = newFieldName.join(', ');
|
|
1230
|
-
|
|
1896
|
+
const existingDq = typeof oldPayload.datatablesQuery === 'string'
|
|
1897
|
+
? oldPayload.datatablesQuery.trim()
|
|
1898
|
+
: '';
|
|
1899
|
+
if (!existingDq.startsWith('file:')) {
|
|
1900
|
+
updatedPayload.datatablesQuery = `select ${fieldsStr} from ${tableName}`;
|
|
1901
|
+
}
|
|
1231
1902
|
|
|
1232
|
-
// Re-generate exportQuery jika ada
|
|
1903
|
+
// Re-generate exportQuery jika ada (guard `file:` reference yang sama).
|
|
1233
1904
|
if (oldPayload.exportQuery) {
|
|
1234
|
-
|
|
1905
|
+
const existingEq = typeof oldPayload.exportQuery === 'string'
|
|
1906
|
+
? oldPayload.exportQuery.trim()
|
|
1907
|
+
: '';
|
|
1908
|
+
if (!existingEq.startsWith('file:')) {
|
|
1909
|
+
updatedPayload.exportQuery = `select ${fieldsStr} from ${tableName}`;
|
|
1910
|
+
}
|
|
1235
1911
|
}
|
|
1236
1912
|
|
|
1237
|
-
// Re-generate datatablesWhere
|
|
1913
|
+
// Re-generate datatablesWhere. Entri existing dipertahankan hanya bila masih
|
|
1914
|
+
// ada di database DAN bertipe string; kolom non-string (mis. integer/boolean)
|
|
1915
|
+
// dibuang karena memicu error runtime pada upper(col) LIKE upper(?). Pola
|
|
1916
|
+
// nama (id/audit) TIDAK diterapkan ke entri existing agar kustomisasi string
|
|
1917
|
+
// user yang sengaja ditambahkan tetap aman. Kolom baru dari drift ditambahkan
|
|
1918
|
+
// memakai heuristik default penuh (name-exclude + string).
|
|
1238
1919
|
if (oldPayload.datatablesWhere) {
|
|
1239
|
-
const excludePatterns = ['_id', '_at', '_by', 'created', 'updated', 'deleted'];
|
|
1240
1920
|
const validWhere = oldPayload.datatablesWhere.filter(
|
|
1241
|
-
w => w === 'all' || newFieldName.includes(w)
|
|
1921
|
+
w => w === 'all' || (newFieldName.includes(w) && stringColumns.has(w))
|
|
1242
1922
|
);
|
|
1243
|
-
// Tambahkan field baru yang searchable
|
|
1244
1923
|
for (const col of comparison.added) {
|
|
1245
|
-
|
|
1246
|
-
const isSearchable = !excludePatterns.some(pattern => fieldLower.includes(pattern));
|
|
1247
|
-
if (isSearchable && !validWhere.includes(col.column)) {
|
|
1924
|
+
if (isDefaultSearchableColumn(col.column, stringColumns) && !validWhere.includes(col.column)) {
|
|
1248
1925
|
// Sisipkan sebelum 'all'
|
|
1249
1926
|
const allIdx = validWhere.indexOf('all');
|
|
1250
1927
|
if (allIdx >= 0) {
|
|
@@ -1257,8 +1934,7 @@ class SchemaValidator {
|
|
|
1257
1934
|
updatedPayload.datatablesWhere = validWhere;
|
|
1258
1935
|
}
|
|
1259
1936
|
|
|
1260
|
-
// Re-generate dateTimeFields
|
|
1261
|
-
const tempGenerator = new PayloadGenerator();
|
|
1937
|
+
// Re-generate dateTimeFields (tempGenerator & enrichedColumns sudah disiapkan di atas)
|
|
1262
1938
|
const dateTimeFields = tempGenerator.generateDateTimeFields(columnTypes, newFieldName);
|
|
1263
1939
|
if (Object.keys(dateTimeFields).length > 0) {
|
|
1264
1940
|
updatedPayload.dateTimeFields = dateTimeFields;
|
|
@@ -1267,11 +1943,6 @@ class SchemaValidator {
|
|
|
1267
1943
|
}
|
|
1268
1944
|
|
|
1269
1945
|
// Re-generate fieldValidation
|
|
1270
|
-
const booleanColumns = typeof this.db.getBooleanColumns === 'function'
|
|
1271
|
-
? await this.db.getBooleanColumns(tableName)
|
|
1272
|
-
: [];
|
|
1273
|
-
let enrichedColumns = tempGenerator.enrichDetailedColumnsWithConstraints(detailedColumns, constraints);
|
|
1274
|
-
enrichedColumns = tempGenerator.enrichDetailedColumnsWithBoolean(enrichedColumns, booleanColumns);
|
|
1275
1946
|
const fieldValidation = tempGenerator.generateFieldValidation(enrichedColumns, newFieldName, primaryKey);
|
|
1276
1947
|
if (fieldValidation.length > 0) {
|
|
1277
1948
|
updatedPayload.fieldValidation = fieldValidation;
|
|
@@ -1297,6 +1968,12 @@ class SchemaValidator {
|
|
|
1297
1968
|
fileName: options.fileName
|
|
1298
1969
|
});
|
|
1299
1970
|
|
|
1971
|
+
// Built-in defaultScope berbasis is_active: tambahkan filter saat kolom
|
|
1972
|
+
// is_active ada di fieldName, atau lepas saat kolom sudah tidak ada lagi di
|
|
1973
|
+
// tabel (mis. kolom dihapus). Hanya menyentuh key is_active sehingga scope
|
|
1974
|
+
// kustom lain tetap dipertahankan (lihat catalogs/rdf/default-scope.md).
|
|
1975
|
+
applyIsActiveDefaultScope(updatedPayload, newFieldName.includes(IS_ACTIVE_COLUMN));
|
|
1976
|
+
|
|
1300
1977
|
return updatedPayload;
|
|
1301
1978
|
}
|
|
1302
1979
|
|
|
@@ -1371,5 +2048,13 @@ class SchemaValidator {
|
|
|
1371
2048
|
|
|
1372
2049
|
module.exports = {
|
|
1373
2050
|
PayloadGenerator,
|
|
1374
|
-
SchemaValidator
|
|
2051
|
+
SchemaValidator,
|
|
2052
|
+
parseFkColumns,
|
|
2053
|
+
deriveTableAlias,
|
|
2054
|
+
pickDisplayColumn,
|
|
2055
|
+
buildForeignKeyExpansion,
|
|
2056
|
+
collectStringColumns,
|
|
2057
|
+
isDefaultSearchableColumn,
|
|
2058
|
+
applyIsActiveDefaultScope,
|
|
2059
|
+
buildPayloadAdvisories
|
|
1375
2060
|
};
|