@twin.org/entity-storage-connector-postgresql 0.0.3-next.1 → 0.0.3-next.11

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 CHANGED
@@ -1,6 +1,6 @@
1
- # TWIN Entity Storage Connector PostgreSql
1
+ # Entity Storage Connector PostgreSQL
2
2
 
3
- Entity Storage connector implementation using PostgreSql storage.
3
+ This package provides a PostgreSQL backend for relational persistence, transactions and advanced SQL features. It is designed to work with the wider storage ecosystem so applications can keep behaviour consistent across connectors and environments.
4
4
 
5
5
  ## Installation
6
6
 
@@ -8,18 +8,13 @@ Entity Storage connector implementation using PostgreSql storage.
8
8
  npm install @twin.org/entity-storage-connector-postgresql
9
9
  ```
10
10
 
11
- ## Testing
11
+ ## Docker
12
12
 
13
- The tests developed are functional tests and need an instance of PostgreSql up and running. To run PostgreSql locally:
13
+ To perform testing of this component it may be necessary to launch a local instance to communicate with.
14
14
 
15
- ```sh
16
- docker run -p 5444:5432 --name twin-entity-storage-postgresql --hostname postgres -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=password -d postgres
17
- ```
18
-
19
- Afterwards you can run the tests as follows:
20
-
21
- ```sh
22
- npm run test
15
+ ```shell
16
+ docker pull postgres:latest
17
+ docker run -d --name twin-entity-storage-postgresql -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=password -p 5444:5432 postgres:latest
23
18
  ```
24
19
 
25
20
  ## Examples
@@ -1,7 +1,7 @@
1
1
  // Copyright 2024 IOTA Stiftung.
2
2
  // SPDX-License-Identifier: Apache-2.0.
3
3
  import { ContextIdHelper, ContextIdStore } from "@twin.org/context";
4
- import { BaseError, Coerce, ComponentFactory, GeneralError, Guards, Is, ObjectHelper } from "@twin.org/core";
4
+ import { BaseError, Coerce, ComponentFactory, GeneralError, Guards, HealthStatus, Is, ObjectHelper } from "@twin.org/core";
5
5
  import { ComparisonOperator, EntitySchemaFactory, EntitySchemaHelper, EntitySchemaPropertyType, LogicalOperator, SortDirection } from "@twin.org/entity";
6
6
  import postgres from "postgres";
7
7
  /**
@@ -115,7 +115,7 @@ export class PostgreSqlEntityStorageConnector {
115
115
  tableName: this._config.tableName
116
116
  }
117
117
  });
118
- const createTableQuery = `CREATE TABLE ${this._config.tableName} (${this.mapPostgreSqlProperties(this._entitySchema)})`;
118
+ const createTableQuery = `CREATE TABLE "${this._config.tableName}" (${this.mapPostgreSqlProperties(this._entitySchema)})`;
119
119
  await dbConnection.unsafe(createTableQuery);
120
120
  await this.waitForTableExists();
121
121
  }
@@ -153,6 +153,45 @@ export class PostgreSqlEntityStorageConnector {
153
153
  className() {
154
154
  return PostgreSqlEntityStorageConnector.CLASS_NAME;
155
155
  }
156
+ /**
157
+ * Get the health of the component.
158
+ * @returns The health of the component.
159
+ */
160
+ async health() {
161
+ try {
162
+ const sql = await this.createConnection();
163
+ await sql `SELECT 1 FROM ${sql(this._config.tableName)} LIMIT 0`;
164
+ return [
165
+ {
166
+ source: PostgreSqlEntityStorageConnector.CLASS_NAME,
167
+ status: HealthStatus.Ok,
168
+ description: "healthDescription",
169
+ data: { tableName: this._config.tableName }
170
+ }
171
+ ];
172
+ }
173
+ catch {
174
+ return [
175
+ {
176
+ source: PostgreSqlEntityStorageConnector.CLASS_NAME,
177
+ status: HealthStatus.Error,
178
+ description: "healthDescription",
179
+ message: "connectionFailed",
180
+ data: { tableName: this._config.tableName }
181
+ }
182
+ ];
183
+ }
184
+ }
185
+ /**
186
+ * The component needs to be stopped when the node is closed.
187
+ * @returns Nothing.
188
+ */
189
+ async stop() {
190
+ if (this._connection) {
191
+ await this._connection.end();
192
+ this._connection = undefined;
193
+ }
194
+ }
156
195
  /**
157
196
  * Get the schema for the entities.
158
197
  * @returns The schema for the entities.
@@ -202,9 +241,17 @@ export class PostgreSqlEntityStorageConnector {
202
241
  if ((prop.type === EntitySchemaPropertyType.Object ||
203
242
  prop.type === EntitySchemaPropertyType.Array) &&
204
243
  typeof row[propColumn] === "string") {
205
- const rowValue = JSON.parse(rows[0][propColumn]);
244
+ let value;
245
+ try {
246
+ value = JSON.parse(rows[0][propColumn]);
247
+ }
248
+ catch {
249
+ // If JSON.parse fails, keep the value as string
250
+ // This handles cases where plain text was stored in Object/Array fields
251
+ value = rows[0][propColumn];
252
+ }
206
253
  delete rows[0][propColumn];
207
- rows[0][prop.property] = rowValue;
254
+ rows[0][prop.property] = value;
208
255
  }
209
256
  if (row[propColumn] === null) {
210
257
  rows[0][prop.property] = undefined;
@@ -249,7 +296,6 @@ export class PostgreSqlEntityStorageConnector {
249
296
  type: EntitySchemaPropertyType.String
250
297
  });
251
298
  ObjectHelper.propertySet(finalEntity, PostgreSqlEntityStorageConnector._PARTITION_KEY, partitionKey ?? PostgreSqlEntityStorageConnector._PARTITION_KEY_VALUE);
252
- ObjectHelper.propertySet(finalEntity, PostgreSqlEntityStorageConnector._PARTITION_KEY, partitionKey ?? PostgreSqlEntityStorageConnector._PARTITION_KEY_VALUE);
253
299
  const keys = [];
254
300
  const values = [];
255
301
  for (const prop of props) {
@@ -277,6 +323,68 @@ export class PostgreSqlEntityStorageConnector {
277
323
  }, err);
278
324
  }
279
325
  }
326
+ /**
327
+ * Set multiple entities in a batch.
328
+ * @param entities The entities to set.
329
+ * @returns Nothing.
330
+ */
331
+ async setBatch(entities) {
332
+ Guards.arrayValue(PostgreSqlEntityStorageConnector.CLASS_NAME, "entities", entities);
333
+ const contextIds = await ContextIdStore.getContextIds();
334
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
335
+ for (const entity of entities) {
336
+ EntitySchemaHelper.validateEntity(entity, this.getSchema());
337
+ }
338
+ try {
339
+ const props = [...(this._entitySchema.properties ?? [])];
340
+ props.unshift({
341
+ property: PostgreSqlEntityStorageConnector._PARTITION_KEY,
342
+ type: EntitySchemaPropertyType.String
343
+ });
344
+ const keys = props.map(p => p.property);
345
+ const allValues = [];
346
+ const rowPlaceholders = [];
347
+ for (const entity of entities) {
348
+ const finalEntity = ObjectHelper.clone(entity);
349
+ ObjectHelper.propertySet(finalEntity, PostgreSqlEntityStorageConnector._PARTITION_KEY, partitionKey ?? PostgreSqlEntityStorageConnector._PARTITION_KEY_VALUE);
350
+ const rowValues = [];
351
+ for (const prop of props) {
352
+ const val = finalEntity[prop.property];
353
+ allValues.push(Is.empty(val) ? null : val);
354
+ rowValues.push(`$${allValues.length}`);
355
+ }
356
+ rowPlaceholders.push(`(${rowValues.join(", ")})`);
357
+ }
358
+ let sql = `INSERT INTO "${this._config.tableName}"`;
359
+ sql += ` (${keys.map(key => `"${key}"`).join(", ")})`;
360
+ sql += ` VALUES ${rowPlaceholders.join(", ")}`;
361
+ sql += ` ON CONFLICT ("${PostgreSqlEntityStorageConnector._PARTITION_KEY}", "${this._primaryKeyProperty.property}")`;
362
+ sql += ` DO UPDATE SET ${keys.map(key => `"${key}" = EXCLUDED."${key}"`).join(", ")};`;
363
+ const dbConnection = await this.createConnection();
364
+ await dbConnection.unsafe(sql, allValues);
365
+ }
366
+ catch (err) {
367
+ throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "setBatchFailed", undefined, err);
368
+ }
369
+ }
370
+ /**
371
+ * Empty all the entities.
372
+ * @returns Nothing.
373
+ */
374
+ async empty() {
375
+ const contextIds = await ContextIdStore.getContextIds();
376
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
377
+ try {
378
+ const sql = `DELETE FROM "${this._config.tableName}" WHERE "${PostgreSqlEntityStorageConnector._PARTITION_KEY}" = $1`;
379
+ const dbConnection = await this.createConnection();
380
+ await dbConnection.unsafe(sql, [
381
+ partitionKey ?? PostgreSqlEntityStorageConnector._PARTITION_KEY_VALUE
382
+ ]);
383
+ }
384
+ catch (err) {
385
+ throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "emptyFailed", undefined, err);
386
+ }
387
+ }
280
388
  /**
281
389
  * Remove the entity.
282
390
  * @param id The id of the entity to remove.
@@ -313,6 +421,68 @@ export class PostgreSqlEntityStorageConnector {
313
421
  }, err);
314
422
  }
315
423
  }
424
+ /**
425
+ * Remove multiple entities by their primary key IDs.
426
+ * @param ids The ids of the entities to remove.
427
+ * @returns Nothing.
428
+ */
429
+ async removeBatch(ids) {
430
+ Guards.arrayValue(PostgreSqlEntityStorageConnector.CLASS_NAME, "ids", ids);
431
+ const contextIds = await ContextIdStore.getContextIds();
432
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
433
+ try {
434
+ const sql = `DELETE FROM "${this._config.tableName}" WHERE "${PostgreSqlEntityStorageConnector._PARTITION_KEY}" = $1 AND "${this._primaryKeyProperty.property}" = ANY($2)`;
435
+ const dbConnection = await this.createConnection();
436
+ await dbConnection.unsafe(sql, [
437
+ partitionKey ?? PostgreSqlEntityStorageConnector._PARTITION_KEY_VALUE,
438
+ ids
439
+ ]);
440
+ }
441
+ catch (err) {
442
+ throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "removeBatchFailed", undefined, err);
443
+ }
444
+ }
445
+ /**
446
+ * Teardown the entity storage by dropping the table.
447
+ * @param nodeLoggingComponentType The node logging component type.
448
+ * @returns True if the teardown process was successful.
449
+ */
450
+ async teardown(nodeLoggingComponentType) {
451
+ const nodeLogging = ComponentFactory.getIfExists(nodeLoggingComponentType);
452
+ await nodeLogging?.log({
453
+ level: "info",
454
+ source: PostgreSqlEntityStorageConnector.CLASS_NAME,
455
+ ts: Date.now(),
456
+ message: "tableDropping",
457
+ data: { tableName: this._config.tableName }
458
+ });
459
+ try {
460
+ const tableExists = await this.tableExists();
461
+ if (tableExists) {
462
+ const dbConnection = await this.createConnection();
463
+ await dbConnection.unsafe(`DROP TABLE "${this._config.tableName}";`);
464
+ await this.waitForTableNotExists();
465
+ }
466
+ await nodeLogging?.log({
467
+ level: "info",
468
+ source: PostgreSqlEntityStorageConnector.CLASS_NAME,
469
+ ts: Date.now(),
470
+ message: "tableDropped",
471
+ data: { tableName: this._config.tableName }
472
+ });
473
+ return true;
474
+ }
475
+ catch (err) {
476
+ await nodeLogging?.log({
477
+ level: "error",
478
+ source: PostgreSqlEntityStorageConnector.CLASS_NAME,
479
+ ts: Date.now(),
480
+ message: "teardownFailed",
481
+ error: BaseError.fromError(err)
482
+ });
483
+ return false;
484
+ }
485
+ }
316
486
  /**
317
487
  * Find all the entities which match the conditions.
318
488
  * @param conditions The conditions to match for the entities.
@@ -367,9 +537,17 @@ export class PostgreSqlEntityStorageConnector {
367
537
  if ((prop.type === EntitySchemaPropertyType.Object ||
368
538
  prop.type === EntitySchemaPropertyType.Array) &&
369
539
  Is.string(row[propColumn])) {
370
- const rowValue = JSON.parse(row[propColumn]);
540
+ let value;
541
+ try {
542
+ value = JSON.parse(row[propColumn]);
543
+ }
544
+ catch {
545
+ // If JSON.parse fails, keep the value as string
546
+ // This handles cases where plain text was stored in Object/Array fields
547
+ value = row[propColumn];
548
+ }
371
549
  delete row[propColumn];
372
- row[prop.property] = rowValue;
550
+ row[prop.property] = value;
373
551
  }
374
552
  if (row[propColumn] === null) {
375
553
  row[prop.property] = undefined;
@@ -394,21 +572,19 @@ export class PostgreSqlEntityStorageConnector {
394
572
  }
395
573
  }
396
574
  /**
397
- * Drop the table.
398
- * @returns Nothing.
575
+ * Count all the entities which match the conditions.
576
+ * @returns The total count of entities in the storage.
399
577
  */
400
- async tableDrop() {
578
+ async count() {
401
579
  try {
402
- const tableExists = await this.tableExists();
403
- if (!tableExists) {
404
- return;
405
- }
406
- const dbConnection = await this.createConnection();
407
- await dbConnection.unsafe(`DROP TABLE ${this._config.tableName};`);
408
- await this.waitForTableNotExists();
580
+ const contextIds = await ContextIdStore.getContextIds();
581
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
582
+ const sql = await this.createConnection();
583
+ const result = await sql `SELECT COUNT(*) AS count FROM ${sql(this._config.tableName)} WHERE "partitionId" = ${partitionKey ?? PostgreSqlEntityStorageConnector._PARTITION_KEY_VALUE}`;
584
+ return Number(result[0].count);
409
585
  }
410
- catch {
411
- // Ignore errors
586
+ catch (err) {
587
+ throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "countFailed", undefined, err);
412
588
  }
413
589
  }
414
590
  /**
@@ -567,40 +743,99 @@ export class PostgreSqlEntityStorageConnector {
567
743
  const placeholders = inValues.map((_, index) => `$${valueIndex + index}`).join(", ");
568
744
  return `"${prop}" IN (${placeholders})`;
569
745
  }
746
+ // null/undefined must use IS NULL / IS NOT NULL — never a parameterised placeholder.
747
+ // Passing undefined through propertyToDbValue() coerces it to NaN for number fields
748
+ // (Number(undefined) === NaN), and null coerces to 0 (Number(null) === 0), both of
749
+ // which produce semantically wrong or invalid SQL.
750
+ if (comparator.value === null || comparator.value === undefined) {
751
+ if (comparator.comparison === ComparisonOperator.Equals ||
752
+ comparator.comparison === ComparisonOperator.NotEquals) {
753
+ const nullCheck = comparator.comparison === ComparisonOperator.Equals ? "IS NULL" : "IS NOT NULL";
754
+ if (comparator.property.split(".").length > 1) {
755
+ const rootProp = comparator.property.split(".")[0];
756
+ const nestedParts = comparator.property.split(".").slice(1);
757
+ const jsonPath = nestedParts
758
+ .map((p, i, arr) => (i === arr.length - 1 ? `->> '${p}'` : `-> '${p}'`))
759
+ .join("");
760
+ const jsonTextExpr = `("${rootProp}"::jsonb ${jsonPath})`;
761
+ return `${jsonTextExpr} ${nullCheck}`;
762
+ }
763
+ return `"${prop}" ${nullCheck}`;
764
+ }
765
+ }
570
766
  const dbValue = this.propertyToDbValue(comparator.value, type);
571
767
  values.push(dbValue);
572
768
  if (comparator.property.split(".").length > 1) {
573
- const jsonPath = comparator.property
574
- .split(".")
575
- .slice(1)
769
+ const rootProp = comparator.property.split(".")[0];
770
+ const nestedParts = comparator.property.split(".").slice(1);
771
+ const rootSchema = this._entitySchema.properties?.find(p => p.property === rootProp);
772
+ const isArray = rootSchema?.type === EntitySchemaPropertyType.Array;
773
+ const jsonPath = nestedParts
576
774
  .map((p, i, arr) => (i === arr.length - 1 ? `->> '${p}'` : `-> '${p}'`))
577
775
  .join("");
578
- return `("${comparator.property.split(".")[0]}"::jsonb ${jsonPath}) = $${valueIndex}`;
579
- }
580
- else if (comparator.comparison === ComparisonOperator.Equals) {
581
- return `"${prop}" = $${valueIndex}`;
582
- }
583
- else if (comparator.comparison === ComparisonOperator.NotEquals) {
584
- return `"${prop}" <> $${valueIndex}`;
585
- }
586
- else if (comparator.comparison === ComparisonOperator.GreaterThan) {
587
- return `"${prop}" > $${valueIndex}`;
588
- }
589
- else if (comparator.comparison === ComparisonOperator.LessThan) {
590
- return `"${prop}" < $${valueIndex}`;
591
- }
592
- else if (comparator.comparison === ComparisonOperator.GreaterThanOrEqual) {
593
- return `"${prop}" >= $${valueIndex}`;
594
- }
595
- else if (comparator.comparison === ComparisonOperator.LessThanOrEqual) {
596
- return `"${prop}" <= $${valueIndex}`;
776
+ const jsonTextExpr = `("${rootProp}"::jsonb ${jsonPath})`;
777
+ switch (comparator.comparison) {
778
+ case ComparisonOperator.Includes: {
779
+ values.pop();
780
+ values.push(`%${String(comparator.value).toLowerCase()}%`);
781
+ if (isArray) {
782
+ const elemPath = nestedParts
783
+ .map((p, i, arr) => (i === arr.length - 1 ? `->>'${p}'` : `->'${p}'`))
784
+ .join("");
785
+ return `EXISTS (SELECT 1 FROM jsonb_array_elements("${rootProp}") elem WHERE LOWER(elem${elemPath}) ILIKE $${valueIndex})`;
786
+ }
787
+ return `LOWER(${jsonTextExpr}) ILIKE $${valueIndex}`;
788
+ }
789
+ case ComparisonOperator.NotEquals:
790
+ return `${jsonTextExpr} <> $${valueIndex}`;
791
+ case ComparisonOperator.GreaterThan:
792
+ return `${jsonTextExpr} > $${valueIndex}`;
793
+ case ComparisonOperator.LessThan:
794
+ return `${jsonTextExpr} < $${valueIndex}`;
795
+ case ComparisonOperator.GreaterThanOrEqual:
796
+ return `${jsonTextExpr} >= $${valueIndex}`;
797
+ case ComparisonOperator.LessThanOrEqual:
798
+ return `${jsonTextExpr} <= $${valueIndex}`;
799
+ default:
800
+ return `${jsonTextExpr} = $${valueIndex}`;
801
+ }
597
802
  }
598
- else if (comparator.comparison === ComparisonOperator.Includes) {
599
- return `EXISTS (SELECT 1 FROM jsonb_array_elements("${prop}") elem WHERE elem @> $${valueIndex}::jsonb)`;
803
+ switch (comparator.comparison) {
804
+ case ComparisonOperator.Equals:
805
+ if (Is.object(comparator.value) || Is.array(comparator.value)) {
806
+ return `"${prop}" = $${valueIndex}::jsonb`;
807
+ }
808
+ return `"${prop}" = $${valueIndex}`;
809
+ case ComparisonOperator.NotEquals:
810
+ if (Is.object(comparator.value) || Is.array(comparator.value)) {
811
+ return `"${prop}" != $${valueIndex}::jsonb`;
812
+ }
813
+ return `"${prop}" <> $${valueIndex}`;
814
+ case ComparisonOperator.GreaterThan:
815
+ return `"${prop}" > $${valueIndex}`;
816
+ case ComparisonOperator.LessThan:
817
+ return `"${prop}" < $${valueIndex}`;
818
+ case ComparisonOperator.GreaterThanOrEqual:
819
+ return `"${prop}" >= $${valueIndex}`;
820
+ case ComparisonOperator.LessThanOrEqual:
821
+ return `"${prop}" <= $${valueIndex}`;
822
+ case ComparisonOperator.Includes: {
823
+ if (type === EntitySchemaPropertyType.String) {
824
+ return `"${prop}" ILIKE '%' || $${valueIndex} || '%'`;
825
+ }
826
+ if (type === EntitySchemaPropertyType.Array || type === EntitySchemaPropertyType.Object) {
827
+ return `EXISTS (SELECT 1 FROM jsonb_array_elements("${prop}") elem WHERE elem @> $${valueIndex}::jsonb)`;
828
+ }
829
+ throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "comparisonNotSupported", {
830
+ comparison: comparator.comparison,
831
+ type
832
+ });
833
+ }
834
+ default:
835
+ throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "comparisonNotSupported", {
836
+ comparison: comparator.comparison
837
+ });
600
838
  }
601
- throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "comparisonNotSupported", {
602
- comparison: comparator.comparison
603
- });
604
839
  }
605
840
  /**
606
841
  * Format a value to insert into DB.
@@ -610,21 +845,19 @@ export class PostgreSqlEntityStorageConnector {
610
845
  * @internal
611
846
  */
612
847
  propertyToDbValue(value, type) {
613
- if (type === "string") {
848
+ if (type === EntitySchemaPropertyType.String) {
614
849
  return String(value);
615
850
  }
616
- else if (type === "number") {
851
+ else if (type === EntitySchemaPropertyType.Number) {
617
852
  return Number(value);
618
853
  }
619
- else if (type === "boolean") {
854
+ else if (type === EntitySchemaPropertyType.Boolean) {
620
855
  return Boolean(value);
621
856
  }
622
- else if (type === "array") {
857
+ else if (type === EntitySchemaPropertyType.Object ||
858
+ type === EntitySchemaPropertyType.Array) {
623
859
  return value;
624
860
  }
625
- if (Is.object(value)) {
626
- return JSON.stringify(value);
627
- }
628
861
  return value;
629
862
  }
630
863
  /**
@@ -681,7 +914,48 @@ export class PostgreSqlEntityStorageConnector {
681
914
  });
682
915
  const columnDefinitions = props
683
916
  .map(prop => {
684
- const sqlType = sqlTypeMap[prop.type] || "TEXT";
917
+ let sqlType = sqlTypeMap[prop.type] || "TEXT";
918
+ if (prop.format) {
919
+ switch (prop.type) {
920
+ case EntitySchemaPropertyType.String:
921
+ switch (prop.format) {
922
+ case "uuid":
923
+ sqlType = "UUID";
924
+ break;
925
+ }
926
+ break;
927
+ case EntitySchemaPropertyType.Number:
928
+ switch (prop.format) {
929
+ case "float":
930
+ sqlType = "REAL";
931
+ break;
932
+ case "double":
933
+ sqlType = "DOUBLE PRECISION";
934
+ break;
935
+ }
936
+ break;
937
+ case EntitySchemaPropertyType.Integer:
938
+ switch (prop.format) {
939
+ case "int8":
940
+ case "uint8":
941
+ sqlType = "SMALLINT";
942
+ break;
943
+ case "int16":
944
+ sqlType = "SMALLINT";
945
+ break;
946
+ case "uint16":
947
+ case "int32":
948
+ sqlType = "INTEGER";
949
+ break;
950
+ case "uint32":
951
+ case "int64":
952
+ case "uint64":
953
+ sqlType = "BIGINT";
954
+ break;
955
+ }
956
+ break;
957
+ }
958
+ }
685
959
  const columnName = String(prop.property);
686
960
  const nullable = prop.optional ? " NULL" : " NOT NULL";
687
961
  if (prop.isPrimary) {