Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 27 additions & 5 deletions src/commands/database/db-status.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { readFile, readdir } from 'fs/promises'
import { join } from 'path'

import { chalk, log, logJson, netlifyCommand } from '../../utils/command-helpers.js'
import BaseCommand from '../base-command.js'
Expand All @@ -10,6 +11,7 @@ import {
} from './util/applied-migrations.js'
import { connectToDatabase, detectExistingLocalConnectionString } from './util/db-connection.js'
import { resolveMigrationsDirectory } from './util/migrations-path.js'
import { fileExistsAsync } from '../../lib/fs.js'

export interface DatabaseStatusOptions {
branch?: string
Expand Down Expand Up @@ -50,14 +52,16 @@ const logConnectCommands = () => {
}

const parseVersion = (name: string): number | null => {
const match = /^(\d+)[_-]/.exec(name)
const match = /^(\d+)_/.exec(name)
if (!match) {
return null
}
const parsed = Number.parseInt(match[1], 10)
return Number.isFinite(parsed) ? parsed : null
}

const MIGRATION_NAME_PATTERN = /^\d+_[a-z0-9_-]+$/

const readLocalMigrations = async (migrationsDirectory: string): Promise<MigrationEntry[]> => {
let entries
try {
Expand All @@ -69,16 +73,34 @@ const readLocalMigrations = async (migrationsDirectory: string): Promise<Migrati
throw error
}

const migrations: MigrationEntry[] = []
// First pass is to extract migration names
const migrationNames: string[] = []
for (const entry of entries) {
if (!entry.isDirectory()) {
if (entry.isDirectory()) {
if (
MIGRATION_NAME_PATTERN.test(entry.name) &&
(await fileExistsAsync(join(migrationsDirectory, entry.name, 'migration.sql')))
) {
migrationNames.push(entry.name)
}
continue
}
const version = parseVersion(entry.name)

if (entry.isFile() && entry.name.endsWith('.sql')) {
const migrationName = entry.name.replace(/\.sql$/, '')
if (MIGRATION_NAME_PATTERN.test(migrationName)) {
migrationNames.push(migrationName)
}
}
}
// Second pass to parse version and create migration entries
const migrations: MigrationEntry[] = []
for (const migrationName of migrationNames) {
const version = parseVersion(migrationName)
if (version === null) {
continue
}
migrations.push({ name: entry.name, version })
migrations.push({ name: migrationName, version })
}
migrations.sort((a, b) => a.version - b.version)
return migrations
Expand Down
172 changes: 155 additions & 17 deletions tests/unit/commands/database/db-status.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { relative, sep } from 'path'

import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest'

const {
mockReaddir,
mockReadFile,
mockFileExistsAsync,
mockConnectToDatabase,
mockDetectExisting,
mockQuery,
Expand All @@ -13,6 +16,7 @@ const {
} = vi.hoisted(() => {
const mockReaddir = vi.fn()
const mockReadFile = vi.fn()
const mockFileExistsAsync = vi.fn()
const mockQuery = vi.fn()
const mockCleanup = vi.fn().mockResolvedValue(undefined)
const mockConnectToDatabase = vi.fn()
Expand All @@ -23,6 +27,7 @@ const {
return {
mockReaddir,
mockReadFile,
mockFileExistsAsync,
mockConnectToDatabase,
mockDetectExisting,
mockQuery,
Expand Down Expand Up @@ -64,6 +69,12 @@ vi.mock('../../../../src/utils/command-helpers.js', async () => ({
},
}))

vi.mock('../../../../src/lib/fs.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../../src/lib/fs.js')>()),
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
fileExistsAsync: (path: string) => mockFileExistsAsync(path),
}))

vi.mock('../../../../src/commands/database/util/db-connection.js', () => ({
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
connectToDatabase: (...args: unknown[]) => mockConnectToDatabase(...args),
Expand All @@ -81,11 +92,60 @@ const LOCAL_CONN_NO_CREDS = 'postgres://localhost:5432/postgres'
const BRANCH_CONN = 'postgres://admin:[email protected]/db'
const PROD_CONN = 'postgres://owner:[email protected]/db'

const makeDirents = (names: string[]) =>
names.map((name) => ({
name,
isDirectory: () => true,
}))
interface MockFSNode {
files?: Record<string, string>
dirs?: Record<string, MockFSNode>
}

const DEFAULT_MOCK_FS_ROOT = '/project/netlify/database/migrations'

const mockFS = (tree: MockFSNode, { root = DEFAULT_MOCK_FS_ROOT }: { root?: string } = {}) => {
const resolve = (absolutePath: string): { kind: 'dir'; node: MockFSNode } | { kind: 'file' } | null => {
const rel = relative(root, absolutePath)
if (rel === '') return { kind: 'dir', node: tree }
if (rel.startsWith('..')) return null
const parts = rel.split(sep)
let cursor: MockFSNode = tree
for (let i = 0; i < parts.length; i += 1) {
const part = parts[i]
const isLast = i === parts.length - 1
if (isLast && cursor.files && part in cursor.files) return { kind: 'file' }
if (cursor.dirs && part in cursor.dirs) {
cursor = cursor.dirs[part]
if (isLast) return { kind: 'dir', node: cursor }
continue
}
return null
}
return null
}

mockReaddir.mockImplementation((path: unknown) => {
const resolved = typeof path === 'string' ? resolve(path) : null
if (!resolved || resolved.kind !== 'dir') {
return Promise.reject(Object.assign(new Error(`ENOENT: ${String(path)}`), { code: 'ENOENT' }))
}
const dirEntries = Object.keys(resolved.node.dirs ?? {}).map((name) => ({
name,
isDirectory: () => true,
isFile: () => false,
}))
const fileEntries = Object.keys(resolved.node.files ?? {}).map((name) => ({
name,
isDirectory: () => false,
isFile: () => true,
}))
return Promise.resolve([...dirEntries, ...fileEntries])
})

mockFileExistsAsync.mockImplementation((path: unknown) =>
Promise.resolve(typeof path === 'string' && resolve(path) !== null),
)
}

const migrationsTree = (names: string[]): MockFSNode => ({
dirs: Object.fromEntries(names.map((name) => [name, { files: { 'migration.sql': '' } }])),
})

function createMockCommand(
overrides: { siteRoot?: string | null; migrationsPath?: string | null; siteId?: string | null } = {},
Expand Down Expand Up @@ -178,7 +238,7 @@ beforeEach(() => {
logMessages.length = 0
jsonMessages.length = 0
vi.clearAllMocks()
mockReaddir.mockResolvedValue([])
mockFS({})
mockCleanup.mockResolvedValue(undefined)
mockLocalAppliedRows([])
setupFetchRouter({ siteDatabase: null })
Expand Down Expand Up @@ -288,7 +348,7 @@ describe('statusDb', () => {
test('still connects and reads migration state when no local database is already running', async () => {
mockDetectExisting.mockReturnValue(null)
mockLocalAppliedRows(['0001_a'])
mockReaddir.mockResolvedValue(makeDirents(['0001_a', '0002_b']))
mockFS(migrationsTree(['0001_a', '0002_b']))

await statusDb({ json: true }, createMockCommand())

Expand All @@ -315,7 +375,7 @@ describe('statusDb', () => {
test('default output hints at starting a persistent local database when none is running', async () => {
mockDetectExisting.mockReturnValue(null)
mockLocalAppliedRows(['0001_a'])
mockReaddir.mockResolvedValue(makeDirents(['0001_a', '0002_b']))
mockFS(migrationsTree(['0001_a', '0002_b']))

await statusDb({}, createMockCommand())

Expand All @@ -338,7 +398,7 @@ describe('statusDb', () => {

test('reports applied and pending correctly', async () => {
mockLocalAppliedRows(['0001_a', '0002_b'])
mockReaddir.mockResolvedValue(makeDirents(['0001_a', '0002_b', '0003_c']))
mockFS(migrationsTree(['0001_a', '0002_b', '0003_c']))

await statusDb({ json: true }, createMockCommand())

Expand Down Expand Up @@ -370,7 +430,7 @@ describe('statusDb', () => {

test('default output shows applied and pending as bullets', async () => {
mockLocalAppliedRows(['0001_a'])
mockReaddir.mockResolvedValue(makeDirents(['0001_a', '0002_b']))
mockFS(migrationsTree(['0001_a', '0002_b']))

await statusDb({}, createMockCommand())

Expand All @@ -383,7 +443,7 @@ describe('statusDb', () => {

test('default output includes the apply-command hint when pending migrations exist on local', async () => {
mockLocalAppliedRows([])
mockReaddir.mockResolvedValue(makeDirents(['0001_a']))
mockFS(migrationsTree(['0001_a']))

await statusDb({}, createMockCommand())

Expand All @@ -392,7 +452,7 @@ describe('statusDb', () => {

test('default output omits the apply-command hint when there are no pending migrations', async () => {
mockLocalAppliedRows(['0001_a'])
mockReaddir.mockResolvedValue(makeDirents(['0001_a']))
mockFS(migrationsTree(['0001_a']))

await statusDb({}, createMockCommand())

Expand Down Expand Up @@ -420,6 +480,85 @@ describe('statusDb', () => {
})
})

describe('local migration discovery', () => {
test('ignores directories that do not contain a migration.sql file', async () => {
mockFS({
dirs: {
'0001_with_sql': { files: { 'migration.sql': '' } },
'0002_without_sql': {},
'0003_wrong_file': { files: { 'readme.md': '' } },
},
})

await statusDb({ json: true }, createMockCommand())

expect(jsonMessages[0]).toMatchObject({
pending: [{ version: 1, name: '0001_with_sql' }],
})
})

test('includes .sql files sitting directly under the migrations directory', async () => {
mockFS({
files: {
'0001_a.sql': '',
'0002_b.sql': '',
},
})

await statusDb({ json: true }, createMockCommand())

expect(jsonMessages[0]).toMatchObject({
pending: [
{ version: 1, name: '0001_a' },
{ version: 2, name: '0002_b' },
],
})
})

test('ignores directories whose name does not match the migration pattern', async () => {
mockFS({
dirs: {
'0001_valid': { files: { 'migration.sql': '' } },
'0002_with-hyphen': { files: { 'migration.sql': '' } },
'not-a-migration': { files: { 'migration.sql': '' } },
'0003_UPPERCASE': { files: { 'migration.sql': '' } },
'0004-hyphen-separator': { files: { 'migration.sql': '' } },
no_leading_digits: { files: { 'migration.sql': '' } },
},
})

await statusDb({ json: true }, createMockCommand())

expect(jsonMessages[0]).toMatchObject({
pending: [
{ version: 1, name: '0001_valid' },
{ version: 2, name: '0002_with-hyphen' },
],
})
})

test('ignores .sql files whose name does not match the migration pattern', async () => {
mockFS({
files: {
'0001_valid.sql': '',
'0002_with-hyphen.sql': '',
'random.sql': '',
'0003_UPPERCASE.sql': '',
'0004-hyphen-separator.sql': '',
},
})

await statusDb({ json: true }, createMockCommand())

expect(jsonMessages[0]).toMatchObject({
pending: [
{ version: 1, name: '0001_valid' },
{ version: 2, name: '0002_with-hyphen' },
],
})
})
})

describe('secondary descriptive lines', () => {
test('renders a descriptive line under Enabled when true', async () => {
process.env.NETLIFY_DB_URL = 'postgres://x/y'
Expand Down Expand Up @@ -451,7 +590,6 @@ describe('statusDb', () => {

test('renders an installed statement under Package when installed', async () => {
await statusDb({}, createMockCommand())

expect(logMessages.join('\n')).toContain('The @netlify/database package is installed')
})

Expand Down Expand Up @@ -493,7 +631,7 @@ describe('statusDb', () => {

test('always renders the immutability note below the Applied migrations list', async () => {
mockLocalAppliedRows(['0001_a'])
mockReaddir.mockResolvedValue(makeDirents(['0001_a']))
mockFS(migrationsTree(['0001_a']))

await statusDb({}, createMockCommand())

Expand Down Expand Up @@ -540,7 +678,7 @@ describe('statusDb', () => {

test('uses the dynamic command in the apply-pending hint', async () => {
mockLocalAppliedRows([])
mockReaddir.mockResolvedValue(makeDirents(['0001_a']))
mockFS(migrationsTree(['0001_a']))

await statusDb({}, createMockCommand())

Expand Down Expand Up @@ -595,7 +733,7 @@ describe('statusDb', () => {
],
},
})
mockReaddir.mockResolvedValue(makeDirents(['0001_a', '0002_b', '0003_c']))
mockFS(migrationsTree(['0001_a', '0002_b', '0003_c']))

await statusDb({ branch: 'feature-x', json: true }, createMockCommand())

Expand Down Expand Up @@ -628,7 +766,7 @@ describe('statusDb', () => {
branch: { 'feature-x': { connection_string: BRANCH_CONN } },
migrations: { 'feature-x': [] },
})
mockReaddir.mockResolvedValue(makeDirents(['0001_a']))
mockFS(migrationsTree(['0001_a']))

await statusDb({ branch: 'feature-x' }, createMockCommand())

Expand Down
Loading