UNPKG

@getanthill/datastore

Version:

Event-Sourced Datastore

528 lines (429 loc) 11.9 kB
import type { ModelConfig } from '../typings'; import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; import get from 'lodash/get'; import has from 'lodash/has'; interface Table { name: string; id?: number; fields?: Field[]; } interface Field { id?: number; name: string; table_id: number; table_name: string; base_type?: string; semantic_type?: string; visibility_type?: 'normal' | 'details-only' | 'sensitive'; } interface Graph { nodes: { id?: string; group?: number; }[]; edges: { source?: string; target?: string; value?: number; key?: string; correlation_field?: string; }[]; } interface MetabaseSdkConfig { namespace?: string; baseUrl?: string; timeout?: number; username?: string; password?: string; tables?: Table[]; fields?: Field[]; models?: ModelConfig[]; graph?: Graph; } const DEFAULT_HEADERS = Object.freeze({ 'Content-Type': 'application/json', Accept: 'application/json', }); export default class Metabase { static ERRORS = { UNAUTHENTICATED: new Error('Client must be authenticated'), }; config: MetabaseSdkConfig = { baseUrl: 'http://localhost:3000', }; axios: AxiosInstance; sessionToken = ''; constructor(config: MetabaseSdkConfig) { this.config = { ...this.config, ...config, }; this.axios = axios.create({ baseURL: this.config.baseUrl, timeout: this.config.timeout, headers: { ...DEFAULT_HEADERS, }, }); } static toSnakeCase(str: string) { return str.replace(/ +/g, '_').toLowerCase(); } static toMetabaseDisplayCase(str: string) { return str .split('_') .map((s) => s[0].toUpperCase() + s.slice(1)) .join(' '); } setSessionToken(token: any) { this.sessionToken = token; this.axios.defaults.headers.common['X-Metabase-Session'] = token; } async authenticate() { const { data: { id }, } = await this.axios.request({ method: 'post', url: '/api/session', data: { username: this.config.username, password: this.config.password, }, }); this.setSessionToken(id); return id; } async request(config: AxiosRequestConfig): Promise<any> { if (!this.sessionToken) { throw Metabase.ERRORS.UNAUTHENTICATED; } const { data } = await this.axios.request(config); return data; } getCurrentUser() { return this.request({ method: 'get', url: '/api/user/current', }); } getDatabases() { return this.request({ method: 'get', url: '/api/database', }); } getTableForeignKeys(tableId: number) { return this.request({ method: 'get', url: `/api/table/${tableId}/fks`, }); } getTableFields(tableId: number): Field[] { return this.getTables().find((t) => t.id === tableId)?.fields ?? []; } getField(fieldId: number) { return this.request({ method: 'get', url: `/api/field/${fieldId}`, }); } updateField(fieldId: number, field: Partial<Field>) { return this.request({ method: 'put', url: `/api/field/${fieldId}`, data: field, }); } async getDatabaseTables(databaseId: number): Promise<Table[]> { const metadata = await this.request({ method: 'get', url: `/api/database/${databaseId}/metadata`, params: { include_hidden: true, }, }); return metadata.tables; } updateTable(tableId: number, table: Partial<Table>) { return this.request({ method: 'put', url: `/api/table/${tableId}`, data: table, }); } /** Model schema sync */ static getNormalizedName(item: { name: string; nfc_path?: string[]; }): string { if (Array.isArray(item.nfc_path)) { let paths = item.nfc_path; if (['event', 'entity'].includes(paths[0])) { paths.shift(); } paths = paths.filter((p) => !!p); return paths.map((p) => Metabase.toSnakeCase(p)).join('.'); } return Metabase.toSnakeCase(item.name); } static getModelNameFromTable(table: Table): string { return Metabase.getNormalizedName(table).replace( /_(events|snapshots)$/, '', ); } static isEventsTable(table: Table): boolean { return Metabase.getNormalizedName(table).endsWith('_events'); } static isSnapshotsTable(table: Table): boolean { return Metabase.getNormalizedName(table).endsWith('_snapshots'); } static isCorrelationField( field: Field, model: Partial<ModelConfig>, ): boolean { const fieldName = Metabase.getNormalizedName(field); return model.correlation_field === fieldName; } static isEncryptedField(field: Field, model: Partial<ModelConfig>): boolean { const fieldName = Metabase.getNormalizedName(field); return (model.encrypted_fields ?? []).includes(fieldName); } static getFieldVisibility( field: Field, model: Partial<ModelConfig>, defaultValue = 'sensitive', ): string { if (Metabase.isEncryptedField(field, model) === true) { return 'sensitive'; } if (Metabase.isCorrelationField(field, model) === true) { return 'normal'; } const fieldName = Metabase.getNormalizedName(field); const isEventsTable = Metabase.isEventsTable({ name: field.table_name, }); if (isEventsTable === true && fieldName === 'type') { return 'normal'; } if (['created_at', 'updated_at', 'version'].includes(fieldName)) { return 'normal'; } if ( has( model, `schema.model.properties.${fieldName.split('.').join('.properties.')}`, ) ) { return 'normal'; } return defaultValue; } /** * @see https://github.com/metabase/metabase/blob/6a6327646964559e735c3557d8c39f5ceff5dcd8/shared/src/metabase/types.cljc */ static getBaseTypeFromJsonSchema( field: Field, model: Partial<ModelConfig>, fieldSchema: { type: string }, defaultBaseType: string | null = null, ) { if (fieldSchema.type === 'boolean') { return 'type/Boolean'; } if (fieldSchema.type === 'integer') { return 'type/Integer'; } if (fieldSchema.type === 'number') { return 'type/Float'; } return defaultBaseType; } /** * @see https://github.com/metabase/metabase/blob/6a6327646964559e735c3557d8c39f5ceff5dcd8/shared/src/metabase/types.cljc */ static getSemanticTypeFromJsonSchema( field: Field, model: Partial<ModelConfig>, fieldSchema: { type: string }, defaultSemanticType: string | null = null, ) { if (Metabase.isCorrelationField(field, model)) { return 'type/PK'; } const fieldName = Metabase.getNormalizedName(field); const isEventsTable = Metabase.isEventsTable({ name: field.table_name, }); if (isEventsTable === true && fieldName === 'type') { return 'type/Category'; } if (fieldName === 'name') { return 'type/Name'; } if (fieldName === 'description') { return 'type/Description'; } if (fieldName === 'created_at') { return 'type/CreationTimestamp'; } if (fieldName === 'updated_at') { return 'type/UpdatedTimestamp'; } if (fieldSchema.type === 'boolean') { return 'type/Category'; } return defaultSemanticType; } setTables(tables: Table[]): this { this.config.tables = tables; return this; } getTables(): Table[] { return this.config.tables ?? []; } getTableById(id: number): Table | undefined { return this.getTables().find((table) => table.id === id); } setFields(fields: Field[]): this { this.config.fields = fields; return this; } getFields(): Field[] { return this.config.fields ?? []; } setModels(models: ModelConfig[]): this { this.config.models = models; return this; } getModels(): ModelConfig[] { return this.config.models ?? []; } setGraph(graph: Graph): this { this.config.graph = graph; return this; } getGraph(): Graph { return ( this.config.graph ?? { nodes: [], edges: [], } ); } getModelFromTable(table: Table): ModelConfig | undefined { const modelName = Metabase.getModelNameFromTable(table); return this.getModels().find( (m) => modelName === m.name || `${this.config.namespace}_${m.name}` === modelName || `${m.db}_${m.name}` === modelName, ); } getTableUpdatePayload(table: Table): any { const model = this.getModelFromTable(table); if (!model) { return null; } const tableName = Metabase.getNormalizedName(table); const payload: any = { name: tableName, display_name: tableName, description: model.description ?? null, visibility_type: Metabase.isSnapshotsTable(table) === true ? 'hidden' : null, }; if (Metabase.isEventsTable(table) === true) { payload.description = 'Events associated to these entities. ' + (payload.description || ''); } return payload; } getFieldUpdatePayload(field: Field): any { const table = this.getTableById(field.table_id); if (!table) { return null; } field.table_name = table.name; const model = this.getModelFromTable({ name: field.table_name, }); if (!model) { return null; } const fieldName = Metabase.getNormalizedName(field); const isCorrelationField = Metabase.isCorrelationField(field, model); const fieldJsonSchema = get( model, `schema.model.properties.${fieldName}`, {}, ); const payload: any = { name: fieldName, display_name: fieldName, description: isCorrelationField === true ? 'Correlation field' : fieldJsonSchema?.description || null, base_type: Metabase.getBaseTypeFromJsonSchema( field, model, fieldJsonSchema, field.base_type, ), semantic_type: Metabase.getSemanticTypeFromJsonSchema( field, model, fieldJsonSchema, field.semantic_type, ), visibility_type: Metabase.getFieldVisibility( field, model, field.visibility_type, ), }; return this.getFieldUpdatePayloadWithForeignKeys(field, payload); } getFieldUpdatePayloadWithForeignKeys(field: Field, payload: any): any { const graph = this.getGraph(); const table = this.getTableById(field.table_id); if (!table) { return payload; } const model = this.getModelFromTable(table); if (!model) { return payload; } const fieldName = Metabase.getNormalizedName(field); const isCorrelationField = Metabase.isCorrelationField(field, model); const isEventsTable = Metabase.isEventsTable(table); const edges = graph.edges.filter( (f) => f.source === model.name && f.key === fieldName, ); if (isCorrelationField === true && isEventsTable === true) { const correlationField = this.getFields().find( (f) => Metabase.getNormalizedName(this.getTableById(f.table_id)!) === model.name && Metabase.getNormalizedName(f) === fieldName, ); if (correlationField) { payload.semantic_type = 'type/FK'; payload.fk_target_field_id = correlationField.id; } } else if (edges.length === 1) { const correlationField = this.getFields().find( (f) => Metabase.getNormalizedName(this.getTableById(f.table_id)!) === edges[0].target && Metabase.getNormalizedName(f) === edges[0].correlation_field, ); if (correlationField) { payload.semantic_type = 'type/FK'; payload.fk_target_field_id = correlationField.id; } } return payload; } }