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

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