Skip to content

Commit 7c132dc

Browse files
committed
Better alignment with JSON Schema
1 parent 4188ee3 commit 7c132dc

File tree

10 files changed

+172
-106
lines changed

10 files changed

+172
-106
lines changed

resources/account.json

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,28 @@
11
{
2-
"name": "accounts",
2+
"table": "accounts",
33
"type": "Account",
44
"properties": {
5-
"id": { "type": "integer", "required": true, "primaryKey": true, "description": "Unique identifier, auto-generated. It's the primary key." },
6-
"inserted": { "type": "date", "required": false, "dateOn": "insert", "description": "Timestamp when current record is inserted" },
7-
"updated": { "type": "date", "required": false, "dateOn": "update", "description": "Timestamp when current record is updated" },
8-
"etag": { "type": "string", "required": false, "maxLength": 1024, "description": "Possible ETag for all resources that are external. Allows for better synch-ing." },
9-
"comments": { "type": "string", "required": false, "maxLength": 8192, "fullText": true, "description": "General comments. Can be used for anything useful related to the instance." },
10-
"country": { "type": "string", "required": true, "maxLength": 16, "default": "'US'", "fullText": true, "description": "Country code" },
11-
"email": { "type": "string", "required": false, "maxLength": 128, "unique": true, "description": "Main email to communicate for that account" },
12-
"established": { "type": "date", "required": false, "maxLength": 6, "minimum": "2020-01-01", "description": "Date on which the account was established" },
13-
"enabled": { "type": "boolean", "required": true, "default": true, "description": "Whether it is enabled or not. Disabled instances will not be used." },
14-
"externalId": { "type": "string", "required": false, "maxLength": 512, "unique": true, "description": "External unique ID, used to refer to external accounts" },
15-
"phone": { "type": "string", "required": false, "maxLength": 128, "fullText": true, "description": "Handle associated with the account" },
16-
"name": { "type": "string", "required": true, "unique": true, "fullText": true, "description": "Descriptive name to identify the instance" },
17-
"preferences": { "type": "json", "required": true, "default": { "wrap": true, "minAge": 18 }, "description": "All the general options associated with the account." },
18-
"valueList": { "type": "json", "asExpression": "JSON_EXTRACT(preferences, '$.*')", "generatedType": "stored" }
5+
"id": { "type": "integer", "primaryKey": true, "description": "Unique identifier, auto-generated. It's the primary key." },
6+
"inserted": { "type": "date", "dateOn": "insert", "description": "Timestamp when current record is inserted" },
7+
"updated": { "type": "date", "dateOn": "update", "description": "Timestamp when current record is updated" },
8+
"etag": { "type": "string", "description": "Possible ETag for all resources that are external. Allows for better synch-ing." },
9+
"comments": { "type": "string", "maxLength": 8192, "description": "General comments. Can be used for anything useful related to the instance." },
10+
"country": { "type": "string", "default": "'US'", "description": "Country code" },
11+
"email": { "type": "string", "uniqueItems": true, "description": "Main email to communicate for that account" },
12+
"established": { "type": "date", "maxLength": 6, "minimum": "2020-01-01", "description": "Date on which the account was established" },
13+
"enabled": { "type": "boolean", "default": true, "description": "Whether it is enabled or not. Disabled instances will not be used." },
14+
"externalId": { "type": "string", "maxLength": 512, "uniqueItems": true, "description": "External unique ID, used to refer to external accounts" },
15+
"phone": { "type": "string", "description": "Handle associated with the account" },
16+
"name": { "type": "string", "uniqueItems": true, "description": "Descriptive name to identify the instance" },
17+
"preferences": { "type": "object", "default": { "wrap": true, "minAge": 18 }, "description": "All the general options associated with the account." },
18+
"valueList": { "type": "object", "as": "JSON_EXTRACT(preferences, '$.*')", "description": "Auto-generated field with values" }
1919
},
20+
"required": ["id", "country", "enabled", "name", "preferences"],
21+
"fullText": ["comments", "country", "phone", "name"],
2022
"indices": [
21-
{ "name": "inserted", "properties": ["inserted"] },
22-
{ "name": "updated", "properties": ["updated"] },
23-
{ "name": "valueList", "properties": ["id", "valueList", "enabled"], "array": 1 }
23+
{ "properties": ["inserted"] },
24+
{ "properties": ["updated"] },
25+
{ "properties": ["id", "valueList", "enabled"], "array": 1 }
2426
],
2527
"constraints": [
2628
{ "name": "email", "check": "email IS NULL OR email RLIKE '^[^@]+@[^@]+[.][^@]{2,}$'" },

resources/account.ts

Lines changed: 50 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,54 @@
1+
/**
2+
* Accounts class definition
3+
* @table accounts
4+
* @fullText comments, country, phone, name
5+
*/
16
export default class Account {
7+
/** Unique identifier, auto-generated. It's the primary key. @primaryKey */
8+
id!: number;
9+
10+
/** General comments. Can be used for anything useful related to the instance. @maxLength 8192 */
11+
comments?: string;
12+
13+
/** Country code */
14+
country = "US";
15+
216
/**
3-
* Internal auto-incremented ID
17+
* Main email to communicate for that account
18+
* @uniqueItems
19+
* @constraint email - email IS NULL OR email RLIKE '^[^@]+@[^@]+[.][^@]{2,}$'
20+
* */
21+
email?: string;
22+
23+
/** Date on which the account was established @maxLength 6 @minimum 2020-01-01 */
24+
established? = new Date();
25+
26+
/** Whether it is enabled or not. Disabled instances will not be used. */
27+
enabled = true;
28+
29+
/** External unique ID, used to refer to external accounts @maxLength 512 @uniqueItems */
30+
externalId?: string;
31+
32+
/** Handle associated with the account */
33+
name!: string;
34+
35+
/**
36+
* Descriptive name to identify the instance
37+
* @constraint phone - phone IS NULL OR phone RLIKE '^[0-9]{8,16}$'
438
*/
5-
id!: number;
6-
comments?: string;
7-
country = "US";
8-
email?: string;
9-
established = new Date();
10-
enabled = true;
11-
externalId?: string;
12-
name!: string;
13-
phone?: string;
14-
preferences: { [key: string]: boolean|number|string; } = { wrap: true, minAge: 18 };
15-
valueList: string[] = [];
16-
provider?: string;
17-
18-
constructor(data?: Pick<Account, "name"> & Partial<Account>) {
19-
Object.assign(this, data);
20-
}
39+
phone?: string;
40+
41+
/** All the general options associated with the account. */
42+
preferences: { [key: string]: boolean|number|string; } = { wrap: true, minAge: 18 };
43+
44+
/**
45+
* Auto-generated field with values
46+
* @as JSON_EXTRACT(preferences, '$.*')
47+
* @index id, valueList, enabled
48+
* */
49+
valueList: string[] = [];
50+
51+
constructor(data?: Pick<Account, "name"> & Partial<Account>) {
52+
Object.assign(this, data);
53+
}
2154
}

src/db.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ export class DB {
162162
static async connect(config: ClientConfig, schemas?: Schema[]): Promise<Client> {
163163
// Iterate over the schemas and map them by name and type if it exists
164164
schemas?.forEach((s) => {
165-
DB.#schemas.set(s.name, s);
165+
DB.#schemas.set(s.table!, s);
166166
if (s.type) DB.#schemas.set(s.type, s);
167167
});
168168
if (DB.client) return Promise.resolve(DB.client);
@@ -270,7 +270,7 @@ export class DB {
270270
const name = typeof target === "string" ? target : target.name;
271271
if (typeof target === "string") target = Object as unknown as Class<T>;
272272
if (!schema) schema = DB.#schemas.get(name);
273-
repository = new Repository(target, schema, schema?.name ?? name);
273+
repository = new Repository(target, schema, schema?.table ?? name);
274274
this.#repositories.set(target, repository as Repository<Identifiable>);
275275

276276
// Return repository

src/ddl.ts

Lines changed: 62 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ import type { Column, Constraint, Index, Relation, Schema } from "./types.ts";
22
import DB from "./db.ts";
33

44
const dataTypes = {
5+
array: "JSON",
56
boolean: "BOOLEAN",
67
date: "DATETIME",
78
integer: "INTEGER",
8-
json: "JSON",
9+
object: "JSON",
910
number: "DOUBLE",
1011
string: "VARCHAR",
1112
};
@@ -17,22 +18,48 @@ const serialType = {
1718
};
1819

1920
const _BaseSchema: DB.Schema = {
20-
name: "_BaseSchema",
21+
table: "_BaseSchema",
2122
properties: {
22-
id: { type: "integer", required: true, primaryKey: true, description: "Unique identifier, auto-generated. It's the primary key." },
23-
insertedAt: { type: "date", required: false, dateOn: "insert", description: "Timestamp when current record is inserted" },
24-
updatedAt: { type: "date", required: false, dateOn: "update", description: "Timestamp when current record is updated" },
25-
etag: { type: "string", required: false, maxLength: 1024, description: "Possible ETag for all resources that are external. Allows for better synch-ing." },
23+
id: { type: "integer", primaryKey: true, description: "Unique identifier, auto-generated. It's the primary key." },
24+
insertedAt: { type: "date", dateOn: "insert", description: "Timestamp when current record is inserted" },
25+
updatedAt: { type: "date", dateOn: "update", description: "Timestamp when current record is updated" },
26+
etag: { type: "string", maxLength: 1024, description: "Possible ETag for all resources that are external. Allows for better synch-ing." },
2627
},
2728
indices: [
28-
{ name: "insertedAt", properties: ["insertedAt"] },
29-
{ name: "updatedAt", properties: ["updatedAt"] },
29+
{ properties: ["insertedAt"] },
30+
{ properties: ["updatedAt"] },
3031
],
3132
};
3233

3334
export class DDL {
35+
static EXTENSIONS = ["as", "constraints", "dateOn", "fullText", "index", "primaryKey", "relations", "table"];
36+
3437
static padWidth = 4;
35-
static defaultWidth = 256;
38+
static defaultWidth = 128;
39+
static textWidth = 128;
40+
41+
/**
42+
* When using tools such as [TJS](https://github.com/YousefED/typescript-json-schema) to
43+
* generate JSON schemas from TypeScript classes, the resulting schema may need some
44+
* cleaning up. This function does that.
45+
*
46+
* @param schema - the tool-generated schema
47+
* @param type - the type of the schema which we may need to correct/override
48+
*/
49+
static cleanSchema(schema: Schema, type?: string, table?: string) {
50+
if (type) schema.type = type;
51+
if (table) schema.table = table;
52+
if (typeof(schema.fullText) === "string") schema.fullText = (schema.fullText as string).split(",").map(s => s.trim());
53+
schema.indices ??= [];
54+
Object.values(schema.properties).forEach((c) => {
55+
if (!c.type) c.type = "string";
56+
if (typeof c.primaryKey === "string") c.primaryKey = true;
57+
if (typeof c.uniqueItems === "string") c.uniqueItems = true;
58+
if (c.format === "date-time") c.type = "date";
59+
if (typeof c.index === "string") schema.indices!.push({ properties: (c.index as string).split(",").map(s => s.trim()) });
60+
});
61+
return schema;
62+
}
3663

3764
// Enhance schema with standard properties
3865
static enhanceSchema(schema: Schema, selected: string[] = ["id", "insertedAt", "updatedAt"]): Schema {
@@ -42,29 +69,30 @@ export class DDL {
4269

4370
// Select indices
4471
if (!schema.indices) schema.indices = [];
45-
for (const name of selected) {
46-
const index = _BaseSchema.indices?.find((i) => i.name === name);
47-
if (index) schema.indices.push(index);
48-
}
72+
if (selected.includes("insertedAt")) schema.indices.push({ properties: ["insertedAt"] });
73+
if (selected.includes("updatedAt")) schema.indices.push({ properties: ["updatedAt"] });
4974

5075
return schema;
5176
}
5277

5378
// Column generator
54-
static createColumn(dbType: string, name: string, column: Column, namePad: number, padWidth = DDL.padWidth, defaultWidth = DDL.defaultWidth): string {
79+
static createColumn(dbType: string, name: string, column: Column, required: boolean, namePad: number, padWidth = DDL.padWidth, defaultWidth = DDL.defaultWidth): string {
80+
if (typeof (column.default) === "string" && !column.default.startsWith("'") && !column.default.endsWith("'")) column.default = "'" + column.default + "'";
5581
if (typeof (column.default) === "object") column.default = "('" + JSON.stringify(column.default) + "')";
5682
if (column.dateOn === "insert") column.default = "CURRENT_TIMESTAMP";
5783
if (column.dateOn === "update") column.default = "CURRENT_TIMESTAMP" + ((dbType !== DB.Provider.MYSQL) ? "" : " ON UPDATE CURRENT_TIMESTAMP");
5884
const pad = "".padEnd(padWidth);
5985
let type = dataTypes[column.type as keyof typeof dataTypes];
60-
const autoIncrement = column.primaryKey && column.type === "integer";
61-
const length = column.maxLength || type.endsWith("CHAR") ? "(" + (column.maxLength ?? defaultWidth) + ")" : "";
62-
const nullable = column.primaryKey || column.required ? " NOT NULL" : "";
86+
if (column.maxLength! > this.textWidth) type = "TEXT";
87+
const primaryKey = (column.primaryKey !== undefined);
88+
const autoIncrement = primaryKey && column.type === "integer";
89+
const length = column.maxLength! < this.textWidth || type.endsWith("CHAR") ? "(" + (column.maxLength ?? defaultWidth) + ")" : "";
90+
const nullable = primaryKey || required ? " NOT NULL" : "";
6391
const gen = autoIncrement ? serialType[dbType as keyof typeof serialType] : "";
64-
const asExpression = column.asExpression && (typeof column.asExpression === "string" ? DB._sqlFilter(column.asExpression) : column.asExpression[dbType]);
65-
const as = asExpression ? " GENERATED ALWAYS AS (" + asExpression + ") " + (column.generatedType?.toUpperCase() || "VIRTUAL") : "";
92+
const expr = column.as && (typeof column.as === "string" ? DB._sqlFilter(column.as) : column.as[dbType]);
93+
const as = expr ? " GENERATED ALWAYS AS (" + expr + ") STORED" : "";
6694
const def = Object.hasOwn(column, "default") ? " DEFAULT " + column.default : "";
67-
const key = column.primaryKey ? " PRIMARY KEY" : (column.unique ? " UNIQUE" : "");
95+
const key = primaryKey ? " PRIMARY KEY" : (column.uniqueItems !== undefined ? " UNIQUE" : "");
6896
const comment = (dbType === DB.Provider.MYSQL) && column.description ? " COMMENT '" + column.description.replace(/'/g, "''") + "'" : "";
6997

7098
// Correct Postgres JSON type
@@ -75,19 +103,19 @@ export class DDL {
75103
}
76104

77105
// Index generator
78-
static createIndex(dbType: string, indice: Index, padWidth = 4, table: string): string {
106+
static createIndex(dbType: string, index: Index, padWidth = 4, table: string): string {
79107
const pad = "".padEnd(padWidth);
80-
const columns = [...indice.properties] as string[];
108+
const columns = [...index.properties] as string[];
109+
const name = columns.join("_");
81110

82111
// If there is an array expression, replace the column by it
83112
// TODO: multivalued indexes only supported on MYSQL for now, Postgres and SQLite will use the entire
84-
const subType = indice.subType ?? "CHAR(32)";
85-
if (indice.array !== undefined) {
86-
columns[indice.array] = "(CAST(" + columns[indice.array] + " AS " + subType + (dbType === DB.Provider.MYSQL ? " ARRAY" : "") + "))";
113+
const subType = index.subType ?? "CHAR(32)";
114+
if (index.array !== undefined) {
115+
columns[index.array] = "(CAST(" + columns[index.array] + " AS " + subType + (dbType === DB.Provider.MYSQL ? " ARRAY" : "") + "))";
87116
}
88117

89-
const name = indice.name ?? "";
90-
const unique = indice.unique ? "UNIQUE " : "";
118+
const unique = index.unique ? "UNIQUE " : "";
91119
return `${pad}CREATE ${unique}INDEX ${table}_${name} ON ${table} (${columns.join(",")});\n`;
92120
}
93121

@@ -138,14 +166,15 @@ export class DDL {
138166
const sqlite = dbType === DB.Provider.SQLITE;
139167

140168
// Create SQL
141-
const table = nameOverride ?? schema.name;
142-
const columns = Object.entries(schema.properties).map(([n, c]) => this.createColumn(dbType, n, c!, namePad)).join("");
143-
const relations = !sqlite && Object.entries(schema.relations || []).map(([n, r]) => this.createRelation(dbType, schema.name, n, r!)).join("") || "";
169+
const table = nameOverride ?? schema.table! ?? schema.type?.toLowerCase();
170+
const required = (n: string) => schema.required?.includes(n) || false;
171+
const columns = Object.entries(schema.properties).map(([n, c]) => this.createColumn(dbType, n, c!, required(n), namePad)).join("");
172+
const relations = !sqlite && Object.entries(schema.relations || []).map(([n, r]) => this.createRelation(dbType, table, n, r!)).join("") || "";
144173

145174
// Create constraints
146175
const filter = (c: Constraint) => !c.provider || c.provider === dbType;
147-
const columnConstraints = Object.entries(schema.properties || {}).map(([n, c]) => this.createColumnConstraint(dbType, schema.name, n, c));
148-
const independentConstraints = (schema.constraints || []).filter(filter).map((c) => this.createIndependentConstraint(dbType, schema.name, c));
176+
const columnConstraints = Object.entries(schema.properties || {}).map(([n, c]) => this.createColumnConstraint(dbType, table, n, c));
177+
const independentConstraints = (schema.constraints || []).filter(filter).map((c) => this.createIndependentConstraint(dbType, table, c));
149178
const constraints = !sqlite && [...columnConstraints, ...independentConstraints].join("") || "";
150179

151180
// Create sql
@@ -155,8 +184,7 @@ export class DDL {
155184
if (schema.indices) sql += "\n" + schema.indices?.map((i) => this.createIndex(dbType, i, 0, table)).join("");
156185

157186
// Full text index
158-
const fullTextColumns = Object.entries(schema.properties).filter(([_, c]) => c.fullText).map(([n, _]) => n);
159-
if (fullTextColumns.length) sql += this.createFullTextIndex(dbType, fullTextColumns, 0, table);
187+
if (schema.fullText?.length) sql += this.createFullTextIndex(dbType, schema.fullText, 0, table);
160188

161189
const fixDanglingComma = (sql: string) => sql.replace(/,\n\)/, "\n);");
162190
if (dbType === DB.Provider.POSTGRES) sql = this.#postgres(sql);
File renamed without changes.

0 commit comments

Comments
 (0)