├── .eslintignore ├── .gitattributes ├── .prettierignore ├── .gitignore ├── .npmignore ├── assets ├── banner-dark-mode.png └── banner-light-mode.png ├── .vscode ├── extensions.json └── settings.json ├── scripts └── generate.js ├── .prettierrc ├── example ├── supabase.ts ├── database.types.ts └── generated.ts ├── jest.config.ts ├── example-codegen.config.json ├── example-codegen.config.js ├── tsconfig.json ├── src ├── utils │ ├── importSupabase │ │ ├── importSupabase.ts │ │ └── importSupabase.spec.ts │ ├── generateTypes │ │ ├── toTypeName.ts │ │ ├── generateTypes.ts │ │ └── toTypeName.spec.ts │ ├── generateHooks │ │ ├── toHookName.ts │ │ ├── toHookName.spec.ts │ │ └── generateHooks.ts │ ├── formatGeneratedContent │ │ └── formatGeneratedContent.ts │ ├── getConfigFile │ │ └── getConfigFile.ts │ └── getTablesProperties │ │ └── getTablesProperties.ts ├── cli.ts └── generate.ts ├── .eslintrc ├── LICENSE.txt ├── package.json └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | scripts -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | *.log 4 | .eslintcache -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !dist/**/* 3 | !README.md 4 | !package.json 5 | -------------------------------------------------------------------------------- /assets/banner-dark-mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/barrymichaeldoyle/supabase-react-query-codegen/HEAD/assets/banner-dark-mode.png -------------------------------------------------------------------------------- /assets/banner-light-mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/barrymichaeldoyle/supabase-react-query-codegen/HEAD/assets/banner-light-mode.png -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "esbenp.prettier-vscode", 4 | "firsttris.vscode-jest-runner", 5 | "dbaeumer.vscode-eslint" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /scripts/generate.js: -------------------------------------------------------------------------------- 1 | const config = require('../example-codegen.config.js'); 2 | 3 | const { default: generate } = require('../dist/generate.js'); 4 | 5 | generate(config) 6 | 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": false, 3 | "tabWidth": 2, 4 | "singleQuote": true, 5 | "semi": true, 6 | "printWidth": 80, 7 | "trailingComma": "es5", 8 | "endOfLine": "lf" 9 | } 10 | -------------------------------------------------------------------------------- /example/supabase.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from '@supabase/supabase-js'; 2 | 3 | import type { Database } from './database.types'; 4 | 5 | export const supabase = createClient('supabsase-url', 'supabase-key'); 6 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import { Config } from 'jest'; 2 | 3 | const config: Config = { 4 | preset: 'ts-jest', 5 | testEnvironment: 'node', 6 | testPathIgnorePatterns: ['/node_modules/', '/dist/'], 7 | }; 8 | 9 | export default config; 10 | -------------------------------------------------------------------------------- /example-codegen.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "outputPath": "./example/generated.ts", 3 | "prettierConfigPath": ".prettierrc", 4 | "relativeSupabasePath": "./supabase", 5 | "supabaseExportName": "supabase", 6 | "typesPath": "./example/database.types.ts" 7 | } 8 | -------------------------------------------------------------------------------- /example-codegen.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | outputPath: './example/generated.ts', 3 | prettierConfigPath: '.prettierrc', 4 | relativeSupabasePath: './supabase', 5 | supabaseExportName: 'supabase', 6 | typesPath: './example/database.types.ts', 7 | }; 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2020", 5 | "outDir": "dist", 6 | "rootDir": "src", 7 | "strict": true, 8 | "esModuleInterop": true 9 | }, 10 | "include": ["src/**/*.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/importSupabase/importSupabase.ts: -------------------------------------------------------------------------------- 1 | interface ImportSupabaseArgs { 2 | relativeSupabasePath?: string; 3 | supabaseExportName?: string; 4 | } 5 | 6 | export function importSupabase({ 7 | relativeSupabasePath = './supabase', 8 | supabaseExportName, 9 | }: ImportSupabaseArgs): string { 10 | const exportName = supabaseExportName 11 | ? `{ ${supabaseExportName} }` 12 | : 'supabase'; 13 | 14 | return `import ${exportName} from '${relativeSupabasePath}';`; 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "dbaeumer.vscode-eslint", 3 | "editor.formatOnSave": true, 4 | "editor.formatOnSaveMode": "file", 5 | "editor.tabSize": 2, 6 | "editor.codeActionsOnSave": { 7 | "source.fixAll.eslint": true, 8 | }, 9 | "eslint.validate": [ 10 | "javascript", 11 | "javascriptreact", 12 | "typescript", 13 | "typescriptreact" 14 | ], 15 | "typescript.tsdk": "node_modules/typescript/lib", 16 | "files.eol": "\n", 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/generateTypes/toTypeName.ts: -------------------------------------------------------------------------------- 1 | import { singular } from 'pluralize'; 2 | 3 | interface ToTypeNameArgs { 4 | tableName: string; 5 | operation: 'Get' | 'Add' | 'Update'; 6 | } 7 | 8 | export function toTypeName({ tableName, operation }: ToTypeNameArgs): string { 9 | const pascalCaseTableName = tableName.replace(/(?:^|_|-)(\w)/g, (_, char) => 10 | char.toUpperCase() 11 | ); 12 | 13 | const formattedTableName = singular(pascalCaseTableName); 14 | 15 | if (operation === 'Get') { 16 | return formattedTableName; 17 | } 18 | 19 | return `${operation}${formattedTableName}Request`; 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/generateHooks/toHookName.ts: -------------------------------------------------------------------------------- 1 | import { plural, singular } from 'pluralize'; 2 | 3 | interface ToHookNameArgs { 4 | tableName: string; 5 | operation: 'GetAll' | 'Get' | 'Add' | 'Update' | 'Delete'; 6 | } 7 | 8 | export function toHookName({ tableName, operation }: ToHookNameArgs): string { 9 | const pascalCaseTableName = tableName.replace(/(?:^|_|-)(\w)/g, (_, char) => 10 | char.toUpperCase() 11 | ); 12 | 13 | const singularTableName = 14 | operation === 'GetAll' 15 | ? plural(pascalCaseTableName) 16 | : singular(pascalCaseTableName); 17 | 18 | return `use${operation}${singularTableName}`; 19 | } 20 | -------------------------------------------------------------------------------- /src/utils/formatGeneratedContent/formatGeneratedContent.ts: -------------------------------------------------------------------------------- 1 | import { format, resolveConfig } from 'prettier'; 2 | 3 | interface FormatContentArgs { 4 | generatedFileContent: string; 5 | prettierConfigPath?: string; 6 | } 7 | 8 | export async function formatGeneratedContent({ 9 | generatedFileContent, 10 | prettierConfigPath = '.prettierrc', 11 | }: FormatContentArgs): Promise { 12 | const prettierConfig = await resolveConfig(prettierConfigPath); 13 | 14 | // Format the file content using Prettier 15 | const formattedFileContent = format(generatedFileContent, { 16 | parser: 'typescript', 17 | ...(prettierConfig || {}), 18 | }); 19 | 20 | return formattedFileContent; 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/importSupabase/importSupabase.spec.ts: -------------------------------------------------------------------------------- 1 | import { importSupabase } from './importSupabase'; 2 | 3 | describe('importSupabase', () => { 4 | test('should return import statement with default export when supabaseExportName is not provided', () => { 5 | expect(importSupabase({ relativeSupabasePath: '../supabaseClient' })).toBe( 6 | "import supabase from '../supabaseClient';" 7 | ); 8 | }); 9 | 10 | test('should return import statement with named export when supabaseExportName is provided', () => { 11 | expect( 12 | importSupabase({ 13 | relativeSupabasePath: '../supabaseClient', 14 | supabaseExportName: 'customSupabase', 15 | }) 16 | ).toBe("import { customSupabase } from '../supabaseClient';"); 17 | }); 18 | 19 | test('should throw an error when relativeSupabasePath is not provided', () => { 20 | expect(importSupabase({})).toBe("import supabase from './supabase';"); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/utils/getConfigFile/getConfigFile.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { Config } from '../../generate'; 3 | 4 | const allowedExtensions = ['.js', '.json']; 5 | 6 | export function getConfigFile(configPath: string): Config { 7 | const absoluteConfigPath = path.resolve(process.cwd(), configPath); 8 | const fileExtension = path.extname(absoluteConfigPath); 9 | 10 | if (!allowedExtensions.includes(fileExtension)) { 11 | throw new Error( 12 | `Invalid configuration file extension. Allowed extensions are: ${allowedExtensions.join( 13 | ', ' 14 | )}` 15 | ); 16 | } 17 | 18 | try { 19 | // eslint-disable-next-line @typescript-eslint/no-var-requires, security-node/detect-non-literal-require-calls 20 | const configFile = require(absoluteConfigPath); 21 | return configFile; 22 | } catch (error) { 23 | throw new Error( 24 | `Config file not found or could not be loaded at "${absoluteConfigPath}"` 25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": [ 4 | "eslint:recommended", 5 | "plugin:prettier/recommended", 6 | "plugin:@typescript-eslint/recommended", 7 | "plugin:security-node/recommended" 8 | ], 9 | "plugins": [ 10 | "prettier", 11 | "@typescript-eslint", 12 | "unused-imports", 13 | "security-node" 14 | ], 15 | "rules": { 16 | "eqeqeq": "error", 17 | "@typescript-eslint/ban-types": "off", 18 | "linebreak-style": ["error", "unix"], 19 | "prettier/prettier": "error", 20 | "unused-imports/no-unused-imports": "error", 21 | "unused-imports/no-unused-vars": [ 22 | "warn", 23 | { 24 | "vars": "all", 25 | "varsIgnorePattern": "^_", 26 | "args": "after-used", 27 | "argsIgnorePattern": "^_" 28 | } 29 | ] 30 | }, 31 | "parserOptions": { 32 | "sourceType": "module", 33 | "ecmaVersion": "latest" 34 | }, 35 | "env": { 36 | "node": true, 37 | "es6": true, 38 | "jest": true 39 | }, 40 | "ignorePatterns": [ 41 | "node_modules", 42 | "dist" 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) 2023 Barry Michael Doyle 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | 'Software'), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /src/utils/getTablesProperties/getTablesProperties.ts: -------------------------------------------------------------------------------- 1 | import { ModuleKind, Project, ScriptTarget } from 'ts-morph'; 2 | 3 | export function getTablesProperties(typesPath: string) { 4 | const project = new Project({ 5 | compilerOptions: { 6 | allowSyntheticDefaultImports: true, 7 | esModuleInterop: true, 8 | module: ModuleKind.ESNext, 9 | target: ScriptTarget.ESNext, 10 | strictNullChecks: true, 11 | }, 12 | }); 13 | 14 | const sourceFile = project.addSourceFileAtPath(typesPath); 15 | 16 | // Find the 'Tables' type alias 17 | const databaseInterface = sourceFile.getInterfaceOrThrow('Database'); 18 | const publicProperty = databaseInterface.getPropertyOrThrow('public'); 19 | const publicType = publicProperty.getType(); 20 | 21 | const tablesProperty = publicType 22 | .getApparentProperties() 23 | .find((property) => property.getName() === 'Tables'); 24 | 25 | if (!tablesProperty) { 26 | throw new Error('No Tables property found within the Database interface.'); 27 | } 28 | 29 | const tablesType = project 30 | .getProgram() 31 | .getTypeChecker() 32 | .getTypeAtLocation(tablesProperty.getValueDeclarationOrThrow()); 33 | const tablesProperties = tablesType.getProperties(); 34 | 35 | if (tablesProperties.length === 0) { 36 | throw new Error('No tables found within the Tables property.'); 37 | } 38 | 39 | return tablesProperties; 40 | } 41 | -------------------------------------------------------------------------------- /example/database.types.ts: -------------------------------------------------------------------------------- 1 | export type Json = 2 | | string 3 | | number 4 | | boolean 5 | | null 6 | | { [key: string]: Json } 7 | | Json[]; 8 | 9 | export interface Database { 10 | public: { 11 | Tables: { 12 | todo_items: { 13 | Row: { 14 | created_at: string; 15 | description: string; 16 | id: string; 17 | name: string; 18 | }; 19 | Insert: { 20 | created_at?: string; 21 | description: string; 22 | id?: string; 23 | name: string; 24 | }; 25 | Update: { 26 | created_at?: string; 27 | description?: string; 28 | id?: string; 29 | name?: string; 30 | }; 31 | }; 32 | profiles: { 33 | Row: { 34 | first_name: string | null; 35 | id: string; 36 | last_name: string | null; 37 | }; 38 | Insert: { 39 | first_name?: string | null; 40 | id: string; 41 | last_name?: string | null; 42 | }; 43 | Update: { 44 | first_name?: string | null; 45 | id?: string; 46 | last_name?: string | null; 47 | }; 48 | }; 49 | }; 50 | Views: { 51 | [_ in never]: never; 52 | }; 53 | Functions: { 54 | [_ in never]: never; 55 | }; 56 | Enums: { 57 | [_ in never]: never; 58 | }; 59 | CompositeTypes: { 60 | [_ in never]: never; 61 | }; 62 | }; 63 | } 64 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import yargs from 'yargs'; 4 | import { hideBin } from 'yargs/helpers'; 5 | 6 | import generate, { Config } from './generate'; 7 | import { getConfigFile } from './utils/getConfigFile/getConfigFile'; 8 | 9 | interface CliConfig extends Config { 10 | configPath?: string; 11 | } 12 | 13 | yargs(hideBin(process.argv)) 14 | .command( 15 | 'generate [configPath]', 16 | 'Generate hooks', 17 | (yargs) => { 18 | return yargs 19 | .positional('configPath', { 20 | describe: 'Path to the configuration file', 21 | type: 'string', 22 | }) 23 | .options({ 24 | outputPath: { type: 'string' }, 25 | prettierConfigPath: { type: 'string' }, 26 | relativeSupabasePath: { type: 'string' }, 27 | supabaseExportName: { type: 'string' }, 28 | typesPath: { type: 'string' }, 29 | }) 30 | .check((argv) => { 31 | if (!argv.configPath && (!argv.outputPath || !argv.typesPath)) { 32 | throw new Error( 33 | 'When "configPath" is not provided, both "outputPath" and "typesPath" must be provided.' 34 | ); 35 | } 36 | return true; 37 | }); 38 | }, 39 | async (argv) => { 40 | const config: CliConfig = argv.configPath 41 | ? getConfigFile(argv.configPath) 42 | : (argv as CliConfig); 43 | 44 | await generate(config); 45 | } 46 | ) 47 | .help() 48 | .alias('help', 'h') 49 | .strict() 50 | .parse(); 51 | -------------------------------------------------------------------------------- /src/utils/generateTypes/generateTypes.ts: -------------------------------------------------------------------------------- 1 | import type { Symbol } from 'ts-morph'; 2 | 3 | import { toTypeName } from './toTypeName'; 4 | 5 | interface GenerateTypesArg { 6 | table: Symbol; 7 | tableName: string; 8 | } 9 | 10 | export function generateTypes({ 11 | tableName, 12 | table, 13 | }: GenerateTypesArg): string[] { 14 | // Get the table type 15 | const tableType = table.getTypeAtLocation(table.getValueDeclarationOrThrow()); 16 | 17 | // Find the 'Row' property within the table type 18 | const rowProperty = tableType.getProperty('Row'); 19 | if (!rowProperty) { 20 | throw new Error(`Unable to find Row property type for ${tableName}.`); 21 | } 22 | 23 | // Get the type of the 'Row' property 24 | const rowType = rowProperty.getTypeAtLocation( 25 | rowProperty.getValueDeclarationOrThrow() 26 | ); 27 | 28 | const insertProperty = tableType.getProperty('Insert'); 29 | if (!insertProperty) { 30 | throw new Error(`Unable to find insert property type for ${tableName}.`); 31 | } 32 | 33 | const insertType = insertProperty.getTypeAtLocation( 34 | insertProperty.getValueDeclarationOrThrow() 35 | ); 36 | 37 | const updateProperty = tableType.getProperty('Update'); 38 | if (!updateProperty) { 39 | throw new Error(`Unable to find update property type for ${tableName}.`); 40 | } 41 | 42 | const updateType = updateProperty.getTypeAtLocation( 43 | updateProperty.getValueDeclarationOrThrow() 44 | ); 45 | 46 | const rowTypeString = rowType.getText(); 47 | const insertTypeString = insertType.getText(); 48 | const updateTypeString = updateType.getText(); 49 | 50 | const types: string[] = []; 51 | 52 | types.push( 53 | `export type ${toTypeName({ 54 | operation: 'Get', 55 | tableName, 56 | })} = ${rowTypeString};`, 57 | `export type ${toTypeName({ 58 | operation: 'Add', 59 | tableName, 60 | })} = ${insertTypeString};`, 61 | `export type ${toTypeName({ 62 | operation: 'Update', 63 | tableName, 64 | })} = { id: string; changes: ${updateTypeString} }; 65 | ` 66 | ); 67 | 68 | return types; 69 | } 70 | -------------------------------------------------------------------------------- /src/utils/generateTypes/toTypeName.spec.ts: -------------------------------------------------------------------------------- 1 | import { toTypeName } from './toTypeName'; 2 | 3 | describe('toTypeName', () => { 4 | it('should return the type name for a table with a single word name.', () => { 5 | expect(toTypeName({ operation: 'Get', tableName: 'users' })).toBe('User'); 6 | expect(toTypeName({ operation: 'Update', tableName: 'users' })).toBe( 7 | 'UpdateUserRequest' 8 | ); 9 | }); 10 | 11 | it('should return the type name for a table with a snake_case name.', () => { 12 | expect(toTypeName({ operation: 'Get', tableName: 'todo_items' })).toBe( 13 | 'TodoItem' 14 | ); 15 | expect(toTypeName({ operation: 'Update', tableName: 'todo_items' })).toBe( 16 | 'UpdateTodoItemRequest' 17 | ); 18 | }); 19 | 20 | it('should return the type name for a table with a multi-word snake_case name', () => { 21 | expect( 22 | toTypeName({ operation: 'Get', tableName: 'order_item_details' }) 23 | ).toBe('OrderItemDetail'); 24 | expect( 25 | toTypeName({ operation: 'Update', tableName: 'order_item_details' }) 26 | ).toBe('UpdateOrderItemDetailRequest'); 27 | }); 28 | 29 | it('should return the type name for a table named `people` (singualize lib test)', () => { 30 | expect(toTypeName({ operation: 'Get', tableName: 'people' })).toBe( 31 | 'Person' 32 | ); 33 | expect(toTypeName({ operation: 'Update', tableName: 'people' })).toBe( 34 | 'UpdatePersonRequest' 35 | ); 36 | }); 37 | 38 | it('should return the type name for a table named `parties` (singualize lib test)', () => { 39 | expect(toTypeName({ operation: 'Get', tableName: 'parties' })).toBe( 40 | 'Party' 41 | ); 42 | expect(toTypeName({ operation: 'Update', tableName: 'parties' })).toBe( 43 | 'UpdatePartyRequest' 44 | ); 45 | }); 46 | 47 | it('should return the type name for a table named `to-do` (singualize lib test)', () => { 48 | expect(toTypeName({ operation: 'Get', tableName: 'to-do' })).toBe('ToDo'); 49 | expect(toTypeName({ operation: 'Update', tableName: 'to-do' })).toBe( 50 | 'UpdateToDoRequest' 51 | ); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/generate.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | import { getTablesProperties } from './utils/getTablesProperties/getTablesProperties'; 5 | import { generateTypes } from './utils/generateTypes/generateTypes'; 6 | import { generateHooks } from './utils/generateHooks/generateHooks'; 7 | import { formatGeneratedContent } from './utils/formatGeneratedContent/formatGeneratedContent'; 8 | import { importSupabase } from './utils/importSupabase/importSupabase'; 9 | 10 | export interface Config { 11 | outputPath: string; 12 | prettierConfigPath?: string; 13 | relativeSupabasePath?: string; 14 | supabaseExportName?: string; 15 | typesPath: string; 16 | } 17 | 18 | export default async function generate({ 19 | outputPath, 20 | prettierConfigPath, 21 | relativeSupabasePath, 22 | supabaseExportName, 23 | typesPath, 24 | }: Config) { 25 | const allowedOutputDir = path.resolve(process.cwd()); 26 | const resolvedOutputPath = path.resolve(allowedOutputDir, outputPath); 27 | if (!resolvedOutputPath.startsWith(allowedOutputDir)) { 28 | throw new Error( 29 | `Invalid output path: "${outputPath}". Writing files outside of the allowed directory is not allowed.` 30 | ); 31 | } 32 | 33 | const tablesProperties = getTablesProperties(typesPath); 34 | 35 | // Iterate through table keys and generate hooks 36 | const hooks: string[] = []; 37 | const types: string[] = []; 38 | 39 | for (const table of tablesProperties) { 40 | const tableName = table.getName(); 41 | 42 | hooks.push(...generateHooks({ supabaseExportName, tableName })); 43 | types.push(...generateTypes({ table, tableName })); 44 | } 45 | 46 | // Create the output file content with imports and hooks 47 | const generatedFileContent = ` 48 | import { useMutation, useQuery, useQueryClient } from 'react-query'; 49 | ${importSupabase({ relativeSupabasePath, supabaseExportName })} 50 | 51 | ${types.join('\n')} 52 | 53 | ${hooks.join('\n\n')} 54 | `; 55 | 56 | const formattedFileContent = await formatGeneratedContent({ 57 | generatedFileContent, 58 | prettierConfigPath, 59 | }); 60 | 61 | // Write the output file 62 | fs.writeFileSync(resolvedOutputPath, formattedFileContent); 63 | } 64 | -------------------------------------------------------------------------------- /src/utils/generateHooks/toHookName.spec.ts: -------------------------------------------------------------------------------- 1 | import { toHookName } from './toHookName'; 2 | 3 | describe('toHookName', () => { 4 | it('should return the hook name for a table with a single word name.', () => { 5 | expect(toHookName({ tableName: 'users', operation: 'Get' })).toBe( 6 | 'useGetUser' 7 | ); 8 | expect(toHookName({ tableName: 'users', operation: 'GetAll' })).toBe( 9 | 'useGetAllUsers' 10 | ); 11 | }); 12 | 13 | it('should return the hook name for a table with a snake_case name.', () => { 14 | expect( 15 | toHookName({ 16 | tableName: 'todo_items', 17 | operation: 'Update', 18 | }) 19 | ).toBe('useUpdateTodoItem'); 20 | expect( 21 | toHookName({ 22 | tableName: 'todo_items', 23 | operation: 'GetAll', 24 | }) 25 | ).toBe('useGetAllTodoItems'); 26 | }); 27 | 28 | it('should return the hook name for a table with a multi-word snake_case name', () => { 29 | expect( 30 | toHookName({ 31 | tableName: 'order_item_details', 32 | operation: 'Delete', 33 | }) 34 | ).toBe('useDeleteOrderItemDetail'); 35 | expect( 36 | toHookName({ 37 | tableName: 'order_item_details', 38 | operation: 'GetAll', 39 | }) 40 | ).toBe('useGetAllOrderItemDetails'); 41 | }); 42 | 43 | it('should return the hook name for a table named `people` (singualize lib test)', () => { 44 | expect(toHookName({ tableName: 'people', operation: 'Delete' })).toBe( 45 | 'useDeletePerson' 46 | ); 47 | expect(toHookName({ tableName: 'people', operation: 'GetAll' })).toBe( 48 | 'useGetAllPeople' 49 | ); 50 | }); 51 | 52 | it('should return the hook name for a table named `parties` (singualize lib test)', () => { 53 | expect(toHookName({ tableName: 'parties', operation: 'Delete' })).toBe( 54 | 'useDeleteParty' 55 | ); 56 | expect(toHookName({ tableName: 'parties', operation: 'GetAll' })).toBe( 57 | 'useGetAllParties' 58 | ); 59 | }); 60 | 61 | it('should return the hook name for a table named `to-do` (singualize lib test)', () => { 62 | expect(toHookName({ tableName: 'to-do', operation: 'Delete' })).toBe( 63 | 'useDeleteToDo' 64 | ); 65 | expect(toHookName({ tableName: 'to-do', operation: 'GetAll' })).toBe( 66 | 'useGetAllToDos' 67 | ); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "supabase-react-query-codegen", 3 | "version": "1.1.0", 4 | "description": "A CLI tool to automatically generate React Query hooks and TypeScript types for your Supabase Database, streamlining data fetching and enhancing developer productivity.", 5 | "main": "index.js", 6 | "private": false, 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/barrymichaeldoyle/supabase-react-query-codegen.git" 10 | }, 11 | "scripts": { 12 | "build": "tsc", 13 | "lint": "eslint --cache \"src/**/*.{js,ts}\"", 14 | "lint:fix": "eslint --cache --fix \"src/**/*.{js,ts}\"", 15 | "test": "jest", 16 | "test-cli": "npm run build && ts-node ./dist/cli.js", 17 | "test-cli-config-js": "npm run test-cli -- generate example-codegen.config.js", 18 | "test-cli-config-json": "npm run test-cli -- generate example-codegen.config.json", 19 | "test-cli-options": "npm run test-cli -- generate --outputPath example/generated.ts --typesPath example/database.types.ts --supabaseExportName supabase", 20 | "test-generate": "npm run build && node scripts/generate.js", 21 | "test:watch": "jest --watch" 22 | }, 23 | "bin": { 24 | "supabase-react-query-codegen": "dist/cli.js" 25 | }, 26 | "keywords": [ 27 | "code-generation", 28 | "react-hooks", 29 | "supabase", 30 | "typescript", 31 | "database", 32 | "automatic", 33 | "react-query", 34 | "crud", 35 | "utilities", 36 | "developer-tools" 37 | ], 38 | "author": "Barry Michael Doyle ", 39 | "license": "MIT", 40 | "bugs": { 41 | "url": "https://github.com/barrymichaeldoyle/supabase-react-query-codegen/issues" 42 | }, 43 | "homepage": "https://github.com/barrymichaeldoyle/supabase-react-query-codegen#readme", 44 | "dependencies": { 45 | "pluralize": "^8.0.0", 46 | "prettier": "^2.8.8", 47 | "ts-morph": "^24.0.0", 48 | "yargs": "^17.7.2" 49 | }, 50 | "peerDependencies": { 51 | "@supabase/supabase-js": "^2.20.0", 52 | "react-query": "^3.39.3" 53 | }, 54 | "devDependencies": { 55 | "@types/jest": "^29.5.0", 56 | "@types/pluralize": "^0.0.29", 57 | "@types/yargs": "^17.0.24", 58 | "@typescript-eslint/eslint-plugin": "^5.59.0", 59 | "@typescript-eslint/parser": "^5.59.0", 60 | "eslint": "^8.38.0", 61 | "eslint-config-prettier": "^8.8.0", 62 | "eslint-plugin-prettier": "^4.2.1", 63 | "eslint-plugin-security-node": "^1.1.1", 64 | "eslint-plugin-unused-imports": "^2.0.0", 65 | "husky": "^8.0.3", 66 | "jest": "^29.5.0", 67 | "lint-staged": "^13.2.1", 68 | "ts-jest": "^29.1.0", 69 | "ts-node": "^10.9.1", 70 | "typescript": "^5.0.4" 71 | }, 72 | "husky": { 73 | "hooks": { 74 | "pre-commit": "lint-staged" 75 | } 76 | }, 77 | "lint-staged": { 78 | "*.{js,ts}": "npm run lint:fix" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/utils/generateHooks/generateHooks.ts: -------------------------------------------------------------------------------- 1 | import { toTypeName } from '../generateTypes/toTypeName'; 2 | import { toHookName } from './toHookName'; 3 | 4 | interface GenerateHooksArg { 5 | tableName: string; 6 | supabaseExportName?: string | false; 7 | } 8 | 9 | export function generateHooks({ 10 | supabaseExportName, 11 | tableName, 12 | }: GenerateHooksArg): string[] { 13 | const hooks: string[] = []; 14 | const supabase = supabaseExportName || 'supabase'; 15 | 16 | const getRowType = toTypeName({ operation: 'Get', tableName }); 17 | const addRowType = toTypeName({ operation: 'Add', tableName }); 18 | const updateRowType = toTypeName({ operation: 'Update', tableName }); 19 | 20 | hooks.push( 21 | `export function ${toHookName({ 22 | operation: 'Get', 23 | tableName, 24 | })}(id: string) { 25 | return useQuery<${getRowType}, Error>( 26 | ['${tableName}', id], 27 | async () => { 28 | const { data, error } = await ${supabase} 29 | .from('${tableName}') 30 | .select('*') 31 | .eq('id', id) 32 | .single(); 33 | if (error) throw error; 34 | if (!data) throw new Error('No data found'); 35 | return data as ${getRowType}; 36 | }, 37 | { enabled: !!id } 38 | ); 39 | }`, 40 | `export function ${toHookName({ operation: 'GetAll', tableName })}() { 41 | return useQuery<${getRowType}[], Error>(['${tableName}'], async () => { 42 | const { data, error } = await ${supabase}.from('${tableName}').select(); 43 | if (error) throw error; 44 | return data as ${getRowType}[]; 45 | }); 46 | }`, 47 | `export function ${toHookName({ operation: 'Add', tableName })}() { 48 | const queryClient = useQueryClient(); 49 | return useMutation( 50 | async (item: ${addRowType}) => { 51 | const { error } = await ${supabase} 52 | .from('${tableName}') 53 | .insert(item) 54 | .single(); 55 | if (error) throw error; 56 | return null; 57 | }, 58 | { 59 | onSuccess: () => { 60 | queryClient.invalidateQueries('${tableName}'); 61 | }, 62 | } 63 | ); 64 | }`, 65 | `export function ${toHookName({ operation: 'Update', tableName })}() { 66 | const queryClient = useQueryClient(); 67 | return useMutation( 68 | async (item: ${updateRowType}) => { 69 | const { error } = await ${supabase} 70 | .from('${tableName}') 71 | .update(item.changes) 72 | .eq('id', item.id) 73 | .single() 74 | if (error) throw error; 75 | return null; 76 | }, 77 | { 78 | onSuccess: () => { 79 | queryClient.invalidateQueries('${tableName}'); 80 | }, 81 | } 82 | ); 83 | }`, 84 | `export function ${toHookName({ operation: 'Delete', tableName })}() { 85 | const queryClient = useQueryClient(); 86 | return useMutation( 87 | async (id: string) => { 88 | const { error} = await ${supabase} 89 | .from('${tableName}') 90 | .delete() 91 | .eq('id', id) 92 | .single() 93 | if (error) throw error; 94 | return null; 95 | }, 96 | { 97 | onSuccess: () => { 98 | queryClient.invalidateQueries('${tableName}'); 99 | } 100 | } 101 | ); 102 | }` 103 | ); 104 | 105 | return hooks; 106 | } 107 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | Supabase and react query codegen 5 | 6 |

7 | 8 | [![npm version](https://img.shields.io/npm/v/supabase-react-query-codegen.svg)](https://www.npmjs.com/package/supabase-react-query-codegen) [![npm](https://img.shields.io/npm/dt/supabase-react-query-codegen.svg)](https://www.npmjs.com/package/supabase-react-query-codegen) 9 | [![Known Vulnerabilities](https://snyk.io/test/github/barrymichaeldoyle/supabase-react-query-codegen/badge.svg)](https://snyk.io/test/github/barrymichaeldoyle/supabase-react-query-codegen) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT) 10 | 11 | # Supabase React Query Codegen 🚀 12 | 13 | A CLI tool to automatically generate React Query hooks and TypeScript types for your Supabase Database, streamlining data fetching and enhancing developer productivity. 14 | 15 | ## Table of Contents 📚 16 | 17 | - [Features ⭐️](#features-⭐️) 18 | - [Installation 📥](#installation-📥) 19 | - [Usage 🛠️](#usage-🛠️) 20 | - [Generated Types 🚧](#generated-types-🚧) 21 | - [Generated Hooks 🔍](#generated-hooks-🔍) 22 | - [Contributing 🤝](#contributing-🤝) 23 | - [License 📜](#license-📜) 24 | 25 | ## Features ⭐️ 26 | 27 | - Automatically generates TypeScript types and React Query hooks for your Supabase database. 28 | - Reduces manual work and the likelihood of errors. 29 | - Increases developer productivity by providing ready-to-use hooks for fetching data. 30 | 31 | ## Installation 📥 32 | 33 | Install the package globally using npm: 34 | 35 | ```bash 36 | npm install -g supabase-react-query-codegen 37 | ``` 38 | 39 | Or with Yarn: 40 | 41 | ```bash 42 | yarn global add supabase-react-query-codegen 43 | ``` 44 | 45 | ## Usage 🛠️ 46 | 47 | 1. First, generate a TypeScript types file for your Supabase database (if you haven't already): 48 | 49 | ```bash 50 | supabase gen types typescript --project-id "" --schema public > path/to/types.ts 51 | ``` 52 | 53 | 2. Create a `supabase-react-query-codegen.config.json` file with the following properties: 54 | ```json5 55 | { 56 | // required 57 | "outputPath": "src/generated.ts", // path where generated code will go 58 | "typesPath": "src/types.ts", // path to your types file generated in step 1 59 | 60 | // optional 61 | "prettierConfigPath": ".prettierrc", // path to your .prettierrc file 62 | "relativeSupabasePath": "./supabase", // where your supabase client is relative to your generated file 63 | "supabaseExportName": "supabase", // if not supplied, default will be imported in your generated file 64 | } 65 | ``` 66 | 67 | 3. Run the `generate` command, passing in the required arguments: 68 | 69 | ```bash 70 | npx supabase-react-query-codegen generate supabase-react-query-codegen.config.json 71 | ``` 72 | 73 | ## Generated Types 🚧 74 | 75 | For convenience this tool also generates types from your Database schema. 76 | The following types will be generated for each table in your database, if you have a table called `todo_items` then you will get these types: 77 | 78 | - `TodoItem` 79 | - `AddTodoItemRequest` 80 | - `UpdateTodoItemRequest` 81 | 82 | ### Alternatives 🔄 83 | 84 | This project has been developed in collaboration with the [Better Supabase Types](https://github.com/FroggyPanda/better-supabase-types) CLI tool made by [FroggyPanda](https://github.com/FroggyPanda). If you don't use React Query but like the types generation part of this tool, it may be worth checking them out! ❤️ 85 | 86 | ## Generated Hooks 🔍 87 | 88 | The following hooks will be generated for each table in your database, if you have a table called `todo_items` then you will get these hooks: 89 | 90 | - `useGetTodoItem`: Fetch a single row by its ID. 91 | - `useGetAllTodoItems`: Fetch all rows in the table. 92 | - `useAddTodoItem`: Add a new row to the table. 93 | - `useUpdateTodoItem`: Update an existing row in the table. 94 | - `useDeleteTodoItem`: Delete a row from the table by its ID. 95 | 96 | Note that `todo_items` is converted to PascalCase in the hook names. 97 | 98 | ## Contributing 🤝 99 | 100 | Contributions are welcome! If you find a bug or have a feature request, please open an issue on the GitHub repository. If you'd like to contribute code, feel free to fork the repository and submit a pull request. 101 | 102 | ### Contributors 👥 103 | 104 | Get yourself added to this list by helping me out wherever you can! 105 | 106 | - [@BarryMichaelDoyle](https://github.com/barrymichaeldoyle) (Founder) 107 | - [@FroggyPanda](https://github.com/FroggyPanda) (Collaborator) 108 | - [@pntrivedy](https://github.com/pntrivedy) (Collaborator) 109 | - [@MegsSwanepoel](https://github.com/MegsSwanepoel) (Banner Design) 110 | - [@SirGoaty](https://github.com/sirgoaty) (Research and Testing) 111 | - [@WagnerA117](https://github.com/WagnerA117) (Research and Testing) 112 | 113 | ## License 📜 114 | 115 | MIT 116 | -------------------------------------------------------------------------------- /example/generated.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQuery, useQueryClient } from 'react-query'; 2 | import { supabase } from './supabase'; 3 | 4 | export type TodoItem = { 5 | created_at: string; 6 | description: string; 7 | id: string; 8 | name: string; 9 | }; 10 | export type AddTodoItemRequest = { 11 | created_at?: string | undefined; 12 | description: string; 13 | id?: string | undefined; 14 | name: string; 15 | }; 16 | export type UpdateTodoItemRequest = { 17 | id: string; 18 | changes: { 19 | created_at?: string | undefined; 20 | description?: string | undefined; 21 | id?: string | undefined; 22 | name?: string | undefined; 23 | }; 24 | }; 25 | 26 | export type Profile = { 27 | first_name: string | null; 28 | id: string; 29 | last_name: string | null; 30 | }; 31 | export type AddProfileRequest = { 32 | first_name?: string | null | undefined; 33 | id: string; 34 | last_name?: string | null | undefined; 35 | }; 36 | export type UpdateProfileRequest = { 37 | id: string; 38 | changes: { 39 | first_name?: string | null | undefined; 40 | id?: string | undefined; 41 | last_name?: string | null | undefined; 42 | }; 43 | }; 44 | 45 | export function useGetTodoItem(id: string) { 46 | return useQuery( 47 | ['todo_items', id], 48 | async () => { 49 | const { data, error } = await supabase 50 | .from('todo_items') 51 | .select('*') 52 | .eq('id', id) 53 | .single(); 54 | if (error) throw error; 55 | if (!data) throw new Error('No data found'); 56 | return data; 57 | }, 58 | { enabled: !!id } 59 | ); 60 | } 61 | 62 | export function useGetAllTodoItems() { 63 | return useQuery(['todo_items'], async () => { 64 | const { data, error } = await supabase.from('todo_items').select(); 65 | if (error) throw error; 66 | return data as TodoItem[]; 67 | }); 68 | } 69 | 70 | export function useAddTodoItem() { 71 | const queryClient = useQueryClient(); 72 | return useMutation( 73 | async (item: AddTodoItemRequest) => { 74 | const { error } = await supabase.from('todo_items').insert(item).single(); 75 | if (error) throw error; 76 | return null; 77 | }, 78 | { 79 | onSuccess: () => { 80 | queryClient.invalidateQueries('todo_items'); 81 | }, 82 | } 83 | ); 84 | } 85 | 86 | export function useUpdateTodoItem() { 87 | const queryClient = useQueryClient(); 88 | return useMutation( 89 | async (item: UpdateTodoItemRequest) => { 90 | const { error } = await supabase 91 | .from('todo_items') 92 | .update(item.changes) 93 | .eq('id', item.id) 94 | .single(); 95 | if (error) throw error; 96 | return null; 97 | }, 98 | { 99 | onSuccess: () => { 100 | queryClient.invalidateQueries('todo_items'); 101 | }, 102 | } 103 | ); 104 | } 105 | 106 | export function useDeleteTodoItem() { 107 | const queryClient = useQueryClient(); 108 | return useMutation( 109 | async (id: string) => { 110 | const { error } = await supabase 111 | .from('todo_items') 112 | .delete() 113 | .eq('id', id) 114 | .single(); 115 | if (error) throw error; 116 | return null; 117 | }, 118 | { 119 | onSuccess: () => { 120 | queryClient.invalidateQueries('todo_items'); 121 | }, 122 | } 123 | ); 124 | } 125 | 126 | export function useGetProfile(id: string) { 127 | return useQuery( 128 | ['profiles', id], 129 | async () => { 130 | const { data, error } = await supabase 131 | .from('profiles') 132 | .select('*') 133 | .eq('id', id) 134 | .single(); 135 | if (error) throw error; 136 | if (!data) throw new Error('No data found'); 137 | return data; 138 | }, 139 | { enabled: !!id } 140 | ); 141 | } 142 | 143 | export function useGetAllProfiles() { 144 | return useQuery(['profiles'], async () => { 145 | const { data, error } = await supabase.from('profiles').select(); 146 | if (error) throw error; 147 | return data as Profile[]; 148 | }); 149 | } 150 | 151 | export function useAddProfile() { 152 | const queryClient = useQueryClient(); 153 | return useMutation( 154 | async (item: AddProfileRequest) => { 155 | const { error } = await supabase.from('profiles').insert(item).single(); 156 | if (error) throw error; 157 | return null; 158 | }, 159 | { 160 | onSuccess: () => { 161 | queryClient.invalidateQueries('profiles'); 162 | }, 163 | } 164 | ); 165 | } 166 | 167 | export function useUpdateProfile() { 168 | const queryClient = useQueryClient(); 169 | return useMutation( 170 | async (item: UpdateProfileRequest) => { 171 | const { error } = await supabase 172 | .from('profiles') 173 | .update(item.changes) 174 | .eq('id', item.id) 175 | .single(); 176 | if (error) throw error; 177 | return null; 178 | }, 179 | { 180 | onSuccess: () => { 181 | queryClient.invalidateQueries('profiles'); 182 | }, 183 | } 184 | ); 185 | } 186 | 187 | export function useDeleteProfile() { 188 | const queryClient = useQueryClient(); 189 | return useMutation( 190 | async (id: string) => { 191 | const { error } = await supabase 192 | .from('profiles') 193 | .delete() 194 | .eq('id', id) 195 | .single(); 196 | if (error) throw error; 197 | return null; 198 | }, 199 | { 200 | onSuccess: () => { 201 | queryClient.invalidateQueries('profiles'); 202 | }, 203 | } 204 | ); 205 | } 206 | --------------------------------------------------------------------------------