@travetto/model-sql
Version:
SQL backing for the travetto model module, with real-time modeling support for SQL schemas.
160 lines (138 loc) • 5.9 kB
text/typescript
import { type AsyncContext, WithAsyncContext } from '@travetto/context';
import { ModelRegistryIndex } from '@travetto/model';
import { type Class } from '@travetto/runtime';
import { SchemaRegistryIndex, type SchemaFieldConfig } from '@travetto/schema';
import { Connected, Transactional } from './connection/decorator.ts';
import type { SQLDialect } from './dialect/base.ts';
import { SQLModelUtil } from './util.ts';
import type { Connection } from './connection/base.ts';
import type { VisitStack } from './types.ts';
type UpsertStructure = { dropIndex: string[], createIndex: string[], table: string[] };
const isSimpleField = (input: VisitStack | undefined): input is SchemaFieldConfig =>
!!input && (!('type' in input) || (input.type && !SchemaRegistryIndex.has(input.type)));
/**
* Manage creation/updating of all tables
*/
export class TableManager {
#dialect: SQLDialect;
context: AsyncContext;
constructor(context: AsyncContext, dialect: SQLDialect) {
this.#dialect = dialect;
this.context = context;
}
#exec<T = unknown>(sql: string): Promise<{ records: T[], count: number }> {
return this.#dialect.executeSQL<T>(sql);
}
/**
* Get a valid connection
*/
get connection(): Connection {
return this.#dialect.connection;
}
/**
* Create all needed tables for a given class
*/
async exportTables(cls: Class): Promise<string[]> {
const out: string[] = [];
for (const command of this.#dialect.getCreateAllTablesSQL(cls)) {
out.push(command);
}
const indices = ModelRegistryIndex.getConfig(cls).indices;
if (indices) {
for (const command of this.#dialect.getCreateAllIndicesSQL(cls, indices)) {
out.push(command);
}
}
return out;
}
async getUpsertTablesSQL(cls: Class): Promise<UpsertStructure> {
const sqlCommands: UpsertStructure = { dropIndex: [], createIndex: [], table: [] };
const onVisit = async (type: Class, fields: SchemaFieldConfig[], path: VisitStack[]): Promise<void> => {
const found = await this.#dialect.describeTable(this.#dialect.namespace(path));
const existingFields = new Map(found?.columns.map(column => [column.name, column]) ?? []);
const existingIndices = new Map(found?.indices.map(index => [index.name, index]) ?? []);
const model = path.length === 1 ? ModelRegistryIndex.getConfig(type) : undefined;
const requestedIndices = new Map((model?.indices ?? []).map(index => [this.#dialect.getIndexName(type, index), index]) ?? []);
// Manage fields
if (!existingFields.size) {
sqlCommands.table.push(this.#dialect.getCreateTableSQL(path));
} else { // Existing
// Fields
const requestedFields = new Map(fields.map(field => [field.name, field]));
const top = path.at(-1);
if (isSimpleField(top)) {
requestedFields.set(top.name, top);
}
for (const [column, field] of requestedFields.entries()) {
if (!existingFields.has(column)) {
sqlCommands.table.push(this.#dialect.getAddColumnSQL([...path, field]));
} else if (this.#dialect.isColumnChanged(field, existingFields.get(column)!)) {
sqlCommands.table.push(this.#dialect.getModifyColumnSQL([...path, field]));
}
}
// TODO: Handle dropping tables that are FK'd when no longer in use
for (const column of existingFields.keys()) {
if (!requestedFields.has(column)) {
sqlCommands.table.push(this.#dialect.getDropColumnSQL([...path, { name: column, type: undefined!, array: false }]));
}
}
}
// Manage indices
for (const index of requestedIndices.keys()) {
if (!existingIndices.has(index)) {
sqlCommands.createIndex.push(this.#dialect.getCreateIndexSQL(type, requestedIndices.get(index)!));
} else if (this.#dialect.isIndexChanged(requestedIndices.get(index)!, existingIndices.get(index)!)) {
sqlCommands.dropIndex.push(this.#dialect.getDropIndexSQL(type, existingIndices.get(index)!.name));
sqlCommands.createIndex.push(this.#dialect.getCreateIndexSQL(type, requestedIndices.get(index)!));
}
}
for (const index of existingIndices.keys()) {
if (!requestedIndices.has(index)) {
sqlCommands.dropIndex.push(this.#dialect.getDropIndexSQL(type, existingIndices.get(index)!.name));
}
}
};
const schema = SchemaRegistryIndex.getConfig(cls);
await SQLModelUtil.visitSchema(schema, {
onRoot: async ({ config, path, fields, descend }) => { await onVisit(config.class, fields, path); return descend(); },
onSub: async ({ config, path, fields, descend }) => { await onVisit(config.type, fields, path); return descend(); },
onSimple: async ({ config, path, fields }) => { await onVisit(config.type, fields, path); }
});
return sqlCommands;
}
async upsertTables(cls: Class): Promise<void> {
// Enforce id length
this.#dialect.enforceIdLength(cls);
const sqlCommands = await this.getUpsertTablesSQL(cls);
for (const key of ['dropIndex', 'table', 'createIndex'] as const) {
await Promise.all(sqlCommands[key].map(command => this.#exec(command)));
}
}
/**
* Drop all tables for a given class
*/
async dropTables(cls: Class): Promise<void> {
for (const command of this.#dialect.getDropAllTablesSQL(cls)) {
await this.#exec(command);
}
}
/**
* Drop all tables for a given class
*/
async truncateTables(cls: Class): Promise<void> {
for (const command of this.#dialect.getTruncateAllTablesSQL(cls)) {
await this.#exec(command);
}
}
}