@getanthill/datastore
Version:
Event-Sourced Datastore
528 lines (429 loc) • 11.9 kB
text/typescript
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;
}
}