📄

supabase cliから作成したschemaをもとに簡易的なテーブル定義書を作成する方法

2023/09/14に公開

手順

  1. supabase cliを導入します ※参考「Supabase CLI | Supabase Docs
  2. supabase gen types typescript --linked > schema.tsのようにしてDBからスキーマを作成します
    2-1. 2の前にsupabase loginsupabase link --project-ref <project-id>するなどしてlinkしておく必要があります
    2-2. もしローカルの開発環境から持ってくるのであればsupabase gen types typescript --local > schema.tsのようになります
  3. 作成したschema.tsをtypescript compiler APIを利用して解析します
  4. 解析した情報からtable_name.mdのようなファイルを作成し、それをテーブル定義書とします

作成ファイル例

### Table Name: products

#### Column Information

| Column      | Type          | Foreign Key          |
| ----------- | ------------- | -------------------- |
| category_id | number        | iherb_categories(id) |
| created_at  | string        |                      |
| id          | number        |                      |
| name        | string        |                      |
| price       | number | null |                      |
| updated_at  | string        |                      |

schema.tsからはuniqueやPKなどの情報は取れないため、作成できるファイルはこれぐらいの詳細度になるかなと思います。

※やったことないですが、supabase graphqlにも対応したようなので、そっちから持ってきた情報をもとに作成したらまた違うんですかね? -> GraphQL is now available in Supabase

コード

element.ts
import * as ts from 'typescript';
import { Visitor } from '../../visitors/ts_ast/visitor';

class ParsingContext {
  public program: ts.Program;
  public checker: ts.TypeChecker;

  constructor(filePaths: string[]) {
    this.program = ts.createProgram(filePaths, {});
    this.checker = this.program.getTypeChecker();
  }
}

abstract class Element {

  protected source: ts.SourceFile

  constructor(protected context: ParsingContext, filepath: string) {
    const source = this.context.program.getSourceFile(filepath);
    if (!source) {
      throw new Error('Source file not found.');
    }
    this.source = source;
  }

  abstract accept(visitor: Visitor): void

  public getContext(): ParsingContext {
    return this.context;
  }

  public getSource(): ts.SourceFile {
    return this.source;
  }
}

export { ParsingContext, Element }
sourceElement.ts
import { Element, ParsingContext } from './element';
import { Visitor } from '../../visitors/ts_ast/visitor';

class SourceElement extends Element {

  constructor(protected context: ParsingContext, filepath: string) {
    super(context, filepath)
  }

  accept(visitor: Visitor) {
    visitor.visit(this);
  }

}

export { SourceElement }
visitor.ts
import { Element } from "../../elements/ts_ast/element";
import * as ts from 'typescript';

abstract class Visitor {
  abstract visit(element: Element): void;

  /**
   * ノードを名前で検索する
   * @param source 
   * @param name 
   * @returns 
   */
  protected findNodeByName(source: ts.Node, name: string): ts.Node | null {
    let foundNode: ts.Node | null = null;
    ts.forEachChild(source, (node) => {
      if (!foundNode && ts.isInterfaceDeclaration(node) && node.name.text === name) {
        foundNode = node;
      }
    });
    return foundNode;
  }

  /**
   * 特定のノードの子要素からプロパティシグネチャを名前で検索して返す
   * @param node 
   * @param propertyName 
   * @returns 
   */
  protected findPropertySignatureInNode(node: ts.Node, propertyName: string): ts.PropertySignature | null {
    if (ts.isInterfaceDeclaration(node)) {
      for (const member of node.members) {
        if (ts.isPropertySignature(member) && member.name.getText() === propertyName) {
          return member;
        }
      }
    }
    return null;
  }

  /**
   * 型ノードの子要素プロパティシグネチャを名前で検索して返す
   * @param typeNode 
   * @param propertyName 
   * @returns 
   */
  protected findPropertySignatureInTypeNode(typeNode: ts.TypeNode, propertyName: string): ts.PropertySignature | null {
    if (ts.isTypeLiteralNode(typeNode)){
      for (const member of typeNode.members) {
        if (!ts.isPropertySignature(member)) continue
        if (member.name.getText() === propertyName) return member
      }
    } 
    return null
  }

  /**
   * 型ノードの子要素プロパティシグネチャを全て返す
   * @param typeNode 
   * @param propertyName 
   * @returns 
   */
    protected getPropertySignaturesInTypeNode(typeNode: ts.TypeNode): ts.PropertySignature[] {
      const propertySignatures: ts.PropertySignature[] = []
      if (ts.isTypeLiteralNode(typeNode)){
        for (const member of typeNode.members) {
          if (!ts.isPropertySignature(member)) continue
          propertySignatures.push(member)
        }
      } 
      return propertySignatures
    }

}

export { Visitor }
sourceVisitor.ts
import { Visitor } from "./visitor"
import { Element } from "../../elements/ts_ast/element"
import * as ts from 'typescript';
import * as fs from 'fs';
import * as path from 'path';
import os from 'os';

type ColumnMetadata = { [columnName: string]: string };
type ForeignKeyMetadata = { [key: string]: string };
type TableMetadata = { columns: ColumnMetadata; foreign_keys?: ForeignKeyMetadata };
type Metadata = { [tableName: string]: TableMetadata };

class SourceVisitor extends Visitor {
  async visit(element: Element) {
    const databaseNode = this.getDatabaseNode(element.getSource());
    const publicPropertySignature = this.getPublicPropertySignature(databaseNode)
    const tablePropertySignatures = this.getTablePropertySignatures(publicPropertySignature)

    const mapping = {}

    for( const tablePropertySignature of tablePropertySignatures ) {

      // Row取得処理
      const rowMap = this.getTableRow(tablePropertySignature);

      // RelationShip取得処理
      const relationshipsMap = this.getTableRelationships(tablePropertySignature);

      // mappingに追加
      mapping[tablePropertySignature.name.getText()] = {
        columns: rowMap,
        foreign_keys: relationshipsMap
      }

    }

    this.createTableDefinition(mapping)
  }

  /**
   * テーブル定義のMarkdownファイルを生成する
   * @param tablesMetadata 
   */
  private async createTableDefinition(tablesMetadata: Metadata) {
    const homeDir = os.homedir();
    const outputPath = path.join(homeDir, 'documents/db');
  
    // テーブルごとにMarkdownファイルを生成
    for (const [tableName, tableData] of Object.entries(tablesMetadata)) {
      let markdownContent = `### Table Name: ${tableName}\n\n`;
  
      // Calculate the maximum length of column names and types
      const maxColNameLength = Math.max(...Object.keys(tableData.columns).map(s => s.length), "Column".length);
      const maxColTypeLength = Math.max(...Object.values(tableData.columns).map(s => s.length), "Type".length);
      const maxForeignKeyLength = Math.max(...Object.values(tableData.foreign_keys || {}).map(s => s.length), "Foreign Key".length);
  
      // Column Information
      markdownContent += '#### Column Information\n\n';
      markdownContent += `| Column${' '.repeat(maxColNameLength - "Column".length)} | Type${' '.repeat(maxColTypeLength - "Type".length)} | Foreign Key${' '.repeat(maxForeignKeyLength - "Foreign Key".length)} |\n`;
      markdownContent += `| ${'-'.repeat(maxColNameLength)} | ${'-'.repeat(maxColTypeLength)} | ${'-'.repeat(maxForeignKeyLength)} |\n`;
  
      for (const [colName, colType] of Object.entries(tableData.columns)) {
        const foreignKey = tableData.foreign_keys?.[colName] || '';
        markdownContent += `| ${colName}${' '.repeat(maxColNameLength - colName.length)} | ${colType}${' '.repeat(maxColTypeLength - colType.length)} | ${foreignKey}${' '.repeat(maxForeignKeyLength - foreignKey.length)} |\n`;
      }
      markdownContent += '\n';
      // Write to file
      fs.writeFileSync(path.join(outputPath, `${tableName}.md`), markdownContent);
    }
  }

  /**
   * Databaseノードを取得する
   * @param source 
   * @returns 
   */
  private getDatabaseNode(source: ts.SourceFile): ts.Node {
    const databaseNode = this.findNodeByName(source, 'Database');
    if (!databaseNode) throw new Error('Database node not found.');
    return databaseNode
  }

  /**
   * Databaseノードのpublicプロパティを取得する
   * @param databaseNode 
   * @returns 
   */
  private getPublicPropertySignature(databaseNode: ts.Node): ts.PropertySignature {
    const publicPropertySignature = this.findPropertySignatureInNode(databaseNode, 'public');
    if (!publicPropertySignature) throw new Error('Public property sigunature not found.');
    return publicPropertySignature
  }

  /**
   * tablesプロパティシグネチャ配下のプロパティシグネチャをすべて取得する
   * @param tablesSymbol
   * @param checker
   * @returns
   */
  private getTablePropertySignatures(publicPropertySignature: ts.PropertySignature): ts.PropertySignature[] {
    if(!publicPropertySignature.type) throw new Error('Public property signature type not found.');
    const tablesPropertySignature = this.findPropertySignatureInTypeNode(publicPropertySignature.type, 'Tables')
    if(!tablesPropertySignature) throw new Error('tables property signature not found.');
    const tablePropertySignatures = this.getPropertySignaturesInTypeNode(tablesPropertySignature.type)
    return tablePropertySignatures
  }

  /**
   * Rowのkeyと型のマップを取得する
   * @param tablePropertySignature 
   * @param checker 
   * @returns 
   */
  private getTableRow(tablePropertySignature: ts.PropertySignature): {} {
    const rowPropertySignature = this.findPropertySignatureInTypeNode(tablePropertySignature.type, 'Row')
    const columnPropertySignatures = this.getPropertySignaturesInTypeNode(rowPropertySignature.type)
    const rowMap = {}
    for (const columnPropertySignature of columnPropertySignatures) {
      const columnName = columnPropertySignature.name.getText()
      const typeText = columnPropertySignature.type.getText()
      rowMap[columnName] = typeText
    }

    return rowMap
  }

  /**
   * リレーションシップのkeyと型のマップを取得する
   * @param tablePropertySignature 
   * @param checker 
   * @returns 
   */
  private getTableRelationships(tablePropertySignature: ts.PropertySignature): {} {
    const relationshipsPropertySignature = this.findPropertySignatureInTypeNode(tablePropertySignature.type, 'Relationships')
    const mappings = []
    if(ts.isTupleTypeNode(relationshipsPropertySignature.type)) {
      for(const elementType of relationshipsPropertySignature.type.elements){
        const mapping = {}
        const columnPropertySignatures = this.getPropertySignaturesInTypeNode(elementType)
        for (const columnPropertySignature of columnPropertySignatures) {
          mapping[columnPropertySignature.name.getText()] = this.parseBracketedString(columnPropertySignature.type.getText().trim().replace(/["']/g, '')).join(", ")
        }
        mappings.push(mapping)
      }
    }
    const foreignKeys: { [key: string]: string } = {};
  
    for (const mapping of mappings) {
      foreignKeys[mapping.columns] = `${mapping.referencedRelation}(${mapping.referencedColumns})`;
    }
  
    return foreignKeys;
  }

  /**
   * 角括弧で囲まれた文字列を配列に変換する
   * @param str 
   * @returns 
   */
  private parseBracketedString(str: string): string[] {
    return str.replace(/^\[|\]$/g, '').split(',').map(s => s.trim());
  }
}

export { SourceVisitor }
index.ts
import { SourceElement } from './elements/ts_ast/sourceElement';
import { ParsingContext } from './elements/ts_ast/element';
import { SourceVisitor } from './visitors/ts_ast/sourceVisitor';


const parsingContext = new ParsingContext(['../supabase/schema.ts']);
const sourceElement = new SourceElement(parsingContext, '../supabase/schema.ts');
const sourceVisitor = new SourceVisitor();

sourceElement.accept(sourceVisitor);

TIPS

  • ビジターパターンというのを意識して書きました、あってるかは知らんです
  • sourceVisitor.tsにファイルのアウトプット先をハードコーディングしちゃってるので、気を付けてください。
  • コード内コメントは誤りありそうな気がしてます、Databaseノードとかは適切な表現なのかと思ったりとか。

作ってみた感想

ドキュメント見ながらやってみましたが把握難しく、https://ts-ast-viewer.com/# このサイトで対象ファイルを見てしまうのが早かったです。

やってみた感じ、string | nullのような型情報をSymbol型などからTypeCheckerを使って取得するとnullが落ちるようで、ノードで掘り進んでいく形にしないといけないっぽかったです。(多分ノードレベルだと完全な情報が取れるとかなのかな?)

Discussion