@carbonorm/carbonnode 4.0.1 → 5.0.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 +158 -49
- package/dist/api/executors/SqlExecutor.d.ts +6 -0
- package/dist/api/handlers/ExpressHandler.d.ts +2 -1
- package/dist/api/types/ormInterfaces.d.ts +12 -0
- package/dist/api/utils/sqlAllowList.d.ts +2 -0
- package/dist/index.cjs.js +247 -10
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.esm.js +246 -11
- package/dist/index.esm.js.map +1 -1
- package/package.json +1 -1
- package/scripts/assets/handlebars/C6.test.ts.handlebars +578 -32
- package/scripts/generateRestBindings.cjs +5 -5
- package/scripts/generateRestBindings.ts +5 -5
- package/src/__tests__/fixtures/createTestServer.ts +11 -3
- package/src/__tests__/fixtures/sqlResponses/actor.get.json +13 -0
- package/src/__tests__/fixtures/sqlResponses/sqlAllowList.blocked.json +3 -0
- package/src/__tests__/fixtures/sqlResponses/sqlAllowList.json +3 -0
- package/src/__tests__/sakila-db/C6.js +1 -1
- package/src/__tests__/sakila-db/C6.mysql.cnf +6 -0
- package/src/__tests__/sakila-db/C6.mysqldump.json +1 -0
- package/src/__tests__/sakila-db/C6.mysqldump.sql +720 -0
- package/src/__tests__/sakila-db/C6.sqlAllowList.json +94 -0
- package/src/__tests__/sakila-db/C6.test.ts +578 -32
- package/src/__tests__/sakila-db/C6.ts +1 -1
- package/src/__tests__/sakila-db/sqlResponses/C6.actor.delete.json +10 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.actor.delete.lookup.json +9 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.actor.get.json +14 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.actor.join.json +15 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.actor.post.json +12 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.actor.post.latest.json +14 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.actor.put.json +11 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.actor.put.lookup.json +16 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.actor.seed.json +14 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.address.delete.json +10 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.address.delete.lookup.json +9 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.address.fk.current.json +358 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.address.fk.referenced.json +158 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.address.get.json +22 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.address.join.json +24 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.address.post.json +16 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.address.post.latest.json +22 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.address.put.json +11 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.address.put.lookup.json +24 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.address.seed.json +22 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.category.delete.json +10 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.category.delete.lookup.json +9 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.category.get.json +13 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.category.join.json +14 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.category.post.json +11 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.category.post.latest.json +13 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.category.put.json +11 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.category.put.lookup.json +15 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.category.seed.json +13 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.city.delete.json +10 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.city.delete.lookup.json +9 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.city.fk.current.json +158 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.city.fk.referenced.json +133 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.city.get.json +14 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.city.join.json +15 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.city.post.json +12 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.city.post.latest.json +14 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.city.put.json +11 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.city.put.lookup.json +16 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.city.seed.json +14 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.country.delete.json +10 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.country.delete.lookup.json +9 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.country.get.json +13 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.country.join.json +15 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.country.post.json +11 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.country.post.latest.json +13 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.country.put.json +11 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.country.put.lookup.json +15 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.country.seed.json +13 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.customer.delete.json +10 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.customer.delete.lookup.json +9 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.customer.fk.current.json +283 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.customer.fk.referenced.json +358 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.customer.get.json +19 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.customer.join.json +29 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.customer.post.json +17 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.customer.post.latest.json +19 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.customer.put.json +11 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.customer.put.lookup.json +21 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.customer.seed.json +19 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.film.delete.json +10 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.film.delete.lookup.json +9 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.film.fk.current.json +383 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.film.fk.referenced.json +38 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.film.get.json +23 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.film.join.json +24 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.film.post.json +20 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.film.post.latest.json +23 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.film.put.json +11 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.film.put.lookup.json +25 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.film.seed.json +23 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.inventory.delete.json +10 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.inventory.delete.lookup.json +9 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.inventory.fk.current.json +158 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.inventory.fk.referenced.json +20 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.inventory.get.json +14 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.inventory.join.json +25 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.inventory.post.json +12 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.inventory.post.latest.json +14 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.inventory.put.json +11 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.inventory.put.lookup.json +16 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.inventory.seed.json +14 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.language.delete.json +10 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.language.delete.lookup.json +9 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.language.get.json +13 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.language.join.json +24 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.language.post.json +11 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.language.post.latest.json +13 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.language.put.json +11 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.language.put.lookup.json +15 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.language.seed.json +13 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.payment.delete.json +10 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.payment.delete.lookup.json +9 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.payment.fk.current.json +233 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.payment.fk.referenced.json +233 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.payment.get.json +17 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.payment.join.json +24 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.payment.post.json +15 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.payment.post.latest.json +17 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.payment.put.json +11 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.payment.put.lookup.json +19 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.payment.seed.json +17 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.rental.delete.json +10 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.rental.delete.lookup.json +9 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.rental.fk.current.json +233 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.rental.fk.referenced.json +34 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.rental.get.json +17 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.rental.join.json +24 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.rental.post.json +15 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.rental.post.latest.json +17 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.rental.put.json +11 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.rental.put.lookup.json +19 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.rental.seed.json +17 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.staff.fk.current.json +34 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.staff.fk.referenced.json +20 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.staff.get.json +21 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.staff.join.json +31 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.staff.seed.json +21 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.store.fk.current.json +20 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.store.fk.referenced.json +34 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.store.get.json +14 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.store.join.json +24 -0
- package/src/__tests__/sakila-db/sqlResponses/C6.store.seed.json +14 -0
- package/src/__tests__/sakila.generated.test.ts +31 -0
- package/src/__tests__/sqlAllowList.test.ts +135 -0
- package/src/api/executors/SqlExecutor.ts +156 -0
- package/src/api/handlers/ExpressHandler.ts +10 -1
- package/src/api/types/ormInterfaces.ts +15 -0
- package/src/api/utils/sqlAllowList.ts +54 -0
- package/src/index.ts +1 -0
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import mysql from 'mysql2/promise';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
5
|
import {
|
|
3
6
|
checkAllRequestsComplete,
|
|
7
|
+
normalizeSql,
|
|
8
|
+
type DetermineResponseDataType,
|
|
9
|
+
type OrmGenerics,
|
|
4
10
|
} from '@carbonorm/carbonnode';
|
|
5
11
|
import {
|
|
6
12
|
C6,
|
|
@@ -14,10 +20,139 @@ import {
|
|
|
14
20
|
afterAll,
|
|
15
21
|
} from 'vitest';
|
|
16
22
|
|
|
17
|
-
function toPascalCase(name) {
|
|
23
|
+
function toPascalCase(name: string) {
|
|
18
24
|
return name.replace(/(^|_)([a-z])/g, (_, __, c) => c.toUpperCase());
|
|
19
25
|
}
|
|
20
26
|
|
|
27
|
+
function stripPrefix(name: string) {
|
|
28
|
+
if (!C6.PREFIX) return name;
|
|
29
|
+
return name.startsWith(C6.PREFIX) ? name.slice(C6.PREFIX.length) : name;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getBinding(shortName: string) {
|
|
33
|
+
return C6.ORM?.[toPascalCase(shortName)] ?? C6[toPascalCase(shortName)];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function formatTimestamp() {
|
|
37
|
+
return new Date().toISOString().slice(0, 19).replace('T', ' ');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type RestResponse<G extends OrmGenerics = OrmGenerics> = DetermineResponseDataType<
|
|
41
|
+
G['RequestMethod'],
|
|
42
|
+
G['RestTableInterface']
|
|
43
|
+
>;
|
|
44
|
+
|
|
45
|
+
type RestPromise<G extends OrmGenerics = OrmGenerics> = Promise<RestResponse<G>>;
|
|
46
|
+
|
|
47
|
+
function unwrapResponse<G extends OrmGenerics = OrmGenerics>(
|
|
48
|
+
response: RestResponse<G> | null | undefined
|
|
49
|
+
) {
|
|
50
|
+
return response;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
54
|
+
const __dirname = path.dirname(__filename);
|
|
55
|
+
const sqlResponsesDir = path.join(__dirname, 'sqlResponses');
|
|
56
|
+
const sqlAllowListPath = path.join(__dirname, 'C6.sqlAllowList.json');
|
|
57
|
+
const sqlAllowListEntries = new Set<string>();
|
|
58
|
+
|
|
59
|
+
async function recordSqlResponse<G extends OrmGenerics = OrmGenerics>(
|
|
60
|
+
label: string,
|
|
61
|
+
response: RestResponse<G> | null | undefined
|
|
62
|
+
) {
|
|
63
|
+
if (!response) return;
|
|
64
|
+
const payload = unwrapResponse(response);
|
|
65
|
+
if (!payload) return;
|
|
66
|
+
const sqlValue = payload?.sql?.sql ?? (typeof payload?.sql === 'string' ? payload.sql : undefined);
|
|
67
|
+
if (typeof sqlValue === 'string') {
|
|
68
|
+
sqlAllowListEntries.add(normalizeSql(sqlValue));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
await mkdir(sqlResponsesDir, { recursive: true });
|
|
72
|
+
const filePath = path.join(sqlResponsesDir, `C6.${label}.json`);
|
|
73
|
+
await writeFile(filePath, JSON.stringify(payload, null, 2));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function finalizeSqlAllowList() {
|
|
77
|
+
await mkdir(sqlResponsesDir, { recursive: true });
|
|
78
|
+
const compiled = Array.from(sqlAllowListEntries).sort();
|
|
79
|
+
await writeFile(sqlAllowListPath, JSON.stringify(compiled, null, 2));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function executeAndRecord<G extends OrmGenerics = OrmGenerics>(
|
|
83
|
+
label: string,
|
|
84
|
+
fn: () => RestPromise<G>
|
|
85
|
+
) {
|
|
86
|
+
const result = await fn();
|
|
87
|
+
await recordSqlResponse<G>(label, result);
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function buildScalarValue(meta: any, columnName: string, seedRow: any) {
|
|
92
|
+
const seedValue = seedRow?.[columnName];
|
|
93
|
+
const mysqlType = String(meta?.MYSQL_TYPE ?? '').toLowerCase();
|
|
94
|
+
|
|
95
|
+
if (mysqlType === 'year') return new Date().getFullYear();
|
|
96
|
+
|
|
97
|
+
const geometryTypes = [
|
|
98
|
+
'geometry',
|
|
99
|
+
'point',
|
|
100
|
+
'linestring',
|
|
101
|
+
'polygon',
|
|
102
|
+
'multipoint',
|
|
103
|
+
'multilinestring',
|
|
104
|
+
'multipolygon',
|
|
105
|
+
'geometrycollection',
|
|
106
|
+
];
|
|
107
|
+
if (geometryTypes.some((type) => mysqlType.includes(type))) {
|
|
108
|
+
return "ST_GeomFromText('POINT(0 0)')";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (mysqlType === 'json') return {};
|
|
112
|
+
if (mysqlType === 'enum' || mysqlType === 'set') {
|
|
113
|
+
return seedValue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const isStringType = ['char', 'varchar', 'text', 'tinytext', 'mediumtext', 'longtext'].some((t) =>
|
|
117
|
+
mysqlType.includes(t)
|
|
118
|
+
);
|
|
119
|
+
if (isStringType) {
|
|
120
|
+
const value = `${columnName}_${Date.now()}`;
|
|
121
|
+
const maxLength = parseInt(meta?.MAX_LENGTH ?? '', 10);
|
|
122
|
+
if (Number.isFinite(maxLength) && maxLength > 0) {
|
|
123
|
+
return value.slice(0, maxLength);
|
|
124
|
+
}
|
|
125
|
+
return value;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const isDateType = ['date', 'time', 'datetime', 'timestamp', 'year'].some((t) => mysqlType.includes(t));
|
|
129
|
+
if (isDateType) return formatTimestamp();
|
|
130
|
+
|
|
131
|
+
const isNumericType = [
|
|
132
|
+
'int',
|
|
133
|
+
'decimal',
|
|
134
|
+
'numeric',
|
|
135
|
+
'float',
|
|
136
|
+
'double',
|
|
137
|
+
'real',
|
|
138
|
+
'bit',
|
|
139
|
+
].some((t) => mysqlType.includes(t));
|
|
140
|
+
if (isNumericType) {
|
|
141
|
+
if (typeof seedValue === 'number') return seedValue + 1;
|
|
142
|
+
return 1;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (mysqlType.includes('bool')) return 1;
|
|
146
|
+
|
|
147
|
+
if (['blob', 'binary', 'varbinary'].some((t) => mysqlType.includes(t))) {
|
|
148
|
+
return Buffer.from('00', 'hex');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (seedValue !== undefined) return seedValue;
|
|
152
|
+
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
21
156
|
async function waitForRequests(timeout = 10000) {
|
|
22
157
|
const start = Date.now();
|
|
23
158
|
while (!checkAllRequestsComplete()) {
|
|
@@ -28,6 +163,302 @@ async function waitForRequests(timeout = 10000) {
|
|
|
28
163
|
}
|
|
29
164
|
}
|
|
30
165
|
|
|
166
|
+
async function fetchSeedRow(binding: any, label: string) {
|
|
167
|
+
const result = await executeAndRecord(label, () =>
|
|
168
|
+
binding.Get({
|
|
169
|
+
[C6.PAGINATION]: { [C6.LIMIT]: 1 },
|
|
170
|
+
} as any)
|
|
171
|
+
);
|
|
172
|
+
const data = unwrapResponse(result);
|
|
173
|
+
return data?.rest?.[0];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function pickForeignKeyValue({
|
|
177
|
+
columnName,
|
|
178
|
+
referencedTable,
|
|
179
|
+
referencedColumn,
|
|
180
|
+
restBinding,
|
|
181
|
+
label,
|
|
182
|
+
}: {
|
|
183
|
+
columnName: string;
|
|
184
|
+
referencedTable: string;
|
|
185
|
+
referencedColumn: string;
|
|
186
|
+
restBinding: any;
|
|
187
|
+
label: string;
|
|
188
|
+
}) {
|
|
189
|
+
const referencedBinding = getBinding(referencedTable);
|
|
190
|
+
if (!referencedBinding) return undefined;
|
|
191
|
+
|
|
192
|
+
const [currentResult, referencedResult] = await Promise.all([
|
|
193
|
+
executeAndRecord(`${label}.fk.current`, () =>
|
|
194
|
+
restBinding.Get({
|
|
195
|
+
[C6.PAGINATION]: { [C6.LIMIT]: 25 },
|
|
196
|
+
} as any)
|
|
197
|
+
),
|
|
198
|
+
executeAndRecord(`${label}.fk.referenced`, () =>
|
|
199
|
+
referencedBinding.Get({
|
|
200
|
+
[C6.PAGINATION]: { [C6.LIMIT]: 25 },
|
|
201
|
+
} as any)
|
|
202
|
+
),
|
|
203
|
+
]);
|
|
204
|
+
|
|
205
|
+
const currentData = unwrapResponse(currentResult);
|
|
206
|
+
const referencedData = unwrapResponse(referencedResult);
|
|
207
|
+
|
|
208
|
+
const currentRows = currentData?.rest ?? [];
|
|
209
|
+
const currentValues = new Set(
|
|
210
|
+
currentRows
|
|
211
|
+
.map((row) => row?.[columnName])
|
|
212
|
+
.filter((value) => value !== undefined && value !== null)
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
const candidate = (referencedData?.rest ?? [])
|
|
216
|
+
.map((row) => row?.[referencedColumn])
|
|
217
|
+
.find((value) => value !== undefined && value !== null && !currentValues.has(value));
|
|
218
|
+
|
|
219
|
+
if (candidate !== undefined) return candidate;
|
|
220
|
+
|
|
221
|
+
if (currentValues.size < currentRows.length) {
|
|
222
|
+
return referencedData?.rest?.[0]?.[referencedColumn];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function buildUpdatedValue(meta: any, columnName: string, currentValue: any) {
|
|
229
|
+
const mysqlType = String(meta?.MYSQL_TYPE ?? '').toLowerCase();
|
|
230
|
+
const geometryTypes = [
|
|
231
|
+
'geometry',
|
|
232
|
+
'point',
|
|
233
|
+
'linestring',
|
|
234
|
+
'polygon',
|
|
235
|
+
'multipoint',
|
|
236
|
+
'multilinestring',
|
|
237
|
+
'multipolygon',
|
|
238
|
+
'geometrycollection',
|
|
239
|
+
];
|
|
240
|
+
|
|
241
|
+
if (mysqlType === 'year') return new Date().getFullYear();
|
|
242
|
+
if (geometryTypes.some((type) => mysqlType.includes(type))) {
|
|
243
|
+
return "ST_GeomFromText('POINT(1 1)')";
|
|
244
|
+
}
|
|
245
|
+
if (mysqlType === 'json') return { updated: true };
|
|
246
|
+
|
|
247
|
+
const isDateType = ['date', 'time', 'datetime', 'timestamp'].some((t) => mysqlType.includes(t));
|
|
248
|
+
if (isDateType) return formatTimestamp();
|
|
249
|
+
|
|
250
|
+
const isNumericType = [
|
|
251
|
+
'int',
|
|
252
|
+
'decimal',
|
|
253
|
+
'numeric',
|
|
254
|
+
'float',
|
|
255
|
+
'double',
|
|
256
|
+
'real',
|
|
257
|
+
'bit',
|
|
258
|
+
].some((t) => mysqlType.includes(t));
|
|
259
|
+
if (isNumericType) {
|
|
260
|
+
if (typeof currentValue === 'number') return currentValue + 1;
|
|
261
|
+
return 1;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (mysqlType.includes('bool')) return currentValue ? 0 : 1;
|
|
265
|
+
|
|
266
|
+
const isStringType = ['char', 'varchar', 'text', 'tinytext', 'mediumtext', 'longtext'].some((t) =>
|
|
267
|
+
mysqlType.includes(t)
|
|
268
|
+
);
|
|
269
|
+
if (isStringType) {
|
|
270
|
+
const base = `${columnName}_updated_${Date.now()}`;
|
|
271
|
+
const maxLength = parseInt(meta?.MAX_LENGTH ?? '', 10);
|
|
272
|
+
if (Number.isFinite(maxLength) && maxLength > 0) {
|
|
273
|
+
return base.slice(0, maxLength);
|
|
274
|
+
}
|
|
275
|
+
return base;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (currentValue instanceof Date) {
|
|
279
|
+
return formatTimestamp();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return currentValue ?? null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function normalizeForComparison(meta: any, value: any) {
|
|
286
|
+
if (value === undefined || value === null) return value;
|
|
287
|
+
const mysqlType = String(meta?.MYSQL_TYPE ?? '').toLowerCase();
|
|
288
|
+
|
|
289
|
+
const isDateType = ['date', 'time', 'datetime', 'timestamp', 'year'].some((t) => mysqlType.includes(t));
|
|
290
|
+
if (isDateType) {
|
|
291
|
+
const dateValue = value instanceof Date ? value : new Date(value);
|
|
292
|
+
const time = dateValue.getTime();
|
|
293
|
+
if (!Number.isNaN(time)) return time;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const isDecimalType = ['decimal', 'numeric'].some((t) => mysqlType.includes(t));
|
|
297
|
+
if (isDecimalType) {
|
|
298
|
+
const parsed = Number(value);
|
|
299
|
+
if (!Number.isNaN(parsed)) return parsed;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const isNumericType = [
|
|
303
|
+
'int',
|
|
304
|
+
'float',
|
|
305
|
+
'double',
|
|
306
|
+
'real',
|
|
307
|
+
'bit',
|
|
308
|
+
].some((t) => mysqlType.includes(t));
|
|
309
|
+
if (isNumericType) {
|
|
310
|
+
const parsed = Number(value);
|
|
311
|
+
if (!Number.isNaN(parsed)) return parsed;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return value;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function rowMatches(row: any, fields: Array<{ columnName: string; value: any; meta: any }>) {
|
|
318
|
+
if (!row) return false;
|
|
319
|
+
return fields.every(({ columnName, value, meta }) => {
|
|
320
|
+
const actual = normalizeForComparison(meta ?? {}, row?.[columnName]);
|
|
321
|
+
const expected = normalizeForComparison(meta ?? {}, value);
|
|
322
|
+
return actual === expected;
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function buildInsertPayload(restModel: any, restBinding: any, label: string) {
|
|
327
|
+
const seedRow = await fetchSeedRow(restBinding, `${label}.seed`);
|
|
328
|
+
if (!seedRow) return null;
|
|
329
|
+
|
|
330
|
+
const payload: Record<string, any> = {};
|
|
331
|
+
const where: Record<string, any> = {};
|
|
332
|
+
const compareFields: Array<{ columnName: string; value: any; meta: any }> = [];
|
|
333
|
+
const references: Record<string, any[]> = restModel.TABLE_REFERENCES ?? {};
|
|
334
|
+
const validations: Record<string, any> = restModel.TYPE_VALIDATION ?? {};
|
|
335
|
+
const columnMap: Record<string, string> = restModel.COLUMNS ?? {};
|
|
336
|
+
const metaByColumnName: Record<string, any> = {};
|
|
337
|
+
let missingRequired = false;
|
|
338
|
+
|
|
339
|
+
for (const [fullColumn, meta] of Object.entries(validations) as [string, any][]) {
|
|
340
|
+
const columnName = columnMap[fullColumn] ?? fullColumn.split('.').pop();
|
|
341
|
+
if (!columnName) continue;
|
|
342
|
+
metaByColumnName[columnName] = meta;
|
|
343
|
+
metaByColumnName[fullColumn] = meta;
|
|
344
|
+
|
|
345
|
+
if (meta.AUTO_INCREMENT) continue;
|
|
346
|
+
if (meta.SKIP_COLUMN_IN_POST) continue;
|
|
347
|
+
|
|
348
|
+
let value;
|
|
349
|
+
const refList = references[columnName];
|
|
350
|
+
if (Array.isArray(refList) && refList.length > 0) {
|
|
351
|
+
const ref = refList[0];
|
|
352
|
+
const referencedTable = stripPrefix(ref.TABLE);
|
|
353
|
+
value = await pickForeignKeyValue({
|
|
354
|
+
columnName,
|
|
355
|
+
referencedTable,
|
|
356
|
+
referencedColumn: ref.COLUMN,
|
|
357
|
+
restBinding,
|
|
358
|
+
label,
|
|
359
|
+
});
|
|
360
|
+
if (value === undefined) {
|
|
361
|
+
missingRequired = true;
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
} else {
|
|
365
|
+
value = buildScalarValue(meta, columnName, seedRow);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (value === undefined) {
|
|
369
|
+
if (seedRow?.[columnName] !== undefined) {
|
|
370
|
+
value = seedRow[columnName];
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (value === undefined) {
|
|
375
|
+
missingRequired = true;
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
payload[columnName] = value;
|
|
380
|
+
|
|
381
|
+
const mysqlType = String(meta?.MYSQL_TYPE ?? '').toLowerCase();
|
|
382
|
+
const geometryTypes = [
|
|
383
|
+
'geometry',
|
|
384
|
+
'point',
|
|
385
|
+
'linestring',
|
|
386
|
+
'polygon',
|
|
387
|
+
'multipoint',
|
|
388
|
+
'multilinestring',
|
|
389
|
+
'multipolygon',
|
|
390
|
+
'geometrycollection',
|
|
391
|
+
];
|
|
392
|
+
const isDateType = ['date', 'time', 'datetime', 'timestamp', 'year'].some((t) => mysqlType.includes(t));
|
|
393
|
+
const shouldSkipWhere =
|
|
394
|
+
isDateType
|
|
395
|
+
|| geometryTypes.some((type) => mysqlType.includes(type))
|
|
396
|
+
|| mysqlType === 'json'
|
|
397
|
+
|| Array.isArray(value)
|
|
398
|
+
|| value instanceof Date
|
|
399
|
+
|| (typeof Buffer !== 'undefined' && Buffer.isBuffer && Buffer.isBuffer(value));
|
|
400
|
+
|
|
401
|
+
if (!shouldSkipWhere && fullColumn && value !== undefined && value !== null) {
|
|
402
|
+
where[fullColumn] = value;
|
|
403
|
+
compareFields.push({ columnName, value, meta });
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (missingRequired) return null;
|
|
408
|
+
|
|
409
|
+
if (!Object.keys(payload).length) return null;
|
|
410
|
+
|
|
411
|
+
if (!Object.keys(where).length) return null;
|
|
412
|
+
|
|
413
|
+
return { payload, where, metaByColumnName, compareFields };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function buildJoinRequest(shortName: string, restModel: any) {
|
|
417
|
+
const baseTable = restModel.TABLE_NAME ?? shortName;
|
|
418
|
+
const references: Record<string, any[]> = restModel.TABLE_REFERENCES ?? {};
|
|
419
|
+
const referencedBy: Record<string, any[]> = restModel.TABLE_REFERENCED_BY ?? {};
|
|
420
|
+
|
|
421
|
+
const referenceEntries = Object.entries(references) as [string, any[]][];
|
|
422
|
+
if (referenceEntries.length > 0) {
|
|
423
|
+
const [localColumn, refs] = referenceEntries[0];
|
|
424
|
+
const ref = refs[0];
|
|
425
|
+
const joinTable = ref.TABLE;
|
|
426
|
+
const joinAlias = `${stripPrefix(ref.TABLE)}_ref`;
|
|
427
|
+
return {
|
|
428
|
+
[C6.SELECT]: ['*'],
|
|
429
|
+
[C6.JOIN]: {
|
|
430
|
+
[C6.INNER]: {
|
|
431
|
+
[`${joinTable} ${joinAlias}`]: {
|
|
432
|
+
[`${joinAlias}.${ref.COLUMN}`]: [C6.EQUAL, `${baseTable}.${localColumn}`],
|
|
433
|
+
},
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
[C6.PAGINATION]: { [C6.LIMIT]: 1 },
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const referencedByEntries = Object.entries(referencedBy) as [string, any[]][];
|
|
441
|
+
if (referencedByEntries.length > 0) {
|
|
442
|
+
const [localColumn, refs] = referencedByEntries[0];
|
|
443
|
+
const ref = refs[0];
|
|
444
|
+
const joinTable = ref.TABLE;
|
|
445
|
+
const joinAlias = `${stripPrefix(ref.TABLE)}_ref`;
|
|
446
|
+
return {
|
|
447
|
+
[C6.SELECT]: ['*'],
|
|
448
|
+
[C6.JOIN]: {
|
|
449
|
+
[C6.INNER]: {
|
|
450
|
+
[`${joinTable} ${joinAlias}`]: {
|
|
451
|
+
[`${joinAlias}.${ref.COLUMN}`]: [C6.EQUAL, `${baseTable}.${localColumn}`],
|
|
452
|
+
},
|
|
453
|
+
},
|
|
454
|
+
},
|
|
455
|
+
[C6.PAGINATION]: { [C6.LIMIT]: 1 },
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
|
|
31
462
|
describe('sakila-db generated C6 bindings', () => {
|
|
32
463
|
let pool;
|
|
33
464
|
|
|
@@ -43,48 +474,163 @@ describe('sakila-db generated C6 bindings', () => {
|
|
|
43
474
|
|
|
44
475
|
afterAll(async () => {
|
|
45
476
|
await pool.end();
|
|
477
|
+
await finalizeSqlAllowList();
|
|
46
478
|
});
|
|
47
479
|
|
|
48
|
-
for (const [shortName] of Object.entries(C6.TABLES)) {
|
|
49
|
-
const restBinding =
|
|
480
|
+
for (const [shortName, restModel] of Object.entries(C6.TABLES as Record<string, any>)) {
|
|
481
|
+
const restBinding = getBinding(shortName);
|
|
482
|
+
const tableModel = restModel as any;
|
|
50
483
|
if (!restBinding) continue;
|
|
51
484
|
|
|
52
485
|
it(`[${shortName}] GET`, async () => {
|
|
53
|
-
const result = await
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
486
|
+
const result = await executeAndRecord(`${shortName}.get`, () =>
|
|
487
|
+
restBinding.Get({
|
|
488
|
+
[C6.SELECT]: ['*'],
|
|
489
|
+
[C6.PAGINATION]: { [C6.LIMIT]: 1 },
|
|
490
|
+
} as any)
|
|
491
|
+
);
|
|
492
|
+
const data = unwrapResponse(result);
|
|
493
|
+
expect(Array.isArray(data?.rest)).toBe(true);
|
|
494
|
+
await waitForRequests();
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it(`[${shortName}] JOIN`, async () => {
|
|
498
|
+
const joinRequest = buildJoinRequest(shortName, tableModel);
|
|
499
|
+
if (!joinRequest) return;
|
|
500
|
+
|
|
501
|
+
const result = await executeAndRecord(`${shortName}.join`, () =>
|
|
502
|
+
restBinding.Get(joinRequest as any)
|
|
503
|
+
);
|
|
504
|
+
const data = unwrapResponse(result);
|
|
58
505
|
expect(Array.isArray(data?.rest)).toBe(true);
|
|
59
506
|
await waitForRequests();
|
|
60
507
|
});
|
|
61
|
-
}
|
|
62
508
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
509
|
+
it(`[${shortName}] POST/PUT/DELETE`, async () => {
|
|
510
|
+
const primaryKeys = tableModel.PRIMARY_SHORT ?? [];
|
|
511
|
+
if (primaryKeys.length !== 1) return;
|
|
66
512
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const originalLastName = data?.rest?.[0]?.last_name;
|
|
513
|
+
const payloadSpec = await buildInsertPayload(tableModel, restBinding, shortName);
|
|
514
|
+
if (!payloadSpec) return;
|
|
70
515
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
} as any);
|
|
516
|
+
await executeAndRecord(`${shortName}.post`, () =>
|
|
517
|
+
restBinding.Post(payloadSpec.payload as any)
|
|
518
|
+
);
|
|
75
519
|
|
|
76
|
-
|
|
77
|
-
[
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
data = result?.data ?? result;
|
|
81
|
-
expect(data?.rest?.[0]?.last_name).toBe('Updated');
|
|
520
|
+
const primaryFull = tableModel.PRIMARY?.[0];
|
|
521
|
+
const primaryKey = primaryKeys[0];
|
|
522
|
+
const primaryMeta = payloadSpec.metaByColumnName?.[primaryKey]
|
|
523
|
+
?? payloadSpec.metaByColumnName?.[primaryFull ?? ''];
|
|
82
524
|
|
|
83
|
-
|
|
84
|
-
[Actor.ACTOR_ID]: testId,
|
|
85
|
-
[Actor.LAST_NAME]: originalLastName,
|
|
86
|
-
} as any);
|
|
87
|
-
await waitForRequests();
|
|
88
|
-
});
|
|
89
|
-
});
|
|
525
|
+
let insertedRow: any;
|
|
90
526
|
|
|
527
|
+
if (primaryFull && primaryMeta?.AUTO_INCREMENT) {
|
|
528
|
+
const latestResult = await executeAndRecord(`${shortName}.post.latest`, () =>
|
|
529
|
+
restBinding.Get({
|
|
530
|
+
[C6.PAGINATION]: {
|
|
531
|
+
[C6.LIMIT]: 1,
|
|
532
|
+
[C6.ORDER]: { [primaryFull]: 'DESC' },
|
|
533
|
+
},
|
|
534
|
+
cacheResults: false,
|
|
535
|
+
} as any)
|
|
536
|
+
);
|
|
537
|
+
const latestData = unwrapResponse(latestResult);
|
|
538
|
+
insertedRow = latestData?.rest?.[0];
|
|
539
|
+
} else {
|
|
540
|
+
const lookupResult = await executeAndRecord(`${shortName}.post.lookup`, () =>
|
|
541
|
+
restBinding.Get({
|
|
542
|
+
[C6.WHERE]: payloadSpec.where,
|
|
543
|
+
[C6.PAGINATION]: { [C6.LIMIT]: 1 },
|
|
544
|
+
cacheResults: false,
|
|
545
|
+
} as any)
|
|
546
|
+
);
|
|
547
|
+
const lookupData = unwrapResponse(lookupResult);
|
|
548
|
+
insertedRow = (lookupData?.rest ?? []).find((row: any) =>
|
|
549
|
+
rowMatches(row, payloadSpec.compareFields ?? [])
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
if (!insertedRow && primaryFull) {
|
|
553
|
+
const fallbackResult = await executeAndRecord(`${shortName}.post.fallback`, () =>
|
|
554
|
+
restBinding.Get({
|
|
555
|
+
[C6.PAGINATION]: {
|
|
556
|
+
[C6.LIMIT]: 1,
|
|
557
|
+
[C6.ORDER]: { [primaryFull]: 'DESC' },
|
|
558
|
+
},
|
|
559
|
+
cacheResults: false,
|
|
560
|
+
} as any)
|
|
561
|
+
);
|
|
562
|
+
const fallbackData = unwrapResponse(fallbackResult);
|
|
563
|
+
insertedRow = fallbackData?.rest?.[0];
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
expect(insertedRow).toBeDefined();
|
|
568
|
+
|
|
569
|
+
if (!insertedRow) return;
|
|
570
|
+
|
|
571
|
+
const primaryValue = insertedRow[primaryKey];
|
|
572
|
+
if (primaryValue === undefined || primaryValue === null) return;
|
|
573
|
+
|
|
574
|
+
const foreignKeys = new Set(Object.keys(tableModel.TABLE_REFERENCES ?? {}));
|
|
575
|
+
const updateColumn = Object.keys(payloadSpec.payload)
|
|
576
|
+
.find((key) => {
|
|
577
|
+
if (key === primaryKey || foreignKeys.has(key)) return false;
|
|
578
|
+
const meta = payloadSpec.metaByColumnName?.[key];
|
|
579
|
+
const mysqlType = String(meta?.MYSQL_TYPE ?? '').toLowerCase();
|
|
580
|
+
const geometryTypes = [
|
|
581
|
+
'geometry',
|
|
582
|
+
'point',
|
|
583
|
+
'linestring',
|
|
584
|
+
'polygon',
|
|
585
|
+
'multipoint',
|
|
586
|
+
'multilinestring',
|
|
587
|
+
'multipolygon',
|
|
588
|
+
'geometrycollection',
|
|
589
|
+
];
|
|
590
|
+
return !geometryTypes.some((type) => mysqlType.includes(type));
|
|
591
|
+
})
|
|
592
|
+
?? Object.keys(payloadSpec.payload).find((key) => key !== primaryKey);
|
|
593
|
+
|
|
594
|
+
if (!updateColumn) return;
|
|
595
|
+
|
|
596
|
+
const currentValue = insertedRow[updateColumn];
|
|
597
|
+
const updateMeta = payloadSpec.metaByColumnName?.[updateColumn];
|
|
598
|
+
const updatedValue = buildUpdatedValue(updateMeta ?? {}, updateColumn, currentValue);
|
|
599
|
+
|
|
600
|
+
await executeAndRecord(`${shortName}.put`, () =>
|
|
601
|
+
restBinding.Put({
|
|
602
|
+
[primaryKey]: primaryValue,
|
|
603
|
+
[updateColumn]: updatedValue,
|
|
604
|
+
} as any)
|
|
605
|
+
);
|
|
606
|
+
|
|
607
|
+
const updatedResult = await executeAndRecord(`${shortName}.put.lookup`, () =>
|
|
608
|
+
restBinding.Get({
|
|
609
|
+
[primaryKey]: primaryValue,
|
|
610
|
+
cacheResults: false,
|
|
611
|
+
} as any)
|
|
612
|
+
);
|
|
613
|
+
const updatedData = unwrapResponse(updatedResult);
|
|
614
|
+
const normalizedActual = normalizeForComparison(updateMeta ?? {}, updatedData?.rest?.[0]?.[updateColumn]);
|
|
615
|
+
const normalizedExpected = normalizeForComparison(updateMeta ?? {}, updatedValue);
|
|
616
|
+
expect(normalizedActual).toBe(normalizedExpected);
|
|
617
|
+
|
|
618
|
+
await executeAndRecord(`${shortName}.delete`, () =>
|
|
619
|
+
restBinding.Delete({
|
|
620
|
+
[primaryKey]: primaryValue,
|
|
621
|
+
} as any)
|
|
622
|
+
);
|
|
623
|
+
|
|
624
|
+
const deletedResult = await executeAndRecord(`${shortName}.delete.lookup`, () =>
|
|
625
|
+
restBinding.Get({
|
|
626
|
+
[primaryKey]: primaryValue,
|
|
627
|
+
cacheResults: false,
|
|
628
|
+
} as any)
|
|
629
|
+
);
|
|
630
|
+
const deletedData = unwrapResponse(deletedResult);
|
|
631
|
+
expect(Array.isArray(deletedData?.rest)).toBe(true);
|
|
632
|
+
expect(deletedData?.rest?.length ?? 0).toBe(0);
|
|
633
|
+
await waitForRequests();
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
});
|
|
@@ -2009,7 +2009,7 @@ export type RestTableInterfaces = iActor
|
|
|
2009
2009
|
|
|
2010
2010
|
export const C6 : iC6Object<RestTableInterfaces> = {
|
|
2011
2011
|
...C6Constants,
|
|
2012
|
-
C6VERSION: '
|
|
2012
|
+
C6VERSION: '5.0.0',
|
|
2013
2013
|
IMPORT: async (tableName: string) : Promise<iDynamicApiImport> => {
|
|
2014
2014
|
|
|
2015
2015
|
tableName = tableName.toLowerCase();
|