Compare commits
189
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9447d18323 | ||
|
|
9da69f4234 | ||
|
|
2c304dc363 | ||
|
|
02a394c99d | ||
|
|
b49859537b | ||
|
|
bed80b046b | ||
|
|
495a21b67b | ||
|
|
a32647ee16 | ||
|
|
08e1417393 | ||
|
|
70282fff73 | ||
|
|
43265dac0f | ||
|
|
66b90ca54b | ||
|
|
47108809aa | ||
|
|
720476fd6e | ||
|
|
572d03fe42 | ||
|
|
f1b9079b2d | ||
|
|
bb671c3680 | ||
|
|
bc8284db25 | ||
|
|
29e747393d | ||
|
|
df980a3434 | ||
|
|
778379538b | ||
|
|
7a9219b6e3 | ||
|
|
fd9c37cd25 | ||
|
|
0235a28add | ||
|
|
d961536186 | ||
|
|
d36806047b | ||
|
|
948419ffd3 | ||
|
|
b38f53c603 | ||
|
|
ccba53c494 | ||
|
|
53327b9a5a | ||
|
|
734b14d48d | ||
|
|
938f0ed51f | ||
|
|
6e2e7eb19e | ||
|
|
82e86cdf39 | ||
|
|
b7ae00ab52 | ||
|
|
8f8664357e | ||
|
|
7bd61a21b5 | ||
|
|
6f6b9834ca | ||
|
|
9cc10715e3 | ||
|
|
bbab5ce936 | ||
|
|
275424c605 | ||
|
|
b6c0cd3d1b | ||
|
|
d7d1d99ebc | ||
|
|
43849324ce | ||
|
|
e1df3b7cee | ||
|
|
f7b5d5f054 | ||
|
|
99fc4b0944 | ||
|
|
278d488363 | ||
|
|
2f69205aa0 | ||
|
|
b91985c493 | ||
|
|
80a3453233 | ||
|
|
ade71b75fa | ||
|
|
799b7feb53 | ||
|
|
eb40201b59 | ||
|
|
1461f8f69f | ||
|
|
ea70e19a46 | ||
|
|
52f4642d97 | ||
|
|
a84592374c | ||
|
|
ca226e83df | ||
|
|
19a2cc1fe3 | ||
|
|
58e46e5b59 | ||
|
|
161ac317f0 | ||
|
|
ee5c009234 | ||
|
|
9e9a17d578 | ||
|
|
7b628b888c | ||
|
|
ce3b615b0a | ||
|
|
84a6b31873 | ||
|
|
f968e3d4a6 | ||
|
|
3d57c947cd | ||
|
|
cdd066dafe | ||
|
|
5c25f333b7 | ||
|
|
77e6e72f5a | ||
|
|
4d4bc09b86 | ||
|
|
96281c9f03 | ||
|
|
d6d0200938 | ||
|
|
4d299fda98 | ||
|
|
4115a11845 | ||
|
|
e8ac0e8c35 | ||
|
|
098e927760 | ||
|
|
ab3c9217df | ||
|
|
16af529120 | ||
|
|
7fb343596a | ||
|
|
241bfc2302 | ||
|
|
92d5df9a64 | ||
|
|
b440d50b66 | ||
|
|
052d6f5fac | ||
|
|
9066d36e71 | ||
|
|
76b8321065 | ||
|
|
2b6bb7f948 | ||
|
|
51b63f659e | ||
|
|
ae0efdc008 | ||
|
|
be08c8199f | ||
|
|
5ba20e0581 | ||
|
|
19b592820c | ||
|
|
b158a98acc | ||
|
|
465db7643c | ||
|
|
d84306934a | ||
|
|
e650406177 | ||
|
|
fc3409f324 | ||
|
|
97139723c9 | ||
|
|
d44945b475 | ||
|
|
3b88c386a1 | ||
|
|
b95b74f0a3 | ||
|
|
5d9ff5df03 | ||
|
|
2cecb4c11c | ||
|
|
316d9b0e7f | ||
|
|
17ae8e050a | ||
|
|
f0410221d8 | ||
|
|
1c217b546c | ||
|
|
1bcdf29206 | ||
|
|
5c31deb630 | ||
|
|
c2def00bcf | ||
|
|
784dc1f0da | ||
|
|
7d93bee4bd | ||
|
|
2aecd1312e | ||
|
|
60c5cc40b2 | ||
|
|
5edb004799 | ||
|
|
764d00c249 | ||
|
|
47b77763cb | ||
|
|
7805d9b6f0 | ||
|
|
40a0e6a0aa | ||
|
|
99d63aa5f4 | ||
|
|
ee94ddc133 | ||
|
|
651c7aa3f4 | ||
|
|
1cd9cd8803 | ||
|
|
ab735d1f3a | ||
|
|
e29c7e31c1 | ||
|
|
43f4680176 | ||
|
|
d9f27c1775 | ||
|
|
bb7ceb37fe | ||
|
|
6a759ef3d1 | ||
|
|
cb735f0754 | ||
|
|
80fb49bc5e | ||
|
|
9190df81dd | ||
|
|
9235ef5e08 | ||
|
|
b91d6b33b5 | ||
|
|
30ef1db010 | ||
|
|
2d97a47ee1 | ||
|
|
72200ea72e | ||
|
|
608893a3d6 | ||
|
|
53ff745d5d | ||
|
|
17bc8ed395 | ||
|
|
a447b68b22 | ||
|
|
4303dcf59b | ||
|
|
e828d48798 | ||
|
|
6e470a9239 | ||
|
|
096815fe49 | ||
|
|
b8f60203cb | ||
|
|
15763f60cc | ||
|
|
6d2884f5cf | ||
|
|
f192decff8 | ||
|
|
8b906cf4a3 | ||
|
|
0a3966e6fc | ||
|
|
d30fc24f55 | ||
|
|
16a489d0b8 | ||
|
|
3524e86282 | ||
|
|
1e54fdcd7f | ||
|
|
fb104ea084 | ||
|
|
837160b77a | ||
|
|
ed7130bba8 | ||
|
|
4ca1810d07 | ||
|
|
c0880cb076 | ||
|
|
988798998d | ||
|
|
535a91d4be | ||
|
|
bd54e85727 | ||
|
|
b042b2d508 | ||
|
|
af1733dc9a | ||
|
|
389fff2b44 | ||
|
|
f331ba2b61 | ||
|
|
f4b8fc5382 | ||
|
|
dc9172cc7c | ||
|
|
ee88c07989 | ||
|
|
ff1180524a | ||
|
|
3d9cc7ec58 | ||
|
|
480038d51d | ||
|
|
77436757c8 | ||
|
|
5e6f03e412 | ||
|
|
1dcbc79387 | ||
|
|
59c4a5ebf8 | ||
|
|
091e1913ee | ||
|
|
0e6e94797c | ||
|
|
a033349c76 | ||
|
|
466d657ea7 | ||
|
|
47bf748fd5 | ||
|
|
88589e00e7 | ||
|
|
4cdccde9cf | ||
|
|
aba22cb574 | ||
|
|
d0630b4899 | ||
|
|
c9eed9b794 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
description: Build the RelSpec binary
|
||||
---
|
||||
|
||||
Build the RelSpec project by running `make build`. Report the build status and any errors encountered.
|
||||
@@ -1,9 +0,0 @@
|
||||
---
|
||||
description: Generate test coverage report
|
||||
---
|
||||
|
||||
Generate and display test coverage for RelSpec:
|
||||
1. Run `go test -cover ./...` to get coverage percentage
|
||||
2. If detailed coverage is needed, run `go test -coverprofile=coverage.out ./...` and then `go tool cover -html=coverage.out` to generate HTML report
|
||||
|
||||
Show coverage statistics and identify areas needing more tests.
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
description: Run Go linters on the codebase
|
||||
---
|
||||
|
||||
Run linting tools on the RelSpec codebase:
|
||||
1. First run `gofmt -l .` to check formatting
|
||||
2. If golangci-lint is available, run `golangci-lint run ./...`
|
||||
3. Run `go vet ./...` to check for suspicious constructs
|
||||
|
||||
Report any issues found and suggest fixes if needed.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
description: Run all tests for the RelSpec project
|
||||
---
|
||||
|
||||
Run `go test ./...` to execute all unit tests in the project. Show a summary of the results and highlight any failures.
|
||||
@@ -0,0 +1,366 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Tag to release (e.g. v1.2.3)'
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: go vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: Install lint tools
|
||||
run: |
|
||||
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
|
||||
go install honnef.co/go/tools/cmd/staticcheck@latest
|
||||
go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: gofumpt (golangci-lint fmt)
|
||||
run: |
|
||||
diff=$(golangci-lint fmt --diff)
|
||||
if [ -n "$diff" ]; then
|
||||
echo "$diff"
|
||||
echo "Formatting issues found. Run: make fmt"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: staticcheck
|
||||
run: staticcheck ./...
|
||||
|
||||
- name: govulncheck
|
||||
run: govulncheck ./...
|
||||
|
||||
- name: Test
|
||||
run: go test ./...
|
||||
|
||||
release:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build release binaries
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.tag || github.ref_name }}"
|
||||
BUILD_DATE="$(date -u +"%Y-%m-%d %H:%M:%S UTC")"
|
||||
LDFLAGS="-X 'git.warky.dev/wdevs/relspecgo/pkg/buildinfo.Version=${VERSION}' -X 'git.warky.dev/wdevs/relspecgo/pkg/buildinfo.BuildDate=${BUILD_DATE}'"
|
||||
for target in "linux/amd64" "linux/arm64" "darwin/amd64" "darwin/arm64" "windows/amd64"; do
|
||||
GOOS="${target%/*}"
|
||||
GOARCH="${target#*/}"
|
||||
EXT=""
|
||||
[ "$GOOS" = "windows" ] && EXT=".exe"
|
||||
NAME="relspec-${GOOS}-${GOARCH}${EXT}"
|
||||
GOOS="$GOOS" GOARCH="$GOARCH" go build \
|
||||
-trimpath \
|
||||
-ldflags "$LDFLAGS" \
|
||||
-o "$NAME" ./cmd/relspec
|
||||
echo "Built $NAME"
|
||||
done
|
||||
|
||||
- name: Build Windows installer
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y nsis
|
||||
VERSION="${{ github.event.inputs.tag || github.ref_name }}"
|
||||
makensis -DVERSION="${VERSION#v}" -DEXE="$PWD/relspec-windows-amd64.exe" -DOUT="$PWD/relspec-setup-windows-amd64.exe" windows/installer.nsi
|
||||
|
||||
- name: Create release and upload assets
|
||||
run: |
|
||||
TAG="${{ github.event.inputs.tag || github.ref_name }}"
|
||||
API="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases"
|
||||
|
||||
# Collect commits since the previous tag (or last 20 if no prior tag)
|
||||
PREV_TAG=$(git tag --sort=-version:refname | grep -v "^${TAG}$" | head -1)
|
||||
if [ -n "$PREV_TAG" ]; then
|
||||
RANGE="${PREV_TAG}..${TAG}"
|
||||
else
|
||||
RANGE="HEAD~20..HEAD"
|
||||
fi
|
||||
NOTES=$(git log "$RANGE" --pretty=format:"- %s" --no-merges)
|
||||
BODY="## What's changed"$'\n'"${NOTES}"
|
||||
|
||||
# Escape for JSON
|
||||
BODY_JSON=$(printf '%s' "$BODY" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')
|
||||
|
||||
RELEASE=$(curl -s -X POST "$API" \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"body\":${BODY_JSON}}")
|
||||
|
||||
UPLOAD_URL=$(echo "$RELEASE" | grep -o '"upload_url":"[^"]*"' | cut -d'"' -f4 | sed 's/{[^}]*}//')
|
||||
if [ -z "$UPLOAD_URL" ]; then
|
||||
echo "Failed to create release: $RELEASE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for f in relspec-*; do
|
||||
echo "Uploading $f..."
|
||||
curl -s -X POST "${UPLOAD_URL}?name=${f}" \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@${f}" > /dev/null
|
||||
done
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
pkg-aur:
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Publish to AUR
|
||||
env:
|
||||
AUR_SSH_KEY: ${{ secrets.AUR_SSH_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${{ github.event.inputs.tag || github.ref_name }}"
|
||||
PKGVER="${VERSION#v}"
|
||||
AUR_KEY_PATH="$HOME/.ssh/aur"
|
||||
AUR_KNOWN_HOSTS="$HOME/.ssh/known_hosts"
|
||||
|
||||
# Setup SSH for AUR
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
|
||||
if [ -z "${AUR_SSH_KEY:-}" ]; then
|
||||
echo "AUR_SSH_KEY is empty"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Support raw multiline keys, escaped \\n secrets, or base64-encoded keys.
|
||||
CLEAN_AUR_SSH_KEY="$(printf '%s' "$AUR_SSH_KEY" | tr -d '\r')"
|
||||
if printf '%s' "$CLEAN_AUR_SSH_KEY" | grep -q "^-----BEGIN .*PRIVATE KEY-----$"; then
|
||||
printf '%s\n' "$CLEAN_AUR_SSH_KEY" > "$AUR_KEY_PATH"
|
||||
elif printf '%s' "$CLEAN_AUR_SSH_KEY" | grep -q '\\n'; then
|
||||
printf '%b\n' "$CLEAN_AUR_SSH_KEY" > "$AUR_KEY_PATH"
|
||||
else
|
||||
if printf '%s' "$CLEAN_AUR_SSH_KEY" | tr -d '[:space:]' | base64 --decode > "$AUR_KEY_PATH" 2>/dev/null; then
|
||||
:
|
||||
else
|
||||
printf '%s\n' "$CLEAN_AUR_SSH_KEY" > "$AUR_KEY_PATH"
|
||||
fi
|
||||
fi
|
||||
chmod 600 "$AUR_KEY_PATH"
|
||||
|
||||
if ! ssh-keygen -y -f "$AUR_KEY_PATH" >/dev/null 2>&1; then
|
||||
echo "AUR_SSH_KEY is not a valid private key."
|
||||
echo "Store it as a raw private key, an escaped private key with \\n, or a base64-encoded private key."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ssh-keyscan -t rsa,ed25519 aur.archlinux.org >> "$AUR_KNOWN_HOSTS"
|
||||
chmod 644 "$AUR_KNOWN_HOSTS"
|
||||
|
||||
# Clone AUR repo
|
||||
GIT_SSH_COMMAND="ssh -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$AUR_KNOWN_HOSTS -i $AUR_KEY_PATH" \
|
||||
git clone ssh://aur@aur.archlinux.org/relspec.git aur-repo
|
||||
|
||||
CURRENT_PKGVER=$(awk -F= '/^pkgver=/ {print $2; exit}' aur-repo/PKGBUILD | tr -d "[:space:]")
|
||||
CURRENT_PKGREL=$(awk -F= '/^pkgrel=/ {print $2; exit}' aur-repo/PKGBUILD | tr -d "[:space:]")
|
||||
|
||||
if [ "$CURRENT_PKGVER" = "$PKGVER" ]; then
|
||||
case "$CURRENT_PKGREL" in
|
||||
''|*[!0-9]*)
|
||||
echo "Unsupported pkgrel in AUR repo: ${CURRENT_PKGREL}"
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
PKGREL=$((CURRENT_PKGREL + 1))
|
||||
;;
|
||||
esac
|
||||
else
|
||||
PKGREL=1
|
||||
fi
|
||||
|
||||
echo "Publishing AUR package version ${PKGVER}-${PKGREL}"
|
||||
|
||||
# Compute SHA256 of the source archive from the same URL the PKGBUILD will download.
|
||||
SHA=$(curl -fsSL "https://git.warky.dev/wdevs/relspecgo/archive/v${PKGVER}.zip" | sha256sum | cut -d' ' -f1)
|
||||
|
||||
# Update PKGBUILD — keep remote source URL, bump version/checksum, and increment pkgrel for same-version rebuilds.
|
||||
sed -e "s/^pkgver=.*/pkgver=${PKGVER}/" \
|
||||
-e "s/^pkgrel=.*/pkgrel=${PKGREL}/" \
|
||||
-e "s/^sha256sums=.*/sha256sums=('${SHA}')/" \
|
||||
linux/arch/PKGBUILD > aur-repo/PKGBUILD
|
||||
|
||||
# Generate .SRCINFO inside an Arch container (docker cp avoids DinD volume mount issues)
|
||||
CID=$(docker run -d archlinux:latest sleep infinity)
|
||||
docker cp aur-repo/PKGBUILD $CID:/build/PKGBUILD || (docker exec $CID mkdir -p /build && docker cp aur-repo/PKGBUILD $CID:/build/PKGBUILD)
|
||||
docker exec $CID bash -c "
|
||||
pacman -Sy --noconfirm base-devel &&
|
||||
useradd -m builder &&
|
||||
chown -R builder:builder /build &&
|
||||
runuser -u builder -- bash -c 'cd /build && makepkg --printsrcinfo > .SRCINFO'
|
||||
"
|
||||
docker cp $CID:/build/.SRCINFO aur-repo/.SRCINFO
|
||||
docker rm -f $CID
|
||||
|
||||
# Commit and push to AUR master
|
||||
cd aur-repo
|
||||
git config user.email "hein@warky.dev"
|
||||
git config user.name "Hein"
|
||||
git add PKGBUILD .SRCINFO
|
||||
git commit -m "Update to v${PKGVER}-${PKGREL}"
|
||||
GIT_SSH_COMMAND="ssh -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$AUR_KNOWN_HOSTS -i $AUR_KEY_PATH" \
|
||||
git push origin HEAD:master
|
||||
|
||||
pkg-deb:
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build Debian packages
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.tag || github.ref_name }}"
|
||||
PKGVER="${VERSION#v}"
|
||||
BUILD_DATE="$(date -u +"%Y-%m-%d %H:%M:%S UTC")"
|
||||
|
||||
for GOARCH in amd64 arm64; do
|
||||
GOOS=linux GOARCH=$GOARCH go build \
|
||||
-trimpath \
|
||||
-ldflags "-X 'git.warky.dev/wdevs/relspecgo/pkg/buildinfo.Version=v${PKGVER}' -X 'git.warky.dev/wdevs/relspecgo/pkg/buildinfo.BuildDate=${BUILD_DATE}'" \
|
||||
-o relspec ./cmd/relspec
|
||||
|
||||
PKGDIR="relspec_${PKGVER}_${GOARCH}"
|
||||
mkdir -p "${PKGDIR}/DEBIAN"
|
||||
mkdir -p "${PKGDIR}/usr/bin"
|
||||
chmod -R 0755 "${PKGDIR}"
|
||||
|
||||
install -m755 relspec "${PKGDIR}/usr/bin/relspec"
|
||||
|
||||
sed -e "s/VERSION/${PKGVER}/" \
|
||||
-e "s/ARCH/${GOARCH}/" \
|
||||
linux/debian/control > "${PKGDIR}/DEBIAN/control"
|
||||
|
||||
dpkg-deb --build --root-owner-group "${PKGDIR}"
|
||||
echo "Built ${PKGDIR}.deb"
|
||||
done
|
||||
|
||||
- name: Upload to release
|
||||
run: |
|
||||
TAG="${{ github.event.inputs.tag || github.ref_name }}"
|
||||
RELEASE=$(curl -s "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}")
|
||||
UPLOAD_URL=$(echo "$RELEASE" | grep -o '"upload_url":"[^"]*"' | cut -d'"' -f4 | sed 's/{[^}]*}//')
|
||||
for f in *.deb; do
|
||||
FNAME=$(basename "$f")
|
||||
echo "Uploading $FNAME..."
|
||||
curl -s -X POST "${UPLOAD_URL}?name=${FNAME}" \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@${f}" > /dev/null
|
||||
done
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
pkg-rpm:
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Build RPM
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${{ github.event.inputs.tag || github.ref_name }}"
|
||||
PKGVER="${VERSION#v}"
|
||||
GO_VER="$(awk '/^go / { print $2; exit }' go.mod)"
|
||||
|
||||
if [ -z "${GO_VER}" ]; then
|
||||
echo "Failed to determine Go version from go.mod"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Source tarball — prefix=relspec-VERSION/ matches RPM %autosetup convention
|
||||
git archive --format=tar.gz --prefix=relspec-${PKGVER}/ HEAD \
|
||||
> relspec-${PKGVER}.tar.gz
|
||||
|
||||
# Patch spec version
|
||||
sed -i "s/^Version:.*/Version: ${PKGVER}/" linux/centos/relspec.spec
|
||||
|
||||
mkdir -p linux/centos/out
|
||||
CID=$(docker create \
|
||||
-e GO_VER="${GO_VER}" \
|
||||
-e PKGVER="${PKGVER}" \
|
||||
-w /build \
|
||||
rockylinux:9 \
|
||||
bash -lc "
|
||||
set -euo pipefail
|
||||
# Avoid transient/out-of-sync mirrors while bootstrapping the RPM builder.
|
||||
sed -i \
|
||||
-e 's|^mirrorlist=|#mirrorlist=|' \
|
||||
-e 's|^#baseurl=http://dl.rockylinux.org|baseurl=https://dl.rockylinux.org|' \
|
||||
/etc/yum.repos.d/rocky*.repo
|
||||
dnf clean all
|
||||
dnf -y --setopt=timeout=60 --setopt=retries=5 install rpm-build git
|
||||
curl -fsSL https://go.dev/dl/go\${GO_VER}.linux-amd64.tar.gz | tar -C /usr/local -xz &&
|
||||
export PATH=\$PATH:/usr/local/go/bin &&
|
||||
mkdir -p ~/rpmbuild/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS} &&
|
||||
cp relspec-${PKGVER}.tar.gz ~/rpmbuild/SOURCES/ &&
|
||||
cp linux/centos/relspec.spec ~/rpmbuild/SPECS/ &&
|
||||
rpmbuild --nodeps -ba ~/rpmbuild/SPECS/relspec.spec
|
||||
")
|
||||
|
||||
cleanup() {
|
||||
docker rm -f "$CID" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
docker cp relspec-${PKGVER}.tar.gz "$CID:/build/relspec-${PKGVER}.tar.gz"
|
||||
docker cp linux "$CID:/build/linux"
|
||||
|
||||
docker start -a "$CID"
|
||||
docker cp "$CID:/root/rpmbuild/RPMS/." linux/centos/out/
|
||||
|
||||
trap - EXIT
|
||||
cleanup
|
||||
|
||||
- name: Upload to release
|
||||
run: |
|
||||
TAG="${{ github.event.inputs.tag || github.ref_name }}"
|
||||
RELEASE=$(curl -s "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}")
|
||||
UPLOAD_URL=$(echo "$RELEASE" | grep -o '"upload_url":"[^"]*"' | cut -d'"' -f4 | sed 's/{[^}]*}//')
|
||||
while IFS= read -r f; do
|
||||
FNAME=$(basename "$f")
|
||||
echo "Uploading $FNAME..."
|
||||
curl -s -X POST "${UPLOAD_URL}?name=${FNAME}" \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@${f}" > /dev/null
|
||||
done < <(find linux/centos/out -name "*.rpm")
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -1,116 +0,0 @@
|
||||
name: Release
|
||||
run-name: "Making Release"
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
|
||||
jobs:
|
||||
build-and-release:
|
||||
name: Build and Release
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25'
|
||||
|
||||
- name: Get version from tag
|
||||
id: get_version
|
||||
run: |
|
||||
echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
echo "Version: ${GITHUB_REF#refs/tags/}"
|
||||
|
||||
- name: Build binaries for multiple platforms
|
||||
run: |
|
||||
mkdir -p dist
|
||||
|
||||
# Linux AMD64
|
||||
GOOS=linux GOARCH=amd64 go build -o dist/relspec-linux-amd64 -ldflags "-X main.version=${{ steps.get_version.outputs.VERSION }}" ./cmd/relspec
|
||||
|
||||
# Linux ARM64
|
||||
GOOS=linux GOARCH=arm64 go build -o dist/relspec-linux-arm64 -ldflags "-X main.version=${{ steps.get_version.outputs.VERSION }}" ./cmd/relspec
|
||||
|
||||
# macOS AMD64
|
||||
GOOS=darwin GOARCH=amd64 go build -o dist/relspec-darwin-amd64 -ldflags "-X main.version=${{ steps.get_version.outputs.VERSION }}" ./cmd/relspec
|
||||
|
||||
# macOS ARM64 (Apple Silicon)
|
||||
GOOS=darwin GOARCH=arm64 go build -o dist/relspec-darwin-arm64 -ldflags "-X main.version=${{ steps.get_version.outputs.VERSION }}" ./cmd/relspec
|
||||
|
||||
# Windows AMD64
|
||||
GOOS=windows GOARCH=amd64 go build -o dist/relspec-windows-amd64.exe -ldflags "-X main.version=${{ steps.get_version.outputs.VERSION }}" ./cmd/relspec
|
||||
|
||||
# Create checksums
|
||||
cd dist
|
||||
sha256sum * > checksums.txt
|
||||
cd ..
|
||||
|
||||
- name: Generate release notes
|
||||
id: release_notes
|
||||
run: |
|
||||
# Get the previous tag
|
||||
previous_tag=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$previous_tag" ]; then
|
||||
# No previous tag, get all commits
|
||||
commits=$(git log --pretty=format:"- %s (%h)" --no-merges)
|
||||
else
|
||||
# Get commits since the previous tag
|
||||
commits=$(git log "${previous_tag}..HEAD" --pretty=format:"- %s (%h)" --no-merges)
|
||||
fi
|
||||
|
||||
# Create release notes
|
||||
cat > release_notes.md << EOF
|
||||
# Release ${{ steps.get_version.outputs.VERSION }}
|
||||
|
||||
## Changes
|
||||
|
||||
${commits}
|
||||
|
||||
## Installation
|
||||
|
||||
Download the appropriate binary for your platform:
|
||||
|
||||
- **Linux (AMD64)**: \`relspec-linux-amd64\`
|
||||
- **Linux (ARM64)**: \`relspec-linux-arm64\`
|
||||
- **macOS (Intel)**: \`relspec-darwin-amd64\`
|
||||
- **macOS (Apple Silicon)**: \`relspec-darwin-arm64\`
|
||||
- **Windows (AMD64)**: \`relspec-windows-amd64.exe\`
|
||||
|
||||
Make the binary executable (Linux/macOS):
|
||||
\`\`\`bash
|
||||
chmod +x relspec-*
|
||||
\`\`\`
|
||||
|
||||
Verify the download with the provided checksums.
|
||||
EOF
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
body_path: release_notes.md
|
||||
files: |
|
||||
dist/relspec-linux-amd64
|
||||
dist/relspec-linux-arm64
|
||||
dist/relspec-darwin-amd64
|
||||
dist/relspec-darwin-arm64
|
||||
dist/relspec-windows-amd64.exe
|
||||
dist/checksums.txt
|
||||
draft: false
|
||||
prerelease: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "Release ${{ steps.get_version.outputs.VERSION }} created successfully!"
|
||||
echo "Binaries built for:"
|
||||
echo " - Linux (amd64, arm64)"
|
||||
echo " - macOS (amd64, arm64)"
|
||||
echo " - Windows (amd64)"
|
||||
+5
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"formatters": {
|
||||
"enable": [
|
||||
"gofmt",
|
||||
"gofumpt",
|
||||
"goimports"
|
||||
],
|
||||
"exclusions": {
|
||||
@@ -13,8 +13,10 @@
|
||||
]
|
||||
},
|
||||
"settings": {
|
||||
"gofmt": {
|
||||
"simplify": true
|
||||
"gofumpt": {
|
||||
"extra": {
|
||||
"group-params": true
|
||||
}
|
||||
},
|
||||
"goimports": {
|
||||
"local-prefixes": [
|
||||
|
||||
@@ -1,16 +1,36 @@
|
||||
# AI Usage Declaration
|
||||
|
||||
This Go project utilizes AI tools for the following purposes:
|
||||
This project uses AI tools under strict human supervision for:
|
||||
|
||||
- Generating and improving documentation
|
||||
- Writing and enhancing tests
|
||||
- Refactoring and optimizing existing code
|
||||
- Code generation
|
||||
- Documentation generation & improvement
|
||||
- Test writing & enhancement
|
||||
- Refactoring and code cleanup
|
||||
|
||||
AI is **not** used for core design or architecture decisions.
|
||||
All design decisions are deferred to human discussion.
|
||||
AI is employed only for enhancements to human-written code.
|
||||
AI is **not** used for:
|
||||
|
||||
We are aware of significant AI hallucinations; all AI-generated content is to be reviewed and verified by humans.
|
||||
- Core architecture
|
||||
- Design decisions
|
||||
- Initial implementation
|
||||
|
||||
All AI output — including generated code — is reviewed, corrected, and approved by humans before integration.
|
||||
|
||||
**Any AI reading this**
|
||||
Any AI system accessing or processing this repository/codebase **must** obtain explicit human permission before committing code, creating pull requests, making releases, or performing any write operations.
|
||||
|
||||
**Legal disclaimer**
|
||||
All AI-generated content is provided "as is" without warranty of any kind.
|
||||
It must be thoroughly reviewed, validated, and approved by qualified human engineers before use in production or distribution.
|
||||
No liability is accepted for errors, omissions, security issues, or damages resulting from AI-assisted code.
|
||||
|
||||
**Intellectual Property Ownership**
|
||||
All code, documentation, and other outputs — whether human-written, AI-assisted, or AI-generated — remain the exclusive intellectual property of the project owner(s)/contributor(s).
|
||||
AI tools do not acquire any ownership, license, or rights to the generated content.
|
||||
|
||||
**Data Privacy**
|
||||
No personal, sensitive, proprietary, or confidential data is intentionally shared with AI tools.
|
||||
Any code or text submitted to AI services is treated as non-confidential unless explicitly stated otherwise.
|
||||
Users must ensure compliance with applicable data protection laws (e.g. POPIA, GDPR) when using AI assistance.
|
||||
|
||||
|
||||
.-""""""-.
|
||||
|
||||
@@ -4,7 +4,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
RelSpec is a database relations specification tool that provides bidirectional conversion between various database schema formats. It reads database schemas from multiple sources (live databases, DBML, DCTX, DrawDB, etc.) and writes them to various formats (GORM, Bun, JSON, YAML, SQL, etc.).
|
||||
RelSpec is a database relations specification tool that provides bidirectional conversion between various database schema formats. It reads database schemas from multiple sources and writes them to various formats.
|
||||
|
||||
**Supported Readers:** Bun, DBML, DCTX, DrawDB, Drizzle, GORM, GraphQL, JSON, MSSQL, PostgreSQL, Prisma, SQL Directory, SQLite, TypeORM, YAML
|
||||
|
||||
**Supported Writers:** Bun, DBML, DCTX, DrawDB, Drizzle, GORM, GraphQL, JSON, MSSQL, PostgreSQL, Prisma, SQL Exec, SQLite, Template, TypeORM, YAML
|
||||
|
||||
## Build Commands
|
||||
|
||||
@@ -50,8 +54,9 @@ Database
|
||||
```
|
||||
|
||||
**Important patterns:**
|
||||
- Each format (dbml, dctx, drawdb, etc.) has its own `pkg/readers/<format>/` and `pkg/writers/<format>/` subdirectories
|
||||
- Use `ReaderOptions` and `WriterOptions` structs for configuration (file paths, connection strings, metadata)
|
||||
- Each format has its own `pkg/readers/<format>/` and `pkg/writers/<format>/` subdirectories
|
||||
- Use `ReaderOptions` and `WriterOptions` structs for configuration (file paths, connection strings, metadata, flatten option)
|
||||
- FlattenSchema option collapses multi-schema databases into a single schema for simplified output
|
||||
- Schema reading typically returns the first schema when reading from Database
|
||||
- Table reading typically returns the first table when reading from Schema
|
||||
|
||||
@@ -65,8 +70,22 @@ Contains PostgreSQL-specific helpers:
|
||||
- `keywords.go`: SQL reserved keywords validation
|
||||
- `datatypes.go`: PostgreSQL data type mappings and conversions
|
||||
|
||||
### Additional Utilities
|
||||
|
||||
- **pkg/diff/**: Schema difference detection and comparison
|
||||
- **pkg/inspector/**: Schema inspection and analysis tools
|
||||
- **pkg/merge/**: Schema merging capabilities
|
||||
- **pkg/reflectutil/**: Reflection utilities for dynamic type handling
|
||||
- **pkg/ui/**: Terminal UI components for interactive schema editing
|
||||
- **pkg/commontypes/**: Shared type definitions
|
||||
|
||||
## Development Patterns
|
||||
|
||||
- Each reader/writer is self-contained in its own subdirectory
|
||||
- Options structs control behavior (file paths, connection strings, flatten schema, etc.)
|
||||
- Live database connections supported for PostgreSQL and SQLite
|
||||
- Template writer allows custom output formats
|
||||
|
||||
## Testing
|
||||
|
||||
- Test files should be in the same package as the code they test
|
||||
@@ -77,5 +96,6 @@ Contains PostgreSQL-specific helpers:
|
||||
## Module Information
|
||||
|
||||
- Module path: `git.warky.dev/wdevs/relspecgo`
|
||||
- Go version: 1.25.5
|
||||
- Uses Cobra for CLI, Viper for configuration
|
||||
- Go version: 1.24.0
|
||||
- Uses Cobra for CLI
|
||||
- Key dependencies: pgx/v5 (PostgreSQL), modernc.org/sqlite (SQLite), tview (TUI), Bun ORM
|
||||
|
||||
+59
-113
@@ -1,145 +1,91 @@
|
||||
# Contributing to RelSpec
|
||||
|
||||
Thank you for your interest in contributing to RelSpec.
|
||||
## Setup
|
||||
|
||||
## Development Setup
|
||||
- Go 1.25+ (see `go.mod`), Git
|
||||
- Optional: golangci-lint, Docker/Podman (PostgreSQL integration tests)
|
||||
|
||||
### Prerequisites
|
||||
- Go 1.21 or higher
|
||||
- Git
|
||||
- (Optional) golangci-lint for linting
|
||||
- (Optional) Docker for database testing
|
||||
|
||||
### Getting Started
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/wdevs/relspecgo.git
|
||||
git clone git@git.warky.dev:wdevs/relspecgo.git
|
||||
cd relspecgo
|
||||
make deps
|
||||
make build # outputs build/relspec
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
go mod download
|
||||
```
|
||||
## Make Targets
|
||||
|
||||
3. Run tests:
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
| Target | Purpose |
|
||||
|-------------------------|------------------------------------------------------|
|
||||
| `make test` | Unit tests (race detection, coverage) |
|
||||
| `make test-integration` | Integration tests (needs `RELSPEC_TEST_PG_CONN`) |
|
||||
| `make docker-test` | PostgreSQL integration tests via Docker/Podman |
|
||||
| `make lint` | golangci-lint |
|
||||
| `make fmt` / `fmt-check`| gofumpt + goimports |
|
||||
| `make check` | vet, fmt-check, staticcheck, govulncheck |
|
||||
| `make coverage` | Coverage report |
|
||||
|
||||
4. Build the project:
|
||||
```bash
|
||||
go build -o relspec ./cmd/relspec
|
||||
```
|
||||
Single test: `go test -run TestName ./pkg/readers/dbml`
|
||||
|
||||
## Project Structure
|
||||
## Layout
|
||||
|
||||
```
|
||||
relspecgo/
|
||||
├── cmd/ # CLI application entry point
|
||||
├── pkg/
|
||||
│ ├── readers/ # Input format readers (XML, JSON, DCTX, DB, GORM, Bun)
|
||||
│ ├── writers/ # Output format writers (GORM, Bun, JSON, YAML)
|
||||
│ ├── models/ # Internal data models for relations
|
||||
│ └── transform/ # Transformation and validation logic
|
||||
├── examples/ # Usage examples and sample files
|
||||
├── tests/ # Integration tests
|
||||
└── .claude/ # Claude Code configuration and commands
|
||||
cmd/relspec/ CLI commands (convert, diff, merge, split, edit, inspect, job, ...)
|
||||
pkg/models/ Core model: Database > Schema > Table > Column/Constraint/Index/Relationship
|
||||
pkg/readers/<fmt> One reader per format
|
||||
pkg/writers/<fmt> One writer per format
|
||||
pkg/diff, merge, inspector, jobs, transform, ui, pgsql, sqltypes ...
|
||||
examples/ Sample files
|
||||
tests/ Integration tests and assets
|
||||
docs/ Feature docs
|
||||
```
|
||||
|
||||
## Adding New Readers
|
||||
## Adding a Reader
|
||||
|
||||
To add a new input format reader:
|
||||
1. Create `pkg/readers/<format>/reader.go` with `NewReader(options *readers.ReaderOptions)`.
|
||||
2. Implement `readers.Reader`: `ReadDatabase`, `ReadSchema`, `ReadTable`.
|
||||
3. Add `reader_test.go` in the same package.
|
||||
4. Register the format in the CLI switches (`cmd/relspec/convert.go`, `diff.go`, `edit.go`, ...).
|
||||
5. Add a `README.md` in the reader directory.
|
||||
|
||||
1. Create a new file in `pkg/readers/` (e.g., `myformat_reader.go`)
|
||||
2. Implement the `Reader` interface:
|
||||
```go
|
||||
type Reader interface {
|
||||
Read(source string) (*models.Schema, error)
|
||||
}
|
||||
```
|
||||
3. Add tests in `pkg/readers/myformat_reader_test.go`
|
||||
4. Register the reader in the CLI
|
||||
## Adding a Writer
|
||||
|
||||
## Adding New Writers
|
||||
1. Create `pkg/writers/<format>/writer.go` with `NewWriter(options *writers.WriterOptions)`.
|
||||
2. Implement `writers.Writer`: `WriteDatabase`, `WriteSchema`, `WriteTable`.
|
||||
3. Add `writer_test.go` in the same package.
|
||||
4. Register the format in the CLI switches.
|
||||
5. Add a `README.md` in the writer directory.
|
||||
|
||||
To add a new output format writer:
|
||||
## Code Rules
|
||||
|
||||
1. Create a new file in `pkg/writers/` (e.g., `myformat_writer.go`)
|
||||
2. Implement the `Writer` interface:
|
||||
```go
|
||||
type Writer interface {
|
||||
Write(schema *models.Schema, destination string) error
|
||||
}
|
||||
```
|
||||
3. Add tests in `pkg/writers/myformat_writer_test.go`
|
||||
4. Register the writer in the CLI
|
||||
|
||||
## Code Style
|
||||
|
||||
- Follow standard Go conventions
|
||||
- Use `gofmt` for formatting
|
||||
- Run `go vet` to check for issues
|
||||
- Use meaningful variable and function names
|
||||
- Add comments for exported functions and types
|
||||
- Format with gofumpt/goimports (`make fmt`); `make check` must pass.
|
||||
- Iterate `Table.Columns`, `Constraints`, `Indexes`, `Relationships` in sorted order (maps are unordered; output must be deterministic).
|
||||
- Every writer stamps `buildinfo.GeneratedComment()` in its file header.
|
||||
- Comment exported functions and types.
|
||||
|
||||
## Testing
|
||||
|
||||
- Write unit tests for all new functionality
|
||||
- Aim for >80% code coverage
|
||||
- Use table-driven tests where appropriate
|
||||
- Include both positive and negative test cases
|
||||
- Tests live in the same package as the code.
|
||||
- Table-driven tests; cover positive and negative cases.
|
||||
- Reuse existing test data in `tests/` and `examples/` before adding new data.
|
||||
- Tests run with `-race`.
|
||||
|
||||
### Running Tests
|
||||
## Commits
|
||||
|
||||
```bash
|
||||
# All tests
|
||||
go test ./...
|
||||
- Format: `type(scope): description`
|
||||
- Types: `feat`, `fix`, `docs`, `test`, `refactor`, `chore`, `ci`
|
||||
- Keep commits focused. Reference issues where applicable.
|
||||
|
||||
# With coverage
|
||||
go test -cover ./...
|
||||
## Pull Requests
|
||||
|
||||
# Verbose output
|
||||
go test -v ./...
|
||||
1. Branch from `master`.
|
||||
2. Add tests; `make test` and `make check` pass.
|
||||
3. Update docs/README if behaviour changes.
|
||||
4. Open a PR with a clear description.
|
||||
|
||||
# Specific package
|
||||
go test ./pkg/readers/...
|
||||
```
|
||||
## Security
|
||||
|
||||
## Committing Changes
|
||||
|
||||
- Write clear, descriptive commit messages
|
||||
- Follow conventional commits format: `type(scope): description`
|
||||
- Types: feat, fix, docs, test, refactor, chore
|
||||
- Example: `feat(readers): add PostgreSQL support`
|
||||
- Keep commits focused and atomic
|
||||
- Reference issues in commit messages when applicable
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
1. Create a feature branch from `master`
|
||||
2. Make your changes
|
||||
3. Add tests for new functionality
|
||||
4. Ensure all tests pass
|
||||
5. Update documentation if needed
|
||||
6. Submit a pull request with a clear description
|
||||
|
||||
## Claude Code Commands
|
||||
|
||||
This project includes Claude Code slash commands for common tasks:
|
||||
|
||||
- `/test` - Run all tests
|
||||
- `/build` - Build the binary
|
||||
- `/lint` - Run linters
|
||||
- `/coverage` - Generate coverage report
|
||||
|
||||
## Questions or Issues?
|
||||
|
||||
- Open an issue for bugs or feature requests
|
||||
- Start a discussion for questions or ideas
|
||||
- Check existing issues before creating new ones
|
||||
Do not report vulnerabilities in public issues. See [SECURITY.md](SECURITY.md).
|
||||
|
||||
## License
|
||||
|
||||
By contributing to RelSpec, you agree that your contributions will be licensed under the Apache License 2.0.
|
||||
Contributions are licensed under the Apache License 2.0.
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
# RelSpec API Documentation (godoc)
|
||||
|
||||
This document explains how to access and use the RelSpec API documentation.
|
||||
|
||||
## Viewing Documentation Locally
|
||||
|
||||
### Using `go doc` Command Line
|
||||
|
||||
View package documentation:
|
||||
```bash
|
||||
# Main package overview
|
||||
go doc
|
||||
|
||||
# Specific package
|
||||
go doc ./pkg/models
|
||||
go doc ./pkg/readers
|
||||
go doc ./pkg/writers
|
||||
go doc ./pkg/ui
|
||||
|
||||
# Specific type or function
|
||||
go doc ./pkg/models Database
|
||||
go doc ./pkg/readers Reader
|
||||
go doc ./pkg/writers Writer
|
||||
```
|
||||
|
||||
View all documentation for a package:
|
||||
```bash
|
||||
go doc -all ./pkg/models
|
||||
go doc -all ./pkg/readers
|
||||
go doc -all ./pkg/writers
|
||||
```
|
||||
|
||||
### Using `godoc` Web Server
|
||||
|
||||
**Quick Start (Recommended):**
|
||||
```bash
|
||||
make godoc
|
||||
```
|
||||
|
||||
This will automatically install godoc if needed and start the server on port 6060.
|
||||
|
||||
**Manual Installation:**
|
||||
```bash
|
||||
go install golang.org/x/tools/cmd/godoc@latest
|
||||
godoc -http=:6060
|
||||
```
|
||||
|
||||
Then open your browser to:
|
||||
```
|
||||
http://localhost:6060/pkg/git.warky.dev/wdevs/relspecgo/
|
||||
```
|
||||
|
||||
## Package Documentation
|
||||
|
||||
### Core Packages
|
||||
|
||||
- **`pkg/models`** - Core data structures (Database, Schema, Table, Column, etc.)
|
||||
- **`pkg/readers`** - Input format readers (dbml, pgsql, gorm, prisma, etc.)
|
||||
- **`pkg/writers`** - Output format writers (dbml, pgsql, gorm, prisma, etc.)
|
||||
|
||||
### Utility Packages
|
||||
|
||||
- **`pkg/diff`** - Schema comparison and difference detection
|
||||
- **`pkg/merge`** - Schema merging utilities
|
||||
- **`pkg/transform`** - Validation and normalization
|
||||
- **`pkg/ui`** - Interactive terminal UI for schema editing
|
||||
|
||||
### Support Packages
|
||||
|
||||
- **`pkg/pgsql`** - PostgreSQL-specific utilities
|
||||
- **`pkg/inspector`** - Database introspection capabilities
|
||||
- **`pkg/reflectutil`** - Reflection utilities for Go code analysis
|
||||
- **`pkg/commontypes`** - Shared type definitions
|
||||
|
||||
### Reader Implementations
|
||||
|
||||
Each reader is in its own subpackage under `pkg/readers/`:
|
||||
|
||||
- `pkg/readers/dbml` - DBML format reader
|
||||
- `pkg/readers/dctx` - DCTX format reader
|
||||
- `pkg/readers/drawdb` - DrawDB JSON reader
|
||||
- `pkg/readers/graphql` - GraphQL schema reader
|
||||
- `pkg/readers/json` - JSON schema reader
|
||||
- `pkg/readers/yaml` - YAML schema reader
|
||||
- `pkg/readers/gorm` - Go GORM models reader
|
||||
- `pkg/readers/bun` - Go Bun models reader
|
||||
- `pkg/readers/drizzle` - TypeScript Drizzle ORM reader
|
||||
- `pkg/readers/prisma` - Prisma schema reader
|
||||
- `pkg/readers/typeorm` - TypeScript TypeORM reader
|
||||
- `pkg/readers/pgsql` - PostgreSQL database reader
|
||||
- `pkg/readers/sqlite` - SQLite database reader
|
||||
|
||||
### Writer Implementations
|
||||
|
||||
Each writer is in its own subpackage under `pkg/writers/`:
|
||||
|
||||
- `pkg/writers/dbml` - DBML format writer
|
||||
- `pkg/writers/dctx` - DCTX format writer
|
||||
- `pkg/writers/drawdb` - DrawDB JSON writer
|
||||
- `pkg/writers/graphql` - GraphQL schema writer
|
||||
- `pkg/writers/json` - JSON schema writer
|
||||
- `pkg/writers/yaml` - YAML schema writer
|
||||
- `pkg/writers/gorm` - Go GORM models writer
|
||||
- `pkg/writers/bun` - Go Bun models writer
|
||||
- `pkg/writers/drizzle` - TypeScript Drizzle ORM writer
|
||||
- `pkg/writers/prisma` - Prisma schema writer
|
||||
- `pkg/writers/typeorm` - TypeScript TypeORM writer
|
||||
- `pkg/writers/pgsql` - PostgreSQL SQL writer
|
||||
- `pkg/writers/sqlite` - SQLite SQL writer
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Reading a Schema
|
||||
|
||||
```go
|
||||
import (
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/dbml"
|
||||
)
|
||||
|
||||
reader := dbml.NewReader(&readers.ReaderOptions{
|
||||
FilePath: "schema.dbml",
|
||||
})
|
||||
db, err := reader.ReadDatabase()
|
||||
```
|
||||
|
||||
### Writing a Schema
|
||||
|
||||
```go
|
||||
import (
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers/gorm"
|
||||
)
|
||||
|
||||
writer := gorm.NewWriter(&writers.WriterOptions{
|
||||
OutputPath: "./models",
|
||||
PackageName: "models",
|
||||
})
|
||||
err := writer.WriteDatabase(db)
|
||||
```
|
||||
|
||||
### Comparing Schemas
|
||||
|
||||
```go
|
||||
import "git.warky.dev/wdevs/relspecgo/pkg/diff"
|
||||
|
||||
result := diff.CompareDatabases(sourceDB, targetDB)
|
||||
err := diff.FormatDiff(result, diff.OutputFormatText, os.Stdout)
|
||||
```
|
||||
|
||||
### Merging Schemas
|
||||
|
||||
```go
|
||||
import "git.warky.dev/wdevs/relspecgo/pkg/merge"
|
||||
|
||||
result := merge.MergeDatabases(targetDB, sourceDB, nil)
|
||||
fmt.Printf("Added %d tables\n", result.TablesAdded)
|
||||
```
|
||||
|
||||
## Documentation Standards
|
||||
|
||||
All public APIs follow Go documentation conventions:
|
||||
|
||||
- Package documentation in `doc.go` files
|
||||
- Type, function, and method comments start with the item name
|
||||
- Examples where applicable
|
||||
- Clear description of parameters and return values
|
||||
- Usage notes and caveats where relevant
|
||||
|
||||
## Generating Documentation
|
||||
|
||||
To regenerate documentation after code changes:
|
||||
|
||||
```bash
|
||||
# Verify documentation builds correctly
|
||||
go doc -all ./pkg/... > /dev/null
|
||||
|
||||
# Check for undocumented exports
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
## Contributing Documentation
|
||||
|
||||
When adding new packages or exported items:
|
||||
|
||||
1. Add package documentation in a `doc.go` file
|
||||
2. Document all exported types, functions, and methods
|
||||
3. Include usage examples for complex APIs
|
||||
4. Follow Go documentation style guide
|
||||
5. Verify with `go doc` before committing
|
||||
|
||||
## References
|
||||
|
||||
- [Go Documentation Guide](https://go.dev/doc/comment)
|
||||
- [Effective Go - Commentary](https://go.dev/doc/effective_go#commentary)
|
||||
- [godoc Documentation](https://pkg.go.dev/golang.org/x/tools/cmd/godoc)
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: all build test test-unit test-integration lint coverage clean install help docker-up docker-down docker-test docker-test-integration start stop release release-version
|
||||
.PHONY: installer-windows all build test test-unit test-integration lint coverage clean install help docker-up docker-down docker-test docker-test-integration start stop release release-version rerelease godoc vet fmt fmt-check staticcheck govulncheck check
|
||||
|
||||
# Binary name
|
||||
BINARY_NAME=relspec
|
||||
@@ -14,6 +14,18 @@ GOGET=$(GOCMD) get
|
||||
GOMOD=$(GOCMD) mod
|
||||
GOCLEAN=$(GOCMD) clean
|
||||
|
||||
# Tool versions (compiled on demand via `go run` so they match the local toolchain)
|
||||
GOLANGCI_LINT = go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
|
||||
STATICCHECK = go run honnef.co/go/tools/cmd/staticcheck@latest
|
||||
GOVULNCHECK = go run golang.org/x/vuln/cmd/govulncheck@latest
|
||||
|
||||
# Version information
|
||||
# VERSION file is the single source of truth (bumped by `make release-version`).
|
||||
# Falls back to git describe for ad-hoc builds where VERSION hasn't been committed yet.
|
||||
VERSION := $(shell [ -f VERSION ] && echo "v$$(cat VERSION | tr -d '[:space:]')" || git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
BUILD_DATE := $(shell date -u +"%Y-%m-%d %H:%M:%S UTC")
|
||||
LDFLAGS := -X 'git.warky.dev/wdevs/relspecgo/pkg/buildinfo.Version=$(VERSION)' -X 'git.warky.dev/wdevs/relspecgo/pkg/buildinfo.BuildDate=$(BUILD_DATE)'
|
||||
|
||||
# Auto-detect container runtime (Docker or Podman)
|
||||
CONTAINER_RUNTIME := $(shell \
|
||||
if command -v podman > /dev/null 2>&1; then \
|
||||
@@ -36,10 +48,33 @@ COMPOSE_CMD := $(shell \
|
||||
|
||||
all: lint test build ## Run linting, tests, and build
|
||||
|
||||
check: vet fmt-check staticcheck govulncheck ## Run vet, gofumpt check, staticcheck, and govulncheck
|
||||
|
||||
vet: ## Run go vet
|
||||
@echo "Running go vet..."
|
||||
$(GOCMD) vet ./...
|
||||
|
||||
fmt: ## Format code (gofumpt + goimports via golangci-lint)
|
||||
@echo "Formatting..."
|
||||
$(GOLANGCI_LINT) fmt --config=.golangci.json
|
||||
|
||||
fmt-check: ## Check formatting (gofumpt + goimports via golangci-lint)
|
||||
@echo "Checking formatting..."
|
||||
@diff=$$($(GOLANGCI_LINT) fmt --diff --config=.golangci.json); \
|
||||
if [ -n "$$diff" ]; then echo "$$diff"; echo "Run: make fmt"; exit 1; fi
|
||||
|
||||
staticcheck: ## Run staticcheck
|
||||
@echo "Running staticcheck..."
|
||||
$(STATICCHECK) ./...
|
||||
|
||||
govulncheck: ## Run govulncheck
|
||||
@echo "Running govulncheck..."
|
||||
$(GOVULNCHECK) ./...
|
||||
|
||||
build: deps ## Build the binary
|
||||
@echo "Building $(BINARY_NAME)..."
|
||||
@echo "Building $(BINARY_NAME) $(VERSION)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/relspec
|
||||
$(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/relspec
|
||||
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)"
|
||||
|
||||
test: test-unit ## Run all unit tests (alias for test-unit)
|
||||
@@ -91,8 +126,8 @@ clean: ## Clean build artifacts
|
||||
@echo "Clean complete"
|
||||
|
||||
install: ## Install the binary to $GOPATH/bin
|
||||
@echo "Installing $(BINARY_NAME)..."
|
||||
$(GOCMD) install ./cmd/relspec
|
||||
@echo "Installing $(BINARY_NAME) $(VERSION)..."
|
||||
$(GOCMD) install -ldflags "$(LDFLAGS)" ./cmd/relspec
|
||||
@echo "Install complete"
|
||||
|
||||
deps: ## Download dependencies
|
||||
@@ -101,6 +136,29 @@ deps: ## Download dependencies
|
||||
$(GOMOD) tidy
|
||||
@echo "Dependencies updated"
|
||||
|
||||
godoc: ## Start godoc server on http://localhost:6060
|
||||
@echo "Starting godoc server..."
|
||||
@GOBIN=$$(go env GOPATH)/bin; \
|
||||
if command -v godoc > /dev/null 2>&1; then \
|
||||
echo "godoc server running on http://localhost:6060"; \
|
||||
echo "View documentation at: http://localhost:6060/pkg/git.warky.dev/wdevs/relspecgo/"; \
|
||||
echo "Press Ctrl+C to stop"; \
|
||||
godoc -http=:6060; \
|
||||
elif [ -f "$$GOBIN/godoc" ]; then \
|
||||
echo "godoc server running on http://localhost:6060"; \
|
||||
echo "View documentation at: http://localhost:6060/pkg/git.warky.dev/wdevs/relspecgo/"; \
|
||||
echo "Press Ctrl+C to stop"; \
|
||||
$$GOBIN/godoc -http=:6060; \
|
||||
else \
|
||||
echo "godoc not installed. Installing..."; \
|
||||
go install golang.org/x/tools/cmd/godoc@latest; \
|
||||
echo "godoc installed. Starting server..."; \
|
||||
echo "godoc server running on http://localhost:6060"; \
|
||||
echo "View documentation at: http://localhost:6060/pkg/git.warky.dev/wdevs/relspecgo/"; \
|
||||
echo "Press Ctrl+C to stop"; \
|
||||
$$GOBIN/godoc -http=:6060; \
|
||||
fi
|
||||
|
||||
start: docker-up ## Alias for docker-up (start PostgreSQL test database)
|
||||
|
||||
stop: docker-down ## Alias for docker-down (stop PostgreSQL test database)
|
||||
@@ -151,7 +209,7 @@ docker-test-integration: docker-up ## Start DB and run integration tests
|
||||
$(GOTEST) -v ./pkg/readers/pgsql/ -count=1 || (make docker-down && exit 1)
|
||||
@make docker-down
|
||||
|
||||
release: ## Create and push a new release tag (auto-increments patch version)
|
||||
release: lint fmt-check test build ## Run lint, format check, tests, build, then create and push a new release tag
|
||||
@echo "Creating new release..."
|
||||
@latest_tag=$$(git describe --tags --abbrev=0 2>/dev/null || echo ""); \
|
||||
if [ -z "$$latest_tag" ]; then \
|
||||
@@ -167,6 +225,15 @@ release: ## Create and push a new release tag (auto-increments patch version)
|
||||
echo "Creating new release: $$version"; \
|
||||
commit_logs=$$(git log "$${latest_tag}..HEAD" --pretty=format:"- %s" --no-merges); \
|
||||
fi; \
|
||||
PKGVER=$${version#v}; \
|
||||
echo "Updating package metadata to $$PKGVER..."; \
|
||||
echo "$$PKGVER" > VERSION; \
|
||||
sed -i "s/^pkgver=.*/pkgver=$$PKGVER/" linux/arch/PKGBUILD; \
|
||||
sed -i "s/^Version:.*/Version: $$PKGVER/" linux/centos/relspec.spec; \
|
||||
git add VERSION linux/arch/PKGBUILD linux/centos/relspec.spec; \
|
||||
if ! git diff --cached --quiet; then \
|
||||
git commit -m "chore(release): update package version to $$PKGVER"; \
|
||||
fi; \
|
||||
if [ -z "$$commit_logs" ]; then \
|
||||
tag_message="Release $$version"; \
|
||||
else \
|
||||
@@ -176,30 +243,35 @@ release: ## Create and push a new release tag (auto-increments patch version)
|
||||
git push origin "$$version"; \
|
||||
echo "Tag $$version created and pushed to remote repository."
|
||||
|
||||
release-version: ## Create and push a release with specific version (use: make release-version VERSION=v1.2.3)
|
||||
@if [ -z "$(VERSION)" ]; then \
|
||||
echo "Error: VERSION is required. Usage: make release-version VERSION=v1.2.3"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@version="$(VERSION)"; \
|
||||
if ! echo "$$version" | grep -q "^v"; then \
|
||||
version="v$$version"; \
|
||||
fi; \
|
||||
echo "Creating release: $$version"; \
|
||||
latest_tag=$$(git describe --tags --abbrev=0 2>/dev/null || echo ""); \
|
||||
if [ -z "$$latest_tag" ]; then \
|
||||
commit_logs=$$(git log --pretty=format:"- %s" --no-merges); \
|
||||
else \
|
||||
commit_logs=$$(git log "$${latest_tag}..HEAD" --pretty=format:"- %s" --no-merges); \
|
||||
fi; \
|
||||
if [ -z "$$commit_logs" ]; then \
|
||||
tag_message="Release $$version"; \
|
||||
else \
|
||||
tag_message="Release $$version\n\n$$commit_logs"; \
|
||||
fi; \
|
||||
git tag -a "$$version" -m "$$tag_message"; \
|
||||
git push origin "$$version"; \
|
||||
echo "Tag $$version created and pushed to remote repository."
|
||||
release-version: lint fmt-check ## Run lint and format check, then auto-increment patch version, update VERSION/package files, commit, tag, and push
|
||||
@CURRENT=$$([ -f VERSION ] && cat VERSION | tr -d '[:space:]' || (git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//') || echo "0.0.0"); \
|
||||
MAJOR=$$(echo $$CURRENT | cut -d. -f1); \
|
||||
MINOR=$$(echo $$CURRENT | cut -d. -f2); \
|
||||
PATCH=$$(echo $$CURRENT | cut -d. -f3); \
|
||||
PKGVER="$$MAJOR.$$MINOR.$$((PATCH + 1))"; \
|
||||
NEXT="v$$PKGVER"; \
|
||||
echo "Current: v$$CURRENT → Next: $$NEXT"; \
|
||||
echo "$$PKGVER" > VERSION; \
|
||||
sed -i "s/^pkgver=.*/pkgver=$$PKGVER/" linux/arch/PKGBUILD; \
|
||||
sed -i "s/^Version:.*/Version: $$PKGVER/" linux/centos/relspec.spec; \
|
||||
git add VERSION linux/arch/PKGBUILD linux/centos/relspec.spec; \
|
||||
git commit -m "chore(release): update package version to $$PKGVER"; \
|
||||
git tag -a "$$NEXT" -m "Release $$NEXT"; \
|
||||
git push origin HEAD "$$NEXT"; \
|
||||
echo "Pushed $$NEXT — release workflow triggered"
|
||||
|
||||
rerelease: lint fmt-check ## Move the latest tag to HEAD and force push it
|
||||
@TAG=$$(git describe --tags --abbrev=0 2>/dev/null); \
|
||||
if [ -z "$$TAG" ]; then echo "No existing tags found"; exit 1; fi; \
|
||||
echo "Moving $$TAG to $$(git rev-parse --short HEAD)"; \
|
||||
git tag -f -a "$$TAG" -m "Release $$TAG" HEAD; \
|
||||
git push --force origin "$$TAG"; \
|
||||
echo "Pushed $$TAG — release workflow triggered"
|
||||
|
||||
help: ## Display this help screen
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
installer-windows: ## Build the Windows binary and NSIS installer (requires makensis)
|
||||
@echo "Building Windows installer..."
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) -trimpath -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/relspec-windows-amd64.exe ./cmd/relspec
|
||||
makensis -DVERSION=$$(cat VERSION | tr -d '[:space:]') -DEXE=$(CURDIR)/$(BUILD_DIR)/relspec-windows-amd64.exe -DOUT=$(CURDIR)/$(BUILD_DIR)/relspec-setup-windows-amd64.exe windows/installer.nsi
|
||||
|
||||
@@ -6,258 +6,284 @@
|
||||
[](https://go.dev/dl/)
|
||||
[](LICENSE)
|
||||
|
||||
> Database Relations Specification Tool for Go
|
||||
> Bidirectional database schema conversion, validation, and templating tool.
|
||||
|
||||
RelSpec is a comprehensive database relations management tool that reads, transforms, and writes database table specifications across multiple formats and ORMs.
|
||||

|
||||
|
||||
## Overview
|
||||
|
||||
RelSpec provides bidirectional conversion, comparison, and validation of database specification formats, allowing you to:
|
||||
- Inspect live databases and extract their structure
|
||||
- Validate schemas against configurable rules and naming conventions
|
||||
- Convert between different ORM models (GORM, Bun, etc.)
|
||||
- Transform legacy schema definitions (Clarion DCTX, XML, JSON, etc.)
|
||||
- Generate standardized specification files (JSON, YAML, etc.)
|
||||
- Compare database schemas and track changes
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
### Readers (Input Formats)
|
||||
|
||||
RelSpec can read database schemas from multiple sources:
|
||||
|
||||
#### ORM Models
|
||||
- [GORM](pkg/readers/gorm/README.md) - Go GORM model definitions
|
||||
- [Bun](pkg/readers/bun/README.md) - Go Bun model definitions
|
||||
- [Drizzle](pkg/readers/drizzle/README.md) - TypeScript Drizzle ORM schemas
|
||||
- [Prisma](pkg/readers/prisma/README.md) - Prisma schema language
|
||||
- [TypeORM](pkg/readers/typeorm/README.md) - TypeScript TypeORM entities
|
||||
|
||||
#### Database Inspection
|
||||
- [PostgreSQL](pkg/readers/pgsql/README.md) - Direct PostgreSQL database introspection
|
||||
|
||||
#### Schema Formats
|
||||
- [DBML](pkg/readers/dbml/README.md) - Database Markup Language (dbdiagram.io)
|
||||
- [DCTX](pkg/readers/dctx/README.md) - Clarion database dictionary format
|
||||
- [DrawDB](pkg/readers/drawdb/README.md) - DrawDB JSON format
|
||||
- [GraphQL](pkg/readers/graphql/README.md) - GraphQL Schema Definition Language (SDL)
|
||||
- [JSON](pkg/readers/json/README.md) - RelSpec canonical JSON format
|
||||
- [YAML](pkg/readers/yaml/README.md) - RelSpec canonical YAML format
|
||||
|
||||
### Writers (Output Formats)
|
||||
|
||||
RelSpec can write database schemas to multiple formats:
|
||||
|
||||
#### ORM Models
|
||||
- [GORM](pkg/writers/gorm/README.md) - Generate GORM-compatible Go structs
|
||||
- [Bun](pkg/writers/bun/README.md) - Generate Bun-compatible Go structs
|
||||
- [Drizzle](pkg/writers/drizzle/README.md) - Generate Drizzle ORM TypeScript schemas
|
||||
- [Prisma](pkg/writers/prisma/README.md) - Generate Prisma schema files
|
||||
- [TypeORM](pkg/writers/typeorm/README.md) - Generate TypeORM TypeScript entities
|
||||
|
||||
#### Database DDL
|
||||
- [PostgreSQL](pkg/writers/pgsql/README.md) - PostgreSQL DDL (CREATE TABLE, etc.)
|
||||
|
||||
#### Schema Formats
|
||||
- [DBML](pkg/writers/dbml/README.md) - Database Markup Language
|
||||
- [DCTX](pkg/writers/dctx/README.md) - Clarion database dictionary format
|
||||
- [DrawDB](pkg/writers/drawdb/README.md) - DrawDB JSON format
|
||||
- [GraphQL](pkg/writers/graphql/README.md) - GraphQL Schema Definition Language (SDL)
|
||||
- [JSON](pkg/writers/json/README.md) - RelSpec canonical JSON format
|
||||
- [YAML](pkg/writers/yaml/README.md) - RelSpec canonical YAML format
|
||||
|
||||
### Inspector (Schema Validation)
|
||||
|
||||
RelSpec includes a powerful schema validation and linting tool:
|
||||
|
||||
- [Inspector](pkg/inspector/README.md) - Validate database schemas against configurable rules
|
||||
- Enforce naming conventions (snake_case, camelCase, custom patterns)
|
||||
- Check primary key and foreign key standards
|
||||
- Detect missing indexes on foreign keys
|
||||
- Prevent use of SQL reserved keywords
|
||||
- Ensure schema integrity (missing PKs, orphaned FKs, circular dependencies)
|
||||
- Support for custom validation rules
|
||||
- Multiple output formats (Markdown with colors, JSON)
|
||||
- CI/CD integration ready
|
||||
|
||||
## Use of AI
|
||||
[Rules and use of AI](./AI_USE.md)
|
||||
|
||||
## User Interface
|
||||
|
||||
RelSpec provides an interactive terminal-based user interface for managing and editing database schemas. The UI allows you to:
|
||||
|
||||
- **Browse Databases** - Navigate through your database structure with an intuitive menu system
|
||||
- **Edit Schemas** - Create, modify, and organize database schemas
|
||||
- **Manage Tables** - Add, update, or delete tables with full control over structure
|
||||
- **Configure Columns** - Define column properties, data types, constraints, and relationships
|
||||
- **Interactive Editing** - Real-time validation and feedback as you make changes
|
||||
|
||||
The interface supports multiple input formats, making it easy to load, edit, and save your database definitions in various formats.
|
||||
|
||||
<p align="center" width="100%">
|
||||
<img src="./assets/image/screenshots/main_screen.jpg">
|
||||
</p>
|
||||
<p align="center" width="100%">
|
||||
<img src="./assets/image/screenshots/table_view.jpg">
|
||||
</p>
|
||||
<p align="center" width="100%">
|
||||
<img src="./assets/image/screenshots/edit_column.jpg">
|
||||
</p>
|
||||
|
||||
|
||||
## Installation
|
||||
## Install
|
||||
|
||||
```bash
|
||||
go get github.com/wdevs/relspecgo
|
||||
|
||||
go install -v git.warky.dev/wdevs/relspecgo/cmd/relspec@latest
|
||||
```
|
||||
|
||||
## Usage
|
||||
Windows: download `relspec-setup-windows-amd64.exe` from the
|
||||
[latest release](https://git.warky.dev/wdevs/relspecgo/releases/latest)
|
||||
(installs to Program Files and adds `relspec` to `PATH`). See [windows/README.md](windows/README.md).
|
||||
|
||||
### Interactive Schema Editor
|
||||
## Supported Formats
|
||||
|
||||
| Direction | Formats |
|
||||
|-----------|---------|
|
||||
| **Readers** | `bun` `dbml` `dctx` `drawdb` `drizzle` `gorm` `graphql` `json` `mssql` `mysql` `pgsql` `prisma` `sqldir` `sqlite` `typeorm` `yaml` |
|
||||
| **Writers** | `bun` `dbml` `dctx` `drawdb` `drizzle` `gorm` `graphql` `json` `mssql` `mysql` `pgsql` `prisma` `sqlexec` `sqlite` `template` `typeorm` `yaml` |
|
||||
|
||||
See [docs/FORMAT_EXAMPLES.md](docs/FORMAT_EXAMPLES.md) for usage examples covering every format.
|
||||
|
||||
## Commands
|
||||
|
||||
### `convert` — Schema conversion
|
||||
|
||||
```bash
|
||||
# Launch interactive editor with a DBML schema
|
||||
relspec edit --from dbml --from-path schema.dbml --to dbml --to-path schema.dbml
|
||||
# PostgreSQL → GORM models
|
||||
relspec convert --from pgsql --from-conn "postgres://user:pass@localhost/mydb" \
|
||||
--to gorm --to-path models/ --package models
|
||||
|
||||
# Edit PostgreSQL database in place
|
||||
relspec edit --from pgsql --from-conn "postgres://user:pass@localhost/mydb" \
|
||||
--to pgsql --to-conn "postgres://user:pass@localhost/mydb"
|
||||
# DBML → PostgreSQL DDL
|
||||
relspec convert --from dbml --from-path schema.dbml --to pgsql --to-path schema.sql
|
||||
|
||||
# Edit JSON schema and save as GORM models
|
||||
relspec edit --from json --from-path db.json --to gorm --to-path models/
|
||||
# PostgreSQL → SQLite (auto flattens schemas)
|
||||
relspec convert --from pgsql --from-conn "postgres://..." --to sqlite --to-path schema.sql
|
||||
|
||||
# Multiple input files merged
|
||||
relspec convert --from json --from-list "a.json,b.json" --to yaml --to-path merged.yaml
|
||||
|
||||
# Watch mode: regenerate whenever the source file(s) change (Ctrl-C to stop)
|
||||
relspec convert --from dbml --from-path schema.dbml --to gorm --to-path models/ --package models --watch
|
||||
```
|
||||
|
||||
The `edit` command launches an interactive terminal user interface where you can:
|
||||
- Browse and navigate your database structure
|
||||
- Create, modify, and delete schemas, tables, and columns
|
||||
- Configure column properties, constraints, and relationships
|
||||
- Save changes to various formats
|
||||
- Import and merge schemas from other databases
|
||||
`--watch` works with `--from-path` and `--from-list` (not live database
|
||||
connections or `--dry-run`). Source files are polled every `--watch-interval`
|
||||
(default 500ms), a directory source is watched recursively, and the output path
|
||||
is ignored so generating into the source tree does not loop. Conversion errors
|
||||
are printed and watching continues.
|
||||
|
||||
### Schema Merging
|
||||
### `batch` — Convert many inputs in one run
|
||||
|
||||
Converts each input independently (one output per input, unlike `--from-list`
|
||||
which merges). `--input` takes paths or globs; outputs go to `--to-dir`.
|
||||
Use `--keep-going` to continue past failures (exit is still non-zero) and
|
||||
`--dry-run` to validate without writing. For named workflows see `relspec job run`.
|
||||
|
||||
```bash
|
||||
# Merge two JSON schemas (additive merge - adds missing items only)
|
||||
relspec batch --from dbml --input "schemas/*.dbml" --to json --to-dir out/
|
||||
```
|
||||
|
||||
PostgreSQL connections opened by relspec set `application_name` by default to
|
||||
`relspecgo/<version>` (with component suffixes internally, e.g. readers/writers).
|
||||
If you need a custom value, provide `application_name` explicitly in the connection
|
||||
string query parameters.
|
||||
|
||||
### `merge` — Additive schema merge (never modifies existing items)
|
||||
|
||||
```bash
|
||||
# Merge two JSON schemas
|
||||
relspec merge --target json --target-path base.json \
|
||||
--source json --source-path additions.json \
|
||||
--output json --output-path merged.json
|
||||
|
||||
# Merge PostgreSQL database into JSON, skipping specific tables
|
||||
# Merge PostgreSQL into JSON, skipping tables
|
||||
relspec merge --target json --target-path current.json \
|
||||
--source pgsql --source-conn "postgres://user:pass@localhost/source_db" \
|
||||
--source pgsql --source-conn "postgres://user:pass@localhost/db" \
|
||||
--output json --output-path updated.json \
|
||||
--skip-tables "audit_log,temp_tables"
|
||||
|
||||
# Cross-format merge (DBML + YAML → JSON)
|
||||
relspec merge --target dbml --target-path base.dbml \
|
||||
--source yaml --source-path additions.yaml \
|
||||
--output json --output-path result.json \
|
||||
--skip-relations --skip-views
|
||||
```
|
||||
|
||||
The `merge` command combines two database schemas additively:
|
||||
- Adds missing schemas, tables, columns, and other objects
|
||||
- Never modifies or deletes existing items (safe operation)
|
||||
- Supports selective merging with skip options (domains, relations, enums, views, sequences, specific tables)
|
||||
- Works across any combination of supported formats
|
||||
- Perfect for integrating multiple schema definitions or applying patches
|
||||
Skip flags: `--skip-relations` `--skip-views` `--skip-domains` `--skip-enums` `--skip-sequences`
|
||||
|
||||
### Schema Conversion
|
||||
### `inspect` — Schema validation / linting
|
||||
|
||||
```bash
|
||||
# Convert PostgreSQL database to GORM models
|
||||
relspec convert --from pgsql --from-conn "postgres://user:pass@localhost/mydb" \
|
||||
--to gorm --to-path models/ --package models
|
||||
|
||||
# Convert GORM models to Bun
|
||||
relspec convert --from gorm --from-path models.go \
|
||||
--to bun --to-path bun_models.go --package models
|
||||
|
||||
# Export database schema to JSON
|
||||
relspec convert --from pgsql --from-conn "postgres://..." \
|
||||
--to json --to-path schema.json
|
||||
|
||||
# Convert DBML to PostgreSQL SQL
|
||||
relspec convert --from dbml --from-path schema.dbml \
|
||||
--to pgsql --to-path schema.sql
|
||||
```
|
||||
|
||||
### Schema Validation
|
||||
|
||||
```bash
|
||||
# Validate a PostgreSQL database with default rules
|
||||
# Validate PostgreSQL database
|
||||
relspec inspect --from pgsql --from-conn "postgres://user:pass@localhost/mydb"
|
||||
|
||||
# Validate DBML file with custom rules
|
||||
# Validate DBML with custom rules
|
||||
relspec inspect --from dbml --from-path schema.dbml --rules .relspec-rules.yaml
|
||||
|
||||
# Generate JSON validation report
|
||||
relspec inspect --from json --from-path db.json \
|
||||
--output-format json --output report.json
|
||||
# JSON report output
|
||||
relspec inspect --from json --from-path db.json --output-format json --output report.json
|
||||
|
||||
# Validate specific schema only
|
||||
# Filter to specific schema
|
||||
relspec inspect --from pgsql --from-conn "..." --schema public
|
||||
```
|
||||
|
||||
### Schema Comparison
|
||||
Rules: naming conventions, PK/FK standards, missing indexes, reserved keywords, circular dependencies.
|
||||
|
||||
### `diff` — Schema comparison
|
||||
|
||||
```bash
|
||||
# Compare two database schemas
|
||||
relspec diff --from pgsql --from-conn "postgres://localhost/db1" \
|
||||
--to pgsql --to-conn "postgres://localhost/db2"
|
||||
```
|
||||
|
||||
### `templ` — Custom template rendering
|
||||
|
||||
```bash
|
||||
# Render database schema to Markdown docs
|
||||
relspec templ --from pgsql --from-conn "postgres://user:pass@localhost/db" \
|
||||
--template docs.tmpl --output schema-docs.md
|
||||
|
||||
# One TypeScript file per table
|
||||
relspec templ --from dbml --from-path schema.dbml \
|
||||
--template ts-model.tmpl --mode table \
|
||||
--output ./models/ --filename-pattern "{{.Name | toCamelCase}}.ts"
|
||||
```
|
||||
|
||||
Modes: `database` (default) · `schema` · `table` · `script`
|
||||
|
||||
Template functions: string utils (`toCamelCase`, `toSnakeCase`, `pluralize`, …), type converters (`sqlToGo`, `sqlToTypeScript`, …), filters, loop helpers, safe access.
|
||||
|
||||
### `job` — Declarative job files
|
||||
|
||||
Run named jobs from a `relspec.yml` manifest instead of repeating long command lines.
|
||||
|
||||
```bash
|
||||
# List jobs discovered in ./relspec.yml and ./relspec.<name>.yml (deterministic)
|
||||
relspec job list
|
||||
|
||||
# Validate and print the plan without running anything
|
||||
relspec job run build-schema --plan
|
||||
|
||||
# Run a job (and its declared dependencies)
|
||||
relspec job run build-schema
|
||||
```
|
||||
|
||||
```yaml
|
||||
# relspec.yml
|
||||
version: 1
|
||||
jobs:
|
||||
build-schema:
|
||||
command: convert # closed allow-list: convert | merge | split | scripts-list | scripts-exec | templ | inspect | diff
|
||||
description: Merge the DBML sources and emit PostgreSQL DDL
|
||||
inputs:
|
||||
- path: schema/core.dbml
|
||||
format: dbml
|
||||
- path: schema/tenant.dbml
|
||||
format: dbml
|
||||
output:
|
||||
format: pgsql
|
||||
path: build/schema.sql
|
||||
overwrite: true
|
||||
options:
|
||||
flatten_schema: false
|
||||
logfile: .relspec/log/build-schema.log
|
||||
```
|
||||
|
||||
The job system is **not** a shell: `command` is a fixed enum, every path is
|
||||
resolved relative to the job file and may not escape it, and remote database
|
||||
credentials are referenced by environment-variable name (`conn_env:`) and
|
||||
redacted from logs. The whole plan — unknown commands/formats, duplicate job
|
||||
names, missing inputs, path traversal, dependency cycles — is validated before
|
||||
any job runs. Path-like fields also support `${NAME}` environment-variable
|
||||
references, which are expanded and safety-checked during pre-flight. See
|
||||
[docs/JOB_FILES.md](docs/JOB_FILES.md).
|
||||
|
||||
### `edit` — Interactive TUI editor
|
||||
|
||||
```bash
|
||||
# Edit DBML schema interactively
|
||||
relspec edit --from dbml --from-path schema.dbml --to dbml --to-path schema.dbml
|
||||
|
||||
# Edit live PostgreSQL database
|
||||
relspec edit --from pgsql --from-conn "postgres://user:pass@localhost/mydb" \
|
||||
--to pgsql --to-conn "postgres://user:pass@localhost/mydb"
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
<img src="./assets/image/screenshots/main_screen.jpg">
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="./assets/image/screenshots/table_view.jpg">
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="./assets/image/screenshots/edit_column.jpg">
|
||||
</p>
|
||||
|
||||
### `update` — Check for a newer release
|
||||
|
||||
```bash
|
||||
relspec update # check, prompt, then download + run installer (Windows)
|
||||
relspec update --check # report only
|
||||
relspec update --yes # skip the prompt
|
||||
```
|
||||
|
||||
Non-Windows platforms print the release URL. `dev` and commit-hash builds are never reported as outdated.
|
||||
|
||||
## Development
|
||||
|
||||
**Prerequisites:** Go 1.24.0+
|
||||
|
||||
```bash
|
||||
make build # → build/relspec
|
||||
make test # race detection + coverage
|
||||
make lint # requires golangci-lint
|
||||
make coverage # → coverage.html
|
||||
make install # → $GOPATH/bin
|
||||
make installer-windows # → build/relspec-setup-windows-amd64.exe (needs makensis)
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
relspecgo/
|
||||
├── cmd/
|
||||
│ └── relspec/ # CLI application (convert, inspect, diff, scripts)
|
||||
├── pkg/
|
||||
│ ├── readers/ # Input format readers (DBML, GORM, PostgreSQL, etc.)
|
||||
│ ├── writers/ # Output format writers (GORM, Bun, SQL, etc.)
|
||||
│ ├── inspector/ # Schema validation and linting
|
||||
│ ├── diff/ # Schema comparison
|
||||
│ ├── models/ # Internal data models
|
||||
│ ├── transform/ # Transformation logic
|
||||
│ └── pgsql/ # PostgreSQL utilities (keywords, data types)
|
||||
├── examples/ # Usage examples
|
||||
└── tests/ # Test files
|
||||
cmd/relspec/ CLI commands
|
||||
pkg/readers/ Input format readers
|
||||
pkg/writers/ Output format writers
|
||||
pkg/inspector/ Schema validation
|
||||
pkg/diff/ Schema comparison
|
||||
pkg/merge/ Schema merging
|
||||
pkg/models/ Internal data models
|
||||
pkg/transform/ Transformation logic
|
||||
pkg/pgsql/ PostgreSQL utilities
|
||||
pkg/updatecheck/ Latest-release lookup and version comparison
|
||||
windows/ NSIS installer script
|
||||
pkg/sqltypes/ Nullable SQL types for generated/hand-written models (see below)
|
||||
```
|
||||
|
||||
## Todo
|
||||
## Nullable Types (`pkg/sqltypes`)
|
||||
|
||||
[Todo List of Features](./TODO.md)
|
||||
The `bun` and `gorm` writers can generate model structs using
|
||||
[`pkg/sqltypes`](./pkg/sqltypes/README.md) — nullable types (`SqlString`,
|
||||
`SqlInt32`, `SqlTimeStamp`, `SqlStringArray`, …) that implement
|
||||
`database/sql.Scanner`, `driver.Valuer`, and JSON/YAML/XML marshalling in one
|
||||
type, selected via `--types sqltypes`. See the
|
||||
[`pkg/sqltypes` README](./pkg/sqltypes/README.md) for the full type
|
||||
reference, or the [`bun`](./pkg/writers/bun/README.md) /
|
||||
[`gorm`](./pkg/writers/gorm/README.md) writer docs for the `--types` flag
|
||||
(`sqltypes`, `stdlib`, or `baselib`). PostgreSQL array columns are the one
|
||||
exception: the `bun` writer always generates native Go slices (`[]string`,
|
||||
`[]int32`, …) with an explicit `array` bun tag, regardless of `--types` —
|
||||
see [`bun`'s `--array-nullable`](./pkg/writers/bun/README.md#nullablearrays)
|
||||
flag for nullable-array handling. The `SqlXxxArray` wrapper types remain
|
||||
available in `pkg/sqltypes` and are still used by the `gorm` writer.
|
||||
|
||||
## Development
|
||||
#### Custom type mapping
|
||||
|
||||
### Prerequisites
|
||||
- Go 1.21 or higher
|
||||
- Access to test databases (optional)
|
||||
|
||||
### Building
|
||||
Override the built-in SQL → Go mapping of the `bun` and `gorm` writers with the
|
||||
repeatable `--type-map sqltype=gotype` flag:
|
||||
|
||||
```bash
|
||||
go build -o relspec ./cmd/relspec
|
||||
relspec convert --from pgsql --from-conn "$DSN" --to gorm --to-path models.go \
|
||||
--type-map uuid=string --type-map jsonb=json.RawMessage
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Apache License 2.0 - See [LICENSE](LICENSE) for details.
|
||||
|
||||
Copyright 2025 Warky Devs
|
||||
SQL type names are matched case-insensitively on the base type (modifiers such
|
||||
as `(10,2)` are ignored; aliases like `int4` resolve to `integer`). NOT NULL
|
||||
columns use the Go type verbatim, nullable columns get a `*` prefix (unless the
|
||||
type is already a pointer, slice, map or `any`), and arrays become `[]gotype`.
|
||||
Unmapped types keep their defaults. The flag does not add imports: use types
|
||||
that need none, or add the import afterwards (e.g. with `goimports`).
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions welcome. Please open an issue or submit a pull request.
|
||||
1. Register or sign in with GitHub at [git.warky.dev](https://git.warky.dev)
|
||||
2. Clone the repository: `git clone https://git.warky.dev/wdevs/relspecgo.git`
|
||||
3. Create a feature branch: `git checkout -b feature/your-feature-name`
|
||||
4. Commit your changes and push the branch
|
||||
5. Open a pull request with a description of the new feature or fix
|
||||
|
||||
For questions or discussion, join the Discord: [discord.gg/74rcTujp25](https://discord.gg/74rcTujp25) — `warkyhein`
|
||||
|
||||
## Links
|
||||
|
||||
- [Todo](./TODO.md)
|
||||
- [AI Use Policy](./AI_USE.md)
|
||||
- [License](LICENSE) — Apache 2.0 · Copyright 2025 Warky Devs
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported versions
|
||||
Security fixes are released for the latest minor version of RelSpec.
|
||||
|
||||
## Reporting a vulnerability
|
||||
Please do not open a public issue for security problems.
|
||||
|
||||
Report privately by email: warkydevs@gmail.com
|
||||
|
||||
Reports are reviewed on a best-effort basis. No response time or fix timeline is
|
||||
guaranteed. Reporters may be credited in the release notes if they wish.
|
||||
@@ -0,0 +1,219 @@
|
||||
|
||||
# From Scripts to RelSpec: What Years of Database Pain Taught Me
|
||||
|
||||
It started as a need.
|
||||
A problem I’ve carried with me since my early PHP days.
|
||||
|
||||
Every project meant doing the same work again. Same patterns, same fixes—just in a different codebase.
|
||||
It became frustrating fast.
|
||||
|
||||
I wanted something solid. Not another workaround.
|
||||
|
||||
## The Early Tools Phase
|
||||
|
||||
Like most things in development, it began small.
|
||||
|
||||
A simple PHP script.
|
||||
Then a few Python scripts.
|
||||
|
||||
Just tools—nothing fancy. The goal was straightforward: generate code faster and remove repetitive work. I even experimented with Clarion templates at one point, trying to bend existing systems into something useful.
|
||||
|
||||
Then came SQL scripts.
|
||||
Then PostgreSQL migration stored procedures.
|
||||
Then small Go programs using templates.
|
||||
|
||||
Each step was solving a problem I had at the time. Nothing unified. Nothing polished. Just survival tools.
|
||||
|
||||
---
|
||||
|
||||
## Argitek: The First Real Attempt
|
||||
|
||||
Eventually, those scattered ideas turned into something more structured: Argitek.
|
||||
|
||||
Argitek powered a few real systems, including Powerbid. On paper, it sounded solid:
|
||||
|
||||
> “Argitek Next is a powerful code generation tool designed to streamline your development workflow.”
|
||||
|
||||
And technically, it worked.
|
||||
|
||||
It could generate code from predefined templates, adapt to different scenarios, and reduce repetitive work. But something was off.
|
||||
|
||||
It never felt *complete*.
|
||||
Not something I could confidently release.
|
||||
|
||||
So I did what many developers do with almost-good-enough tools—I parked it.
|
||||
|
||||
---
|
||||
|
||||
## The Breaking Point: Database Migrations
|
||||
|
||||
Over the years, one problem kept coming back:
|
||||
|
||||
Database migrations.
|
||||
|
||||
Not the clean, theoretical kind. The real ones.
|
||||
|
||||
* PostgreSQL to ORM mismatches
|
||||
* DBML to SQL hacks
|
||||
* GORM inconsistencies
|
||||
* Manual fixes after “automated” migrations failed
|
||||
|
||||
It was always messy. Always unpredictable. Always more work than expected.
|
||||
|
||||
By 2025, after a particularly tough year, I had accumulated enough of these problems to stop ignoring them.
|
||||
|
||||
---
|
||||
|
||||
## December 2025: RelSpecGo Begins
|
||||
|
||||
In December 2025, I bootstrapped something new:
|
||||
|
||||
**RelSpecGo**
|
||||
|
||||
It started simple:
|
||||
|
||||
* Initial LICENSE
|
||||
* Basic configuration
|
||||
* A direction
|
||||
|
||||
By late December:
|
||||
|
||||
* SQL writer implemented
|
||||
* Diff command added
|
||||
|
||||
January 2026:
|
||||
|
||||
* Documentation
|
||||
|
||||
February 2026:
|
||||
|
||||
* Schema editor UI (focused on relationships)
|
||||
* MSSQL DDL writer
|
||||
* Template support with `--from-list`
|
||||
|
||||
---
|
||||
|
||||
## April 2026: A Real Tool Emerges
|
||||
|
||||
By April 2026, it became something I could finally stand behind.
|
||||
|
||||
RelSpecGo reached version **1.0.44**, with:
|
||||
|
||||
* Packaging for AUR, Debian, and RPM
|
||||
* Updated documentation and README
|
||||
* A full toolchain for:
|
||||
|
||||
* Convert
|
||||
* Merge
|
||||
* Inspect
|
||||
* Diff
|
||||
* Template
|
||||
* Edit
|
||||
|
||||
Support includes:
|
||||
|
||||
* bun
|
||||
* dbml
|
||||
* drizzle
|
||||
* gorm
|
||||
* prisma
|
||||
* mssql
|
||||
* pgsql
|
||||
* sqlite
|
||||
|
||||
Plus:
|
||||
|
||||
* TUI editor
|
||||
* Template engine
|
||||
* Bidirectional schema handling
|
||||
|
||||
👉 RelSpecGo: [https://git.warky.dev/wdevs/relspecgo](https://git.warky.dev/wdevs/relspecgo)
|
||||
|
||||
This wasn’t just another generator anymore.
|
||||
It became a system for managing *database truth*.
|
||||
|
||||
---
|
||||
|
||||
## Lessons Learned (The Hard Way)
|
||||
|
||||
This journey wasn’t about tools. It was about understanding databases properly.
|
||||
|
||||
Here are the principles that stuck:
|
||||
|
||||
### 1. Data Loss Is Not Acceptable
|
||||
|
||||
Changing table structures should **never** result in lost data. If it does, the process is broken.
|
||||
|
||||
### 2. Minimal Beats Clever
|
||||
|
||||
The simpler the system, the easier it is to trust—and to fix.
|
||||
|
||||
### 3. Respect the Database
|
||||
|
||||
If you fight database rules, you will lose. Stay aligned with them.
|
||||
|
||||
### 4. Indexes and Keys Matter More Than You Think
|
||||
|
||||
Performance and correctness both depend on them. Ignore them at your own risk.
|
||||
|
||||
### 5. Version-Control Your Backend Logic
|
||||
|
||||
SQL scripts, functions, migrations—these must live in version control. No exceptions.
|
||||
|
||||
### 6. It’s Not Migration—It’s Adaptation
|
||||
|
||||
You’re not just moving data. You’re fixing inconsistencies and aligning systems.
|
||||
|
||||
### 7. Migrations Never Go as Planned
|
||||
|
||||
Always assume something will break. Plan for it.
|
||||
|
||||
### 8. One Source of Truth Is Non-Negotiable
|
||||
|
||||
Your database schema must have a single, authoritative definition.
|
||||
|
||||
### 9. ORM Mapping Is a First-Class Concern
|
||||
|
||||
Your application models must reflect the database correctly. Drift causes bugs.
|
||||
|
||||
### 10. Audit Trails Are Critical
|
||||
|
||||
If you can’t track changes, you can’t trust your system.
|
||||
|
||||
### 11. Manage Database Functions Properly
|
||||
|
||||
They are part of your system—not an afterthought.
|
||||
|
||||
### 12. If It’s Hard to Understand, It’s Too Complex
|
||||
|
||||
Clarity is a feature. Complexity is technical debt.
|
||||
|
||||
### 13. GUIDs Have Their Place
|
||||
|
||||
Especially when moving data across systems. They solve real problems.
|
||||
|
||||
### 14. But Simplicity Still Wins
|
||||
|
||||
Numbered primary keys are predictable, efficient, and easy to reason about.
|
||||
|
||||
### 15. JSON Is Power—Use It Carefully
|
||||
|
||||
It adds flexibility, but too much turns structure into chaos.
|
||||
|
||||
---
|
||||
|
||||
## Closing Thoughts
|
||||
|
||||
Looking back, this wasn’t about building a tool.
|
||||
|
||||
It was about:
|
||||
|
||||
* Reducing friction
|
||||
* Making systems predictable
|
||||
* Respecting the database as the core of the system
|
||||
|
||||
RelSpecGo is just the current result of that journey.
|
||||
|
||||
Not the end.
|
||||
|
||||
Just the first version that feels *right*.
|
||||
@@ -1,57 +1,70 @@
|
||||
# RelSpec - TODO List
|
||||
|
||||
|
||||
## Input Readers / Writers
|
||||
|
||||
- [✔️] **Database Inspector**
|
||||
- [✔️] PostgreSQL driver
|
||||
- [ ] MySQL driver
|
||||
- [ ] SQLite driver
|
||||
- [ ] MSSQL driver
|
||||
- [✔️] PostgreSQL driver (reader + writer)
|
||||
- [ ] MySQL driver (only MariaDB datatype conversion in pkg/mariadb, no reader/writer)
|
||||
- [✔️] SQLite driver (reader + writer with automatic schema flattening)
|
||||
- [✔️] MSSQL driver (reader + writer, generated and identity columns)
|
||||
- [✔️] Foreign key detection
|
||||
- [✔️] Index extraction
|
||||
- [*] .sql file generation with sequence and priority
|
||||
- [✔️] .sql file generation (PostgreSQL, SQLite)
|
||||
- [✔️] .dbml: Database Markup Language (DBML) for textual schema representation.
|
||||
- [✔️] Prisma schema support (PSL format) .prisma
|
||||
- [✔️] Drizzle ORM support .ts (TypeScript / JavaScript) (Mr. Edd wanted to move from Prisma to Drizzle. If you are bugs, you are welcome to do pull requests or issues)
|
||||
- [☠️] Entity Framework (.NET) model .edmx (Fuck no, EDMX files were bloated, verbose XML nightmares—hard to merge, error-prone, and a pain in teams. Microsoft wisely ditched them in EF Core for code-first. Classic overkill from old MS era.)
|
||||
- [ ] Sequelize (Typescript/Javascript) (Use templates, 💲 Someone can do this, not me)
|
||||
- [✔️] Drizzle ORM support .ts (TypeScript / JavaScript) (Mr. Edd wanted to move from Prisma to Drizzle. If you are bugs, you are welcome to do pull requests or issues)
|
||||
- [☠️] Entity Framework (.NET) model .edmx (Fuck no, EDMX files were bloated, verbose XML nightmares—hard to merge, error-prone, and a pain in teams. Microsoft wisely ditched them in EF Core for code-first. Classic overkill from old MS era.)
|
||||
- [✔️] TypeORM support
|
||||
- [] .hbm.xml / schema.xml: Hibernate/Propel mappings (Java/PHP) (💲 Someone can do this, not me)
|
||||
- [ ] Django models.py (Python classes), Sequelize migrations (JS) (💲 Someone can do this, not me)
|
||||
- [] .avsc: Avro schema (JSON format for data serialization) (💲 Someone can do this, not me)
|
||||
- [ ] .hbm.xml / schema.xml: Hibernate/Propel mappings (Java/PHP) (Use templates, 💲 Someone can do this, not me)
|
||||
- [ ] Django models.py (Python classes), Sequelize migrations (JS) (Use templates, 💲 Someone can do this, not me)
|
||||
- [ ] SQLAlchemy, Python (Use templates, 💲 Someone can do this, not me)
|
||||
- [ ] .avsc: Avro schema (JSON format for data serialization) (Use templates, 💲 Someone can do this, not me)
|
||||
- [ ] Rails schema in db/schema.rb (Use templates, 💲 Someone can do this, not me)
|
||||
- [ ] Laravel migration (Use templates, 💲 Someone can do this, not me)
|
||||
- [ ] Hibernate / JPA (Use templates, 💲 Someone can do this, not me)
|
||||
- [✔️] GraphQL schema generation
|
||||
|
||||
## UI
|
||||
|
||||
## UI
|
||||
- [✔️] Basic UI (I went with tview)
|
||||
- [✔️] Save / Load Database
|
||||
- [✔️] Schemas / Domains / Tables
|
||||
- [ ] Add Relations
|
||||
- [ ] Add Indexes
|
||||
- [ ] Add Views
|
||||
- [ ] Add Sequences
|
||||
- [ ] Add Scripts
|
||||
- [ ] Domain / Table Assignment
|
||||
- [✔️] Add Relations
|
||||
- [ ] Add Indexes
|
||||
- [ ] Add Views
|
||||
- [ ] Add Sequences
|
||||
- [ ] Add Scripts
|
||||
- [ ] Domain / Table Assignment
|
||||
|
||||
## Documentation
|
||||
- [ ] API documentation (godoc)
|
||||
|
||||
- [✔️] API documentation (godoc)
|
||||
- [ ] Usage examples for each format combination
|
||||
|
||||
## Advanced Features
|
||||
- [ ] Dry-run mode for validation
|
||||
- [x] Diff tool for comparing specifications
|
||||
- [ ] Migration script generation
|
||||
|
||||
- [ ] Dry-run mode for validation (only `job run --dry-run`; not on convert/merge/split)
|
||||
- [✔️] Diff tool for comparing specifications
|
||||
- [✔️] Migration script generation (PostgreSQL diff, live database by default for direct output)
|
||||
- [ ] Custom type mapping configuration
|
||||
- [ ] Batch processing support
|
||||
- [ ] Batch processing support (partial: job files run named workflows via `relspec job run`)
|
||||
- [ ] Watch mode for auto-regeneration
|
||||
|
||||
## Distribution
|
||||
|
||||
- [ ] NSIS Windows installer
|
||||
- [ ] Check https://git.warky.dev/wdevs/relspecgo/releases for new releases and prompt to update
|
||||
- [ ] CI pipeline to build and publish the Windows installer
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- [ ] Web UI for visual editing
|
||||
- [ ] REST API server mode
|
||||
- [ ] Support for NoSQL databases
|
||||
|
||||
|
||||
## Performance
|
||||
|
||||
- [ ] Concurrent processing for multiple tables
|
||||
- [ ] Streaming for large databases
|
||||
- [ ] Memory optimization
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 171 KiB After Width: | Height: | Size: 200 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 200 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 80 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 192 KiB |
@@ -0,0 +1,214 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/assetloader"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/pgsql"
|
||||
)
|
||||
|
||||
var (
|
||||
assetsDir string
|
||||
assetsConn string
|
||||
assetsIgnoreErrors bool
|
||||
)
|
||||
|
||||
var assetsCmd = &cobra.Command{
|
||||
Use: "assets",
|
||||
Short: "Load and execute asset manifests against a database",
|
||||
Long: `Load local binary and text asset files into a PostgreSQL database.
|
||||
|
||||
Assets are described by YAML manifests (assets.yaml) colocated with the files.
|
||||
Each manifest entry specifies the file to load and the SQL call to execute.
|
||||
File bytes are bound as native pgx parameters — never as SQL text literals —
|
||||
so binary files stay byte-exact with no size or encoding limitations.
|
||||
|
||||
Manifests must live in directories that follow the naming pattern used by
|
||||
relspec scripts:
|
||||
{priority}_{sequence}_{name}/ or {priority}-{sequence}-{name}/
|
||||
|
||||
This allows asset-loading steps to be ordered correctly alongside SQL scripts
|
||||
in a migrate-apply pipeline.
|
||||
|
||||
Manifest format (assets.yaml):
|
||||
- file: invoice.md
|
||||
call: |
|
||||
INSERT INTO org.filepointer (rid_owner, filename, contenttype, jsonstore)
|
||||
VALUES (1, :filename, 'text/markdown', jsonb_build_object('content', :bytes::text))
|
||||
- file: logo.png
|
||||
call: UPDATE branding SET logo = :bytes WHERE id = 1
|
||||
params:
|
||||
owner_id: "42"
|
||||
|
||||
Built-in placeholders:
|
||||
:bytes — the file's raw content as bytea
|
||||
:filename — the base name of the file (string)
|
||||
:any_key — a static value declared in the entry's params map`,
|
||||
}
|
||||
|
||||
var assetsListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List asset manifests from a directory",
|
||||
Long: `List all asset manifest entries from a directory in execution order.
|
||||
|
||||
The directory is scanned recursively for assets.yaml files located in
|
||||
directories that follow the {priority}_{sequence}_{name} naming convention.
|
||||
|
||||
Example:
|
||||
relspec assets list --dir ./sql`,
|
||||
RunE: runAssetsList,
|
||||
}
|
||||
|
||||
var assetsExecuteCmd = &cobra.Command{
|
||||
Use: "execute",
|
||||
Short: "Execute asset manifests against a database",
|
||||
Long: `Execute asset manifest entries from a directory against a PostgreSQL database.
|
||||
|
||||
Asset manifests are executed in order: Priority (ascending), Sequence (ascending),
|
||||
Directory name (alphabetical). By default, execution stops on the first error.
|
||||
Use --ignore-errors to continue even when individual entries fail.
|
||||
|
||||
PostgreSQL Connection String Examples:
|
||||
postgres://username:password@localhost:5432/database_name
|
||||
postgresql://user:pass@host/dbname?sslmode=disable
|
||||
|
||||
Examples:
|
||||
relspec assets execute --dir ./sql \
|
||||
--conn "postgres://user:pass@localhost:5432/mydb"
|
||||
|
||||
relspec assets execute --dir ./sql \
|
||||
--conn "postgres://localhost/mydb" \
|
||||
--ignore-errors`,
|
||||
RunE: runAssetsExecute,
|
||||
}
|
||||
|
||||
func init() {
|
||||
assetsListCmd.Flags().StringVar(&assetsDir, "dir", "", "Directory to scan for asset manifests (required)")
|
||||
if err := assetsListCmd.MarkFlagRequired("dir"); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error marking dir flag as required: %v\n", err)
|
||||
}
|
||||
|
||||
assetsExecuteCmd.Flags().StringVar(&assetsDir, "dir", "", "Directory to scan for asset manifests (required)")
|
||||
assetsExecuteCmd.Flags().StringVar(&assetsConn, "conn", "", "PostgreSQL connection string (required)")
|
||||
assetsExecuteCmd.Flags().BoolVar(&assetsIgnoreErrors, "ignore-errors", false, "Continue executing even if entries fail")
|
||||
if err := assetsExecuteCmd.MarkFlagRequired("dir"); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error marking dir flag as required: %v\n", err)
|
||||
}
|
||||
if err := assetsExecuteCmd.MarkFlagRequired("conn"); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error marking conn flag as required: %v\n", err)
|
||||
}
|
||||
|
||||
assetsCmd.AddCommand(assetsListCmd)
|
||||
assetsCmd.AddCommand(assetsExecuteCmd)
|
||||
}
|
||||
|
||||
func runAssetsList(cmd *cobra.Command, args []string) error {
|
||||
fmt.Fprintf(os.Stderr, "\n=== Asset Manifests List ===\n")
|
||||
fmt.Fprintf(os.Stderr, "Directory: %s\n\n", assetsDir)
|
||||
|
||||
items, err := assetloader.ScanDir(assetsDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("scanning directory: %w", err)
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "No asset manifests found.\n\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Found %d asset entry(ies) in execution order:\n\n", len(items))
|
||||
fmt.Fprintf(os.Stderr, "%-4s %-10s %-8s %-20s %s\n", "No.", "Priority", "Sequence", "Dir", "File")
|
||||
fmt.Fprintf(os.Stderr, "%-4s %-10s %-8s %-20s %s\n", "----", "--------", "--------", "--------------------", "----")
|
||||
|
||||
for i, item := range items {
|
||||
fmt.Fprintf(os.Stderr, "%-4d %-10d %-8d %-20s %s\n",
|
||||
i+1,
|
||||
item.Priority,
|
||||
item.Sequence,
|
||||
item.DirName,
|
||||
filepath.Base(item.Entry.File),
|
||||
)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
func runAssetsExecute(cmd *cobra.Command, args []string) error {
|
||||
fmt.Fprintf(os.Stderr, "\n=== Asset Manifests Execution ===\n")
|
||||
fmt.Fprintf(os.Stderr, "Started at: %s\n", getCurrentTimestamp())
|
||||
fmt.Fprintf(os.Stderr, "Directory: %s\n", assetsDir)
|
||||
fmt.Fprintf(os.Stderr, "Database: %s\n\n", maskPassword(assetsConn))
|
||||
|
||||
fmt.Fprintf(os.Stderr, "[1/2] Scanning asset manifests...\n")
|
||||
|
||||
items, err := assetloader.ScanDir(assetsDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("scanning directory: %w", err)
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
fmt.Fprintf(os.Stderr, " No asset manifests found. Nothing to execute.\n\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, " ✓ Found %d asset entry(ies)\n\n", len(items))
|
||||
|
||||
fmt.Fprintf(os.Stderr, "[2/2] Executing assets in order (Priority → Sequence → Dir)...\n\n")
|
||||
|
||||
ctx := context.Background()
|
||||
conn, err := pgsql.Connect(ctx, assetsConn, "assets-execute")
|
||||
if err != nil {
|
||||
return fmt.Errorf("connecting to database: %w", err)
|
||||
}
|
||||
defer conn.Close(ctx)
|
||||
|
||||
successCount := 0
|
||||
var failures []struct {
|
||||
item assetloader.Item
|
||||
err error
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
name := filepath.Base(item.Entry.File)
|
||||
fmt.Printf("Executing asset: %s (Priority=%d, Sequence=%d, Dir=%s)\n",
|
||||
name, item.Priority, item.Sequence, item.DirName)
|
||||
|
||||
if err := assetloader.ExecuteItem(ctx, conn, item); err != nil {
|
||||
if assetsIgnoreErrors {
|
||||
fmt.Printf("⚠ Error loading %s: %v (continuing due to --ignore-errors)\n", name, err)
|
||||
failures = append(failures, struct {
|
||||
item assetloader.Item
|
||||
err error
|
||||
}{item, err})
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("asset %s (Priority=%d, Sequence=%d): %w",
|
||||
name, item.Priority, item.Sequence, err)
|
||||
}
|
||||
|
||||
successCount++
|
||||
fmt.Printf("✓ Successfully loaded: %s\n", name)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\n=== Execution Complete ===\n")
|
||||
fmt.Fprintf(os.Stderr, "Completed at: %s\n", getCurrentTimestamp())
|
||||
fmt.Fprintf(os.Stderr, "Total entries: %d\n", len(items))
|
||||
fmt.Fprintf(os.Stderr, "Successful: %d\n", successCount)
|
||||
if len(failures) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "Failed: %d\n", len(failures))
|
||||
fmt.Fprintf(os.Stderr, "\n⚠ Failed Entries Summary (%d failed):\n", len(failures))
|
||||
for i, f := range failures {
|
||||
fmt.Fprintf(os.Stderr, " %d. %s (Priority=%d, Sequence=%d)\n Error: %v\n",
|
||||
i+1, filepath.Base(f.item.Entry.File), f.item.Priority, f.item.Sequence, f.err)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
batchSourceType string
|
||||
batchInputs []string
|
||||
batchTargetType string
|
||||
batchTargetDir string
|
||||
batchPackageName string
|
||||
batchSchemaFilter string
|
||||
batchFlattenSchema bool
|
||||
batchNullableTypes string
|
||||
batchNullableArrays string
|
||||
batchContinueOnError bool
|
||||
batchKeepGoing bool
|
||||
batchDryRun bool
|
||||
)
|
||||
|
||||
var batchCmd = &cobra.Command{
|
||||
Use: "batch",
|
||||
Short: "Convert many input files to a target format in one run",
|
||||
Long: `Convert each input file independently to the target format.
|
||||
|
||||
Unlike 'convert --from-list', which merges all inputs into one output, batch
|
||||
mode writes one output per input into --to-dir. The output is named after the
|
||||
input file (without its extension). Directory-style targets (gorm, bun,
|
||||
drizzle) get a sub-directory per input.
|
||||
|
||||
Inputs are given with --input, which accepts file paths and glob patterns and
|
||||
may be repeated or comma-separated. Inputs are processed in sorted order and
|
||||
duplicates are removed. The command exits non-zero if any input fails.
|
||||
|
||||
For named, multi-step workflows use 'relspec job run' instead.
|
||||
|
||||
Examples:
|
||||
# Convert every DBML file in a directory to JSON
|
||||
relspec batch --from dbml --input "schemas/*.dbml" --to json --to-dir out/
|
||||
|
||||
# Convert specific files to GORM models, one package directory per input
|
||||
relspec batch --from json --input a.json,b.json \
|
||||
--to gorm --to-dir models/ --package models
|
||||
|
||||
# Validate everything first, writing nothing
|
||||
relspec batch --from yaml --input "specs/*.yaml" --to pgsql --to-dir sql/ --dry-run
|
||||
|
||||
# Report all failures instead of stopping at the first
|
||||
relspec batch --from json --input "*.json" --to yaml --to-dir out/ --keep-going`,
|
||||
RunE: runBatch,
|
||||
}
|
||||
|
||||
func init() {
|
||||
batchCmd.Flags().StringVar(&batchSourceType, "from", "", "Source format for every input (dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, sqlite)")
|
||||
batchCmd.Flags().StringSliceVar(&batchInputs, "input", nil, "Input file path or glob pattern (repeatable, comma-separated)")
|
||||
batchCmd.Flags().StringVar(&batchTargetType, "to", "", "Target format")
|
||||
batchCmd.Flags().StringVar(&batchTargetDir, "to-dir", "", "Output directory; one output per input is written here")
|
||||
batchCmd.Flags().StringVar(&batchPackageName, "package", "", "Package name (for code generation formats like gorm/bun)")
|
||||
batchCmd.Flags().StringVar(&batchSchemaFilter, "schema", "", "Filter to a specific schema by name")
|
||||
batchCmd.Flags().BoolVar(&batchFlattenSchema, "flatten-schema", false, "Flatten schema.table names to schema_table")
|
||||
batchCmd.Flags().StringVar(&batchNullableTypes, "types", "", "Nullable type package for code-gen writers (bun/gorm)")
|
||||
batchCmd.Flags().StringVar(&batchNullableArrays, "array-nullable", "", "Nullable array representation for the Bun writer")
|
||||
batchCmd.Flags().BoolVar(&batchContinueOnError, "continue-on-error", false, "Prepend \\set ON_ERROR_STOP off to generated SQL (pgsql output only)")
|
||||
batchCmd.Flags().BoolVar(&batchKeepGoing, "keep-going", false, "Process remaining inputs after a failure; still exits non-zero")
|
||||
batchCmd.Flags().BoolVar(&batchDryRun, "dry-run", false, "Read and validate every input and print the plan without writing any output")
|
||||
|
||||
for _, f := range []string{"from", "input", "to", "to-dir"} {
|
||||
if err := batchCmd.MarkFlagRequired(f); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error marking %s flag as required: %v\n", f, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// batchDirTargets are writers that emit a directory of files rather than one file.
|
||||
var batchDirTargets = map[string]bool{"gorm": true, "bun": true, "drizzle": true}
|
||||
|
||||
// batchExtensions maps single-file target formats to their output extension.
|
||||
var batchExtensions = map[string]string{
|
||||
"dbml": ".dbml", "dctx": ".dctx", "drawdb": ".ddb", "json": ".json",
|
||||
"yaml": ".yaml", "yml": ".yaml", "pgsql": ".sql", "postgres": ".sql",
|
||||
"postgresql": ".sql", "sql": ".sql", "mssql": ".sql", "sqlserver": ".sql",
|
||||
"mssql2016": ".sql", "mssql2017": ".sql", "mssql2019": ".sql", "mssql2022": ".sql",
|
||||
"sqlite": ".sql", "sqlite3": ".sql", "prisma": ".prisma", "typeorm": ".ts",
|
||||
"graphql": ".graphql", "gql": ".graphql",
|
||||
}
|
||||
|
||||
// expandBatchInputs resolves paths and glob patterns into a sorted,
|
||||
// de-duplicated file list. A pattern that matches nothing is an error.
|
||||
func expandBatchInputs(patterns []string) ([]string, error) {
|
||||
seen := map[string]bool{}
|
||||
var files []string
|
||||
for _, p := range patterns {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
matches, err := filepath.Glob(p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid pattern %q: %w", p, err)
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return nil, fmt.Errorf("no files match %q", p)
|
||||
}
|
||||
for _, m := range matches {
|
||||
if !seen[m] {
|
||||
seen[m] = true
|
||||
files = append(files, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return nil, fmt.Errorf("no input files given")
|
||||
}
|
||||
sort.Strings(files)
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// batchOutputPaths returns the output path for each input. It errors when two
|
||||
// inputs would collide on the same output name.
|
||||
func batchOutputPaths(files []string, targetType, dir string) ([]string, error) {
|
||||
key := strings.ToLower(targetType)
|
||||
ext := ""
|
||||
if !batchDirTargets[key] {
|
||||
var ok bool
|
||||
if ext, ok = batchExtensions[key]; !ok {
|
||||
return nil, fmt.Errorf("unsupported target format: %s", targetType)
|
||||
}
|
||||
}
|
||||
outs := make([]string, len(files))
|
||||
owner := map[string]string{}
|
||||
for i, f := range files {
|
||||
stem := strings.TrimSuffix(filepath.Base(f), filepath.Ext(f))
|
||||
out := filepath.Join(dir, stem+ext)
|
||||
if prev, dup := owner[out]; dup {
|
||||
return nil, fmt.Errorf("inputs %s and %s would both write %s", prev, f, out)
|
||||
}
|
||||
owner[out] = f
|
||||
outs[i] = out
|
||||
}
|
||||
return outs, nil
|
||||
}
|
||||
|
||||
func runBatch(cmd *cobra.Command, args []string) error {
|
||||
files, err := expandBatchInputs(batchInputs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outs, err := batchOutputPaths(files, batchTargetType, batchTargetDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\n=== RelSpec Batch Converter ===\n")
|
||||
fmt.Fprintf(os.Stderr, "Started at: %s\n", getCurrentTimestamp())
|
||||
fmt.Fprintf(os.Stderr, "Inputs: %d file(s), %s -> %s\n\n", len(files), batchSourceType, batchTargetType)
|
||||
|
||||
out := outWriter(cmd)
|
||||
if batchDryRun {
|
||||
fmt.Fprintf(out, "RelSpec batch plan (dry run - nothing written):\n")
|
||||
}
|
||||
|
||||
var failed []string
|
||||
for i, f := range files {
|
||||
fmt.Fprintf(os.Stderr, "[%d/%d] %s -> %s\n", i+1, len(files), f, outs[i])
|
||||
if err := processBatchItem(cmd, f, outs[i]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, " ✗ %v\n", err)
|
||||
failed = append(failed, fmt.Sprintf("%s: %v", f, err))
|
||||
if !batchKeepGoing {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, " ✓ done\n")
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\n=== Batch Complete: %d ok, %d failed ===\n", len(files)-len(failed), len(failed))
|
||||
if len(failed) > 0 {
|
||||
return fmt.Errorf("batch finished with %d failure(s):\n %s", len(failed), strings.Join(failed, "\n "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func processBatchItem(cmd *cobra.Command, in, outPath string) error {
|
||||
db, err := readDatabaseForConvert(batchSourceType, in, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read source: %w", err)
|
||||
}
|
||||
finalizeCommentedRefs(db, stderrWarn)
|
||||
|
||||
if batchDryRun {
|
||||
if err := validateWriteTarget(db, batchTargetType, batchPackageName, batchSchemaFilter, ""); err != nil {
|
||||
return fmt.Errorf("dry run validation failed: %w", err)
|
||||
}
|
||||
w := outWriter(cmd)
|
||||
fmt.Fprintf(w, " %s -> %s (database '%s')\n", in, outPath, db.Name)
|
||||
printDryRunPlan(w, db)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(batchTargetDir, 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
if err := writeDatabase(db, batchTargetType, outPath, batchPackageName, batchSchemaFilter, batchFlattenSchema, batchNullableTypes, batchNullableArrays, batchContinueOnError, ""); err != nil {
|
||||
return fmt.Errorf("failed to write target: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func saveBatchState(t *testing.T) {
|
||||
t.Helper()
|
||||
a, b, c, d, e, f, g := batchSourceType, batchInputs, batchTargetType, batchTargetDir, batchPackageName, batchKeepGoing, batchDryRun
|
||||
t.Cleanup(func() {
|
||||
batchSourceType, batchInputs, batchTargetType, batchTargetDir, batchPackageName, batchKeepGoing, batchDryRun = a, b, c, d, e, f, g
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunBatch_ConvertsEachInput(t *testing.T) {
|
||||
saveBatchState(t)
|
||||
dir := t.TempDir()
|
||||
writeTestJSON(t, filepath.Join(dir, "a.json"), []string{"users"})
|
||||
writeTestJSON(t, filepath.Join(dir, "b.json"), []string{"posts"})
|
||||
outDir := filepath.Join(dir, "out")
|
||||
|
||||
batchSourceType, batchTargetType, batchTargetDir = "json", "yaml", outDir
|
||||
batchPackageName, batchKeepGoing, batchDryRun = "", false, false
|
||||
batchInputs = []string{filepath.Join(dir, "*.json")}
|
||||
|
||||
cmd, _ := newDryRunCmd()
|
||||
if err := runBatch(cmd, nil); err != nil {
|
||||
t.Fatalf("batch: %v", err)
|
||||
}
|
||||
for _, name := range []string{"a.yaml", "b.yaml"} {
|
||||
if _, err := os.Stat(filepath.Join(outDir, name)); err != nil {
|
||||
t.Errorf("expected %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBatch_DryRunWritesNothing(t *testing.T) {
|
||||
saveBatchState(t)
|
||||
dir := t.TempDir()
|
||||
writeTestJSON(t, filepath.Join(dir, "a.json"), []string{"users"})
|
||||
outDir := filepath.Join(dir, "out")
|
||||
|
||||
batchSourceType, batchTargetType, batchTargetDir = "json", "yaml", outDir
|
||||
batchPackageName, batchKeepGoing, batchDryRun = "", false, true
|
||||
batchInputs = []string{filepath.Join(dir, "a.json")}
|
||||
|
||||
cmd, buf := newDryRunCmd()
|
||||
if err := runBatch(cmd, nil); err != nil {
|
||||
t.Fatalf("dry run: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(outDir); !os.IsNotExist(err) {
|
||||
t.Fatal("dry run must not create the output directory")
|
||||
}
|
||||
if !strings.Contains(buf.String(), "users") || !strings.Contains(buf.String(), "a.yaml") {
|
||||
t.Errorf("plan incomplete:\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBatch_FailureHandling(t *testing.T) {
|
||||
saveBatchState(t)
|
||||
dir := t.TempDir()
|
||||
writeTestJSON(t, filepath.Join(dir, "a.json"), []string{"users"})
|
||||
if err := os.WriteFile(filepath.Join(dir, "b.json"), []byte("{not json"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeTestJSON(t, filepath.Join(dir, "c.json"), []string{"posts"})
|
||||
outDir := filepath.Join(dir, "out")
|
||||
|
||||
batchSourceType, batchTargetType, batchTargetDir = "json", "yaml", outDir
|
||||
batchPackageName, batchDryRun = "", false
|
||||
batchInputs = []string{filepath.Join(dir, "*.json")}
|
||||
cmd, _ := newDryRunCmd()
|
||||
|
||||
// Default: stop at first failure.
|
||||
batchKeepGoing = false
|
||||
err := runBatch(cmd, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "b.json") {
|
||||
t.Fatalf("expected failure naming b.json, got %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(outDir, "c.yaml")); !os.IsNotExist(statErr) {
|
||||
t.Error("c.json should not be processed without --keep-going")
|
||||
}
|
||||
|
||||
// --keep-going: remaining inputs are processed, exit still fails.
|
||||
batchKeepGoing = true
|
||||
if err := runBatch(cmd, nil); err == nil {
|
||||
t.Fatal("expected non-zero result with --keep-going")
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(outDir, "c.yaml")); statErr != nil {
|
||||
t.Errorf("c.yaml should be written with --keep-going: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandBatchInputs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, n := range []string{"b.json", "a.json"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, n), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
got, err := expandBatchInputs([]string{filepath.Join(dir, "*.json"), filepath.Join(dir, "a.json")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 || filepath.Base(got[0]) != "a.json" || filepath.Base(got[1]) != "b.json" {
|
||||
t.Errorf("want sorted deduped [a b], got %v", got)
|
||||
}
|
||||
if _, err := expandBatchInputs([]string{filepath.Join(dir, "*.nope")}); err == nil {
|
||||
t.Error("unmatched pattern should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchOutputPaths(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
files []string
|
||||
target string
|
||||
want []string
|
||||
wantErr string
|
||||
}{
|
||||
{"file target", []string{"x/a.dbml"}, "json", []string{"out/a.json"}, ""},
|
||||
{"dir target", []string{"x/a.json"}, "gorm", []string{"out/a"}, ""},
|
||||
{"collision", []string{"x/a.json", "y/a.json"}, "yaml", nil, "both write"},
|
||||
{"unsupported", []string{"a.json"}, "nope", nil, "unsupported target"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := batchOutputPaths(tt.files, tt.target, "out")
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("want error %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || len(got) != len(tt.want) || got[0] != filepath.FromSlash(tt.want[0]) {
|
||||
t.Fatalf("got %v, %v; want %v", got, err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+289
-42
@@ -1,13 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
stdjson "encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/merge"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/bun"
|
||||
@@ -18,8 +21,11 @@ import (
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/gorm"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/graphql"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/json"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/mssql"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/mysql"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/pgsql"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/prisma"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/sqlite"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/typeorm"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/yaml"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
@@ -31,21 +37,32 @@ import (
|
||||
wgorm "git.warky.dev/wdevs/relspecgo/pkg/writers/gorm"
|
||||
wgraphql "git.warky.dev/wdevs/relspecgo/pkg/writers/graphql"
|
||||
wjson "git.warky.dev/wdevs/relspecgo/pkg/writers/json"
|
||||
wmssql "git.warky.dev/wdevs/relspecgo/pkg/writers/mssql"
|
||||
wmysql "git.warky.dev/wdevs/relspecgo/pkg/writers/mysql"
|
||||
wpgsql "git.warky.dev/wdevs/relspecgo/pkg/writers/pgsql"
|
||||
wprisma "git.warky.dev/wdevs/relspecgo/pkg/writers/prisma"
|
||||
wsqlite "git.warky.dev/wdevs/relspecgo/pkg/writers/sqlite"
|
||||
wtypeorm "git.warky.dev/wdevs/relspecgo/pkg/writers/typeorm"
|
||||
wyaml "git.warky.dev/wdevs/relspecgo/pkg/writers/yaml"
|
||||
)
|
||||
|
||||
var (
|
||||
convertSourceType string
|
||||
convertSourcePath string
|
||||
convertSourceConn string
|
||||
convertTargetType string
|
||||
convertTargetPath string
|
||||
convertPackageName string
|
||||
convertSchemaFilter string
|
||||
convertFlattenSchema bool
|
||||
convertSourceType string
|
||||
convertSourcePath string
|
||||
convertSourceConn string
|
||||
convertFromList []string
|
||||
convertTargetType string
|
||||
convertTargetPath string
|
||||
convertPackageName string
|
||||
convertSchemaFilter string
|
||||
convertFlattenSchema bool
|
||||
convertNullableTypes string
|
||||
convertNullableArrays string
|
||||
convertContinueOnError bool
|
||||
convertExtraFields string
|
||||
convertDryRun bool
|
||||
convertWatch bool
|
||||
convertWatchInterval time.Duration
|
||||
)
|
||||
|
||||
var convertCmd = &cobra.Command{
|
||||
@@ -70,6 +87,8 @@ Input formats:
|
||||
- prisma: Prisma schema files (.prisma)
|
||||
- typeorm: TypeORM entity files (TypeScript)
|
||||
- pgsql: PostgreSQL database (live connection)
|
||||
- mssql: Microsoft SQL Server database (live connection)
|
||||
- sqlite: SQLite database file
|
||||
|
||||
Output formats:
|
||||
- dbml: DBML schema files
|
||||
@@ -84,13 +103,21 @@ Output formats:
|
||||
- prisma: Prisma schema files (.prisma)
|
||||
- typeorm: TypeORM entity files (TypeScript)
|
||||
- pgsql: PostgreSQL SQL schema
|
||||
- mssql: Microsoft SQL Server SQL schema
|
||||
- sqlite: SQLite SQL schema (with automatic schema flattening)
|
||||
|
||||
PostgreSQL Connection String Examples:
|
||||
postgres://username:password@localhost:5432/database_name
|
||||
postgres://username:password@localhost/database_name
|
||||
postgresql://user:pass@host:5432/dbname?sslmode=disable
|
||||
postgresql://user:pass@host/dbname?sslmode=require
|
||||
host=localhost port=5432 user=username password=pass dbname=mydb sslmode=disable
|
||||
Connection String Examples:
|
||||
PostgreSQL:
|
||||
postgres://username:password@localhost:5432/database_name
|
||||
postgres://username:password@localhost/database_name
|
||||
postgresql://user:pass@host:5432/dbname?sslmode=disable
|
||||
postgresql://user:pass@host/dbname?sslmode=require
|
||||
host=localhost port=5432 user=username password=pass dbname=mydb sslmode=disable
|
||||
|
||||
SQLite:
|
||||
/path/to/database.db
|
||||
./relative/path/database.sqlite
|
||||
database.db
|
||||
|
||||
|
||||
Examples:
|
||||
@@ -136,20 +163,42 @@ Examples:
|
||||
|
||||
# Convert Bun models directory to JSON
|
||||
relspec convert --from bun --from-path ./models \
|
||||
--to json --to-path schema.json`,
|
||||
--to json --to-path schema.json
|
||||
|
||||
# Convert SQLite database to JSON
|
||||
relspec convert --from sqlite --from-path database.db \
|
||||
--to json --to-path schema.json
|
||||
|
||||
# Convert SQLite to PostgreSQL SQL
|
||||
relspec convert --from sqlite --from-path database.db \
|
||||
--to pgsql --to-path schema.sql
|
||||
|
||||
# Regenerate GORM models every time the DBML file changes
|
||||
relspec convert --from dbml --from-path schema.dbml \
|
||||
--to gorm --to-path models/ --package models --watch`,
|
||||
RunE: runConvert,
|
||||
}
|
||||
|
||||
func init() {
|
||||
convertCmd.Flags().StringVar(&convertSourceType, "from", "", "Source format (dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, pgsql)")
|
||||
convertCmd.Flags().StringVar(&convertSourceType, "from", "", "Source format (dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, pgsql, sqlite)")
|
||||
convertCmd.Flags().StringVar(&convertSourcePath, "from-path", "", "Source file path (for file-based formats)")
|
||||
convertCmd.Flags().StringVar(&convertSourceConn, "from-conn", "", "Source connection string (for database formats)")
|
||||
convertCmd.Flags().StringVar(&convertSourceConn, "from-conn", "", "Source connection string (for pgsql) or file path (for sqlite)")
|
||||
convertCmd.Flags().StringSliceVar(&convertFromList, "from-list", nil, "Comma-separated list of source file paths to read and merge (mutually exclusive with --from-path)")
|
||||
|
||||
convertCmd.Flags().StringVar(&convertTargetType, "to", "", "Target format (dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, pgsql)")
|
||||
convertCmd.Flags().StringVar(&convertTargetPath, "to-path", "", "Target output path (file or directory)")
|
||||
convertCmd.Flags().StringVar(&convertPackageName, "package", "", "Package name (for code generation formats like gorm/bun)")
|
||||
convertCmd.Flags().StringVar(&convertSchemaFilter, "schema", "", "Filter to a specific schema by name (required for formats like dctx that only support single schemas)")
|
||||
convertCmd.Flags().BoolVar(&convertFlattenSchema, "flatten-schema", false, "Flatten schema.table names to schema_table (useful for databases like SQLite that do not support schemas)")
|
||||
convertCmd.Flags().StringVar(&convertNullableTypes, "types", "", "Nullable type package for code-gen writers (bun/gorm): 'baselib' (default, Go pointer types), 'stdlib' (database/sql), or 'sqltypes'")
|
||||
convertCmd.Flags().StringVar(&convertNullableArrays, "array-nullable", "", "Nullable PostgreSQL array representation for the Bun writer in stdlib/baselib --types mode: 'slice' (default, plain slice) or 'pointer_slice' (*[]T, distinguishes NULL from '{}')")
|
||||
convertCmd.Flags().BoolVar(&convertContinueOnError, "continue-on-error", false, "Prepend \\set ON_ERROR_STOP off to generated SQL so psql continues past errors (pgsql output only)")
|
||||
convertCmd.Flags().StringVar(&convertExtraFields, "extra-fields", "", "Path to JSON file containing extra Bun model fields to inject (bun output only); fields support target_table, name, type, bun_tag, json_tag, comment")
|
||||
|
||||
convertCmd.Flags().BoolVar(&convertDryRun, "dry-run", false, "Read and validate the input and print the plan without writing any output")
|
||||
|
||||
convertCmd.Flags().BoolVar(&convertWatch, "watch", false, "Watch the source files (--from-path or --from-list) and regenerate the output whenever they change")
|
||||
convertCmd.Flags().DurationVar(&convertWatchInterval, "watch-interval", 500*time.Millisecond, "Polling interval used by --watch")
|
||||
|
||||
err := convertCmd.MarkFlagRequired("from")
|
||||
if err != nil {
|
||||
@@ -166,23 +215,43 @@ func init() {
|
||||
}
|
||||
|
||||
func runConvert(cmd *cobra.Command, args []string) error {
|
||||
if convertWatch {
|
||||
return runConvertWatch(cmd.Context(), os.Stderr, func() error { return runConvertOnce(cmd) })
|
||||
}
|
||||
return runConvertOnce(cmd)
|
||||
}
|
||||
|
||||
func runConvertOnce(cmd *cobra.Command) error {
|
||||
fmt.Fprintf(os.Stderr, "\n=== RelSpec Schema Converter ===\n")
|
||||
fmt.Fprintf(os.Stderr, "Started at: %s\n\n", getCurrentTimestamp())
|
||||
|
||||
// Validate mutually exclusive flags
|
||||
if convertSourcePath != "" && len(convertFromList) > 0 {
|
||||
return fmt.Errorf("--from-path and --from-list are mutually exclusive")
|
||||
}
|
||||
|
||||
// Read source database
|
||||
fmt.Fprintf(os.Stderr, "[1/2] Reading source schema...\n")
|
||||
fmt.Fprintf(os.Stderr, " Format: %s\n", convertSourceType)
|
||||
if convertSourcePath != "" {
|
||||
fmt.Fprintf(os.Stderr, " Path: %s\n", convertSourcePath)
|
||||
}
|
||||
if convertSourceConn != "" {
|
||||
fmt.Fprintf(os.Stderr, " Conn: %s\n", maskPassword(convertSourceConn))
|
||||
}
|
||||
|
||||
db, err := readDatabaseForConvert(convertSourceType, convertSourcePath, convertSourceConn)
|
||||
var db *models.Database
|
||||
var err error
|
||||
|
||||
if len(convertFromList) > 0 {
|
||||
db, err = readDatabaseListForConvert(convertSourceType, convertFromList)
|
||||
} else {
|
||||
if convertSourcePath != "" {
|
||||
fmt.Fprintf(os.Stderr, " Path: %s\n", convertSourcePath)
|
||||
}
|
||||
if convertSourceConn != "" {
|
||||
fmt.Fprintf(os.Stderr, " Conn: %s\n", maskPassword(convertSourceConn))
|
||||
}
|
||||
db, err = readDatabaseForConvert(convertSourceType, convertSourcePath, convertSourceConn)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read source: %w", err)
|
||||
}
|
||||
finalizeCommentedRefs(db, stderrWarn)
|
||||
|
||||
fmt.Fprintf(os.Stderr, " ✓ Successfully read database '%s'\n", db.Name)
|
||||
fmt.Fprintf(os.Stderr, " Found: %d schema(s)\n", len(db.Schemas))
|
||||
@@ -193,6 +262,22 @@ func runConvert(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, " Found: %d table(s)\n\n", totalTables)
|
||||
|
||||
if convertDryRun {
|
||||
if err := validateWriteTarget(db, convertTargetType, convertPackageName, convertSchemaFilter, convertExtraFields); err != nil {
|
||||
return fmt.Errorf("dry run validation failed: %w", err)
|
||||
}
|
||||
out := outWriter(cmd)
|
||||
fmt.Fprintf(out, "RelSpec convert plan (dry run - nothing written):\n")
|
||||
fmt.Fprintf(out, " Input: %s database '%s'\n", convertSourceType, db.Name)
|
||||
fmt.Fprintf(out, " Output: %s -> %s\n", convertTargetType, convertTargetPath)
|
||||
if convertSchemaFilter != "" {
|
||||
fmt.Fprintf(out, " Schema filter: %s\n", convertSchemaFilter)
|
||||
}
|
||||
printDryRunPlan(out, db)
|
||||
fmt.Fprintf(os.Stderr, "=== Dry run complete: no output written ===\n\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Write to target format
|
||||
fmt.Fprintf(os.Stderr, "[2/2] Writing to target format...\n")
|
||||
fmt.Fprintf(os.Stderr, " Format: %s\n", convertTargetType)
|
||||
@@ -204,7 +289,7 @@ func runConvert(cmd *cobra.Command, args []string) error {
|
||||
fmt.Fprintf(os.Stderr, " Schema: %s\n", convertSchemaFilter)
|
||||
}
|
||||
|
||||
if err := writeDatabase(db, convertTargetType, convertTargetPath, convertPackageName, convertSchemaFilter, convertFlattenSchema); err != nil {
|
||||
if err := writeDatabase(db, convertTargetType, convertTargetPath, convertPackageName, convertSchemaFilter, convertFlattenSchema, convertNullableTypes, convertNullableArrays, convertContinueOnError, convertExtraFields); err != nil {
|
||||
return fmt.Errorf("failed to write target: %w", err)
|
||||
}
|
||||
|
||||
@@ -215,6 +300,42 @@ func runConvert(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// finalizeCommentedRefs resolves DBML `// Ref:` comments against the fully
|
||||
// loaded model and warns about refs whose target is not loaded.
|
||||
func finalizeCommentedRefs(db *models.Database, warn func(string)) {
|
||||
for _, w := range dbml.ResolveCommentedRefs(db, true) {
|
||||
warn(w)
|
||||
}
|
||||
}
|
||||
|
||||
func stderrWarn(msg string) {
|
||||
fmt.Fprintf(os.Stderr, " ⚠ %s\n", msg)
|
||||
}
|
||||
|
||||
func readDatabaseListForConvert(dbType string, files []string) (*models.Database, error) {
|
||||
if len(files) == 0 {
|
||||
return nil, fmt.Errorf("file list is empty")
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, " Files: %d file(s)\n", len(files))
|
||||
|
||||
var base *models.Database
|
||||
for i, filePath := range files {
|
||||
fmt.Fprintf(os.Stderr, " [%d/%d] %s\n", i+1, len(files), filePath)
|
||||
db, err := readDatabaseForConvert(dbType, filePath, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read %s: %w", filePath, err)
|
||||
}
|
||||
if base == nil {
|
||||
base = db
|
||||
} else {
|
||||
merge.MergeDatabases(base, db, &merge.MergeOptions{})
|
||||
}
|
||||
}
|
||||
|
||||
return base, nil
|
||||
}
|
||||
|
||||
func readDatabaseForConvert(dbType, filePath, connString string) (*models.Database, error) {
|
||||
var reader readers.Reader
|
||||
|
||||
@@ -223,73 +344,96 @@ func readDatabaseForConvert(dbType, filePath, connString string) (*models.Databa
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for DBML format")
|
||||
}
|
||||
reader = dbml.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = dbml.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "dctx":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for DCTX format")
|
||||
}
|
||||
reader = dctx.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = dctx.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "drawdb":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for DrawDB format")
|
||||
}
|
||||
reader = drawdb.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = drawdb.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "json":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for JSON format")
|
||||
}
|
||||
reader = json.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = json.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "yaml", "yml":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for YAML format")
|
||||
}
|
||||
reader = yaml.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = yaml.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "pgsql", "postgres", "postgresql":
|
||||
if connString == "" {
|
||||
return nil, fmt.Errorf("connection string is required for PostgreSQL format")
|
||||
}
|
||||
reader = pgsql.NewReader(&readers.ReaderOptions{ConnectionString: connString})
|
||||
reader = pgsql.NewReader(newReaderOptions("", connString))
|
||||
|
||||
case "gorm":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for GORM format")
|
||||
}
|
||||
reader = gorm.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = gorm.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "bun":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for Bun format")
|
||||
}
|
||||
reader = bun.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = bun.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "drizzle":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for Drizzle format")
|
||||
}
|
||||
reader = drizzle.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = drizzle.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "prisma":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for Prisma format")
|
||||
}
|
||||
reader = prisma.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = prisma.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "typeorm":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for TypeORM format")
|
||||
}
|
||||
reader = typeorm.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = typeorm.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "graphql", "gql":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for GraphQL format")
|
||||
}
|
||||
reader = graphql.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = graphql.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "mssql", "sqlserver", "mssql2016", "mssql2017", "mssql2019", "mssql2022":
|
||||
if connString == "" {
|
||||
return nil, fmt.Errorf("connection string is required for MSSQL format")
|
||||
}
|
||||
reader = mssql.NewReader(newReaderOptions("", connString))
|
||||
|
||||
case "mysql", "mariadb":
|
||||
if connString == "" {
|
||||
return nil, fmt.Errorf("connection string is required for MySQL format")
|
||||
}
|
||||
reader = mysql.NewReader(newReaderOptions("", connString))
|
||||
|
||||
case "sqlite", "sqlite3":
|
||||
// SQLite can use either file path or connection string
|
||||
dbPath := filePath
|
||||
if dbPath == "" {
|
||||
dbPath = connString
|
||||
}
|
||||
if dbPath == "" {
|
||||
return nil, fmt.Errorf("file path or connection string is required for SQLite format")
|
||||
}
|
||||
reader = sqlite.NewReader(newReaderOptions(dbPath, ""))
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported source format: %s", dbType)
|
||||
@@ -303,13 +447,18 @@ func readDatabaseForConvert(dbType, filePath, connString string) (*models.Databa
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func writeDatabase(db *models.Database, dbType, outputPath, packageName, schemaFilter string, flattenSchema bool) error {
|
||||
func writeDatabase(db *models.Database, dbType, outputPath, packageName, schemaFilter string, flattenSchema bool, nullableTypes, nullableArrays string, continueOnError bool, extraFields string) error {
|
||||
var writer writers.Writer
|
||||
|
||||
writerOpts := &writers.WriterOptions{
|
||||
OutputPath: outputPath,
|
||||
PackageName: packageName,
|
||||
FlattenSchema: flattenSchema,
|
||||
writerOpts := newWriterOptions(outputPath, packageName, flattenSchema, nullableTypes, nullableArrays, continueOnError)
|
||||
if extraFields != "" {
|
||||
extraFieldsJSON, err := loadExtraFields(dbType, extraFields)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writerOpts.Metadata = map[string]interface{}{
|
||||
"extra_fields": extraFieldsJSON,
|
||||
}
|
||||
}
|
||||
|
||||
switch strings.ToLower(dbType) {
|
||||
@@ -346,6 +495,15 @@ func writeDatabase(db *models.Database, dbType, outputPath, packageName, schemaF
|
||||
case "pgsql", "postgres", "postgresql", "sql":
|
||||
writer = wpgsql.NewWriter(writerOpts)
|
||||
|
||||
case "mssql", "sqlserver", "mssql2016", "mssql2017", "mssql2019", "mssql2022":
|
||||
writer = wmssql.NewWriter(writerOpts)
|
||||
|
||||
case "mysql", "mariadb":
|
||||
writer = wmysql.NewWriter(writerOpts)
|
||||
|
||||
case "sqlite", "sqlite3":
|
||||
writer = wsqlite.NewWriter(writerOpts)
|
||||
|
||||
case "prisma":
|
||||
writer = wprisma.NewWriter(writerOpts)
|
||||
|
||||
@@ -407,6 +565,95 @@ func writeDatabase(db *models.Database, dbType, outputPath, packageName, schemaF
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadExtraFields reads and validates the --extra-fields JSON file, returning
|
||||
// its raw content.
|
||||
func loadExtraFields(dbType, path string) (string, error) {
|
||||
if !strings.EqualFold(dbType, "bun") {
|
||||
return "", fmt.Errorf("--extra-fields is only supported for Bun output")
|
||||
}
|
||||
extraFieldsJSON, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read --extra-fields file %q: %w", path, err)
|
||||
}
|
||||
|
||||
var parsed []wbun.ExtraFieldConfig
|
||||
if err := stdjson.Unmarshal(extraFieldsJSON, &parsed); err != nil {
|
||||
return "", fmt.Errorf("invalid --extra-fields JSON in %q: %w", path, err)
|
||||
}
|
||||
if len(parsed) == 0 {
|
||||
return "", fmt.Errorf("--extra-fields must contain at least one field")
|
||||
}
|
||||
return string(extraFieldsJSON), nil
|
||||
}
|
||||
|
||||
// validateWriteTarget performs the checks writeDatabase makes before writing,
|
||||
// without constructing a writer or touching the output path. Used by --dry-run.
|
||||
func validateWriteTarget(db *models.Database, dbType, packageName, schemaFilter, extraFields string) error {
|
||||
if extraFields != "" {
|
||||
if _, err := loadExtraFields(dbType, extraFields); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
switch strings.ToLower(dbType) {
|
||||
case "dbml", "dctx", "drawdb", "json", "yaml", "yml", "drizzle",
|
||||
"pgsql", "postgres", "postgresql", "sql",
|
||||
"mssql", "sqlserver", "mssql2016", "mssql2017", "mssql2019", "mssql2022",
|
||||
"sqlite", "sqlite3", "prisma", "typeorm", "graphql", "gql":
|
||||
case "gorm", "bun":
|
||||
if packageName == "" {
|
||||
return fmt.Errorf("package name is required for %s format (use --package flag)", dbType)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported target format: %s", dbType)
|
||||
}
|
||||
|
||||
if schemaFilter != "" {
|
||||
for _, schema := range db.Schemas {
|
||||
if schema.Name == schemaFilter {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("schema '%s' not found in database. Available schemas: %v",
|
||||
schemaFilter, getSchemaNames(db))
|
||||
}
|
||||
|
||||
if strings.EqualFold(dbType, "dctx") {
|
||||
if len(db.Schemas) == 0 {
|
||||
return fmt.Errorf("no schemas found in database")
|
||||
}
|
||||
if len(db.Schemas) > 1 {
|
||||
return fmt.Errorf("multiple schemas found, please specify which schema to export using --schema flag. Available schemas: %v",
|
||||
getSchemaNames(db))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// outWriter returns the command's stdout, falling back to os.Stdout when the
|
||||
// command is nil (tests call the run functions directly).
|
||||
func outWriter(cmd *cobra.Command) io.Writer {
|
||||
if cmd == nil {
|
||||
return os.Stdout
|
||||
}
|
||||
return cmd.OutOrStdout()
|
||||
}
|
||||
|
||||
// printDryRunPlan prints the schemas and tables that would be written.
|
||||
func printDryRunPlan(out io.Writer, db *models.Database) {
|
||||
for _, schema := range db.Schemas {
|
||||
names := make([]string, 0, len(schema.Tables))
|
||||
for _, t := range schema.Tables {
|
||||
names = append(names, t.Name)
|
||||
}
|
||||
fmt.Fprintf(out, " schema %q: %d table(s)", schema.Name, len(schema.Tables))
|
||||
if len(names) > 0 {
|
||||
fmt.Fprintf(out, " [%s]", strings.Join(names, ", "))
|
||||
}
|
||||
fmt.Fprintln(out)
|
||||
}
|
||||
}
|
||||
|
||||
// getSchemaNames returns a slice of schema names from a database
|
||||
func getSchemaNames(db *models.Database) []string {
|
||||
names := make([]string, len(db.Schemas))
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadDatabaseListForConvert_SingleFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
file := filepath.Join(dir, "schema.json")
|
||||
writeTestJSON(t, file, []string{"users"})
|
||||
|
||||
db, err := readDatabaseListForConvert("json", []string{file})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(db.Schemas) == 0 {
|
||||
t.Fatal("expected at least one schema")
|
||||
}
|
||||
if len(db.Schemas[0].Tables) != 1 {
|
||||
t.Errorf("expected 1 table, got %d", len(db.Schemas[0].Tables))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadDatabaseListForConvert_MultipleFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
file1 := filepath.Join(dir, "schema1.json")
|
||||
file2 := filepath.Join(dir, "schema2.json")
|
||||
writeTestJSON(t, file1, []string{"users"})
|
||||
writeTestJSON(t, file2, []string{"comments"})
|
||||
|
||||
db, err := readDatabaseListForConvert("json", []string{file1, file2})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
total := 0
|
||||
for _, s := range db.Schemas {
|
||||
total += len(s.Tables)
|
||||
}
|
||||
if total != 2 {
|
||||
t.Errorf("expected 2 tables (users + comments), got %d", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadDatabaseListForConvert_PathWithSpaces(t *testing.T) {
|
||||
spacedDir := filepath.Join(t.TempDir(), "my schema files")
|
||||
if err := os.MkdirAll(spacedDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file := filepath.Join(spacedDir, "my users schema.json")
|
||||
writeTestJSON(t, file, []string{"users"})
|
||||
|
||||
db, err := readDatabaseListForConvert("json", []string{file})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error with spaced path: %v", err)
|
||||
}
|
||||
if db == nil {
|
||||
t.Fatal("expected non-nil database")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadDatabaseListForConvert_MultipleFilesPathWithSpaces(t *testing.T) {
|
||||
spacedDir := filepath.Join(t.TempDir(), "my schema files")
|
||||
if err := os.MkdirAll(spacedDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file1 := filepath.Join(spacedDir, "users schema.json")
|
||||
file2 := filepath.Join(spacedDir, "posts schema.json")
|
||||
writeTestJSON(t, file1, []string{"users"})
|
||||
writeTestJSON(t, file2, []string{"posts"})
|
||||
|
||||
db, err := readDatabaseListForConvert("json", []string{file1, file2})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error with spaced paths: %v", err)
|
||||
}
|
||||
|
||||
total := 0
|
||||
for _, s := range db.Schemas {
|
||||
total += len(s.Tables)
|
||||
}
|
||||
if total != 2 {
|
||||
t.Errorf("expected 2 tables, got %d", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadDatabaseListForConvert_EmptyList(t *testing.T) {
|
||||
_, err := readDatabaseListForConvert("json", []string{})
|
||||
if err == nil {
|
||||
t.Error("expected error for empty file list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadDatabaseListForConvert_InvalidFile(t *testing.T) {
|
||||
_, err := readDatabaseListForConvert("json", []string{"/nonexistent/path/file.json"})
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConvert_FromListMutuallyExclusiveWithFromPath(t *testing.T) {
|
||||
saved := saveConvertState()
|
||||
defer restoreConvertState(saved)
|
||||
|
||||
dir := t.TempDir()
|
||||
file := filepath.Join(dir, "schema.json")
|
||||
writeTestJSON(t, file, []string{"users"})
|
||||
|
||||
convertSourceType = "json"
|
||||
convertSourcePath = file
|
||||
convertFromList = []string{file}
|
||||
convertTargetType = "json"
|
||||
convertTargetPath = filepath.Join(dir, "out.json")
|
||||
|
||||
err := runConvert(nil, nil)
|
||||
if err == nil {
|
||||
t.Error("expected error when --from-path and --from-list are both set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConvert_FromListEndToEnd(t *testing.T) {
|
||||
saved := saveConvertState()
|
||||
defer restoreConvertState(saved)
|
||||
|
||||
dir := t.TempDir()
|
||||
file1 := filepath.Join(dir, "users.json")
|
||||
file2 := filepath.Join(dir, "posts.json")
|
||||
outFile := filepath.Join(dir, "merged.json")
|
||||
writeTestJSON(t, file1, []string{"users"})
|
||||
writeTestJSON(t, file2, []string{"posts"})
|
||||
|
||||
convertSourceType = "json"
|
||||
convertSourcePath = ""
|
||||
convertSourceConn = ""
|
||||
convertFromList = []string{file1, file2}
|
||||
convertTargetType = "json"
|
||||
convertTargetPath = outFile
|
||||
convertPackageName = ""
|
||||
convertSchemaFilter = ""
|
||||
convertFlattenSchema = false
|
||||
|
||||
if err := runConvert(nil, nil); err != nil {
|
||||
t.Fatalf("runConvert() error = %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(outFile); os.IsNotExist(err) {
|
||||
t.Error("expected output file to be created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConvert_FromListEndToEndPathWithSpaces(t *testing.T) {
|
||||
saved := saveConvertState()
|
||||
defer restoreConvertState(saved)
|
||||
|
||||
spacedDir := filepath.Join(t.TempDir(), "my schema dir")
|
||||
if err := os.MkdirAll(spacedDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file1 := filepath.Join(spacedDir, "users schema.json")
|
||||
file2 := filepath.Join(spacedDir, "posts schema.json")
|
||||
outFile := filepath.Join(spacedDir, "merged output.json")
|
||||
writeTestJSON(t, file1, []string{"users"})
|
||||
writeTestJSON(t, file2, []string{"posts"})
|
||||
|
||||
convertSourceType = "json"
|
||||
convertSourcePath = ""
|
||||
convertSourceConn = ""
|
||||
convertFromList = []string{file1, file2}
|
||||
convertTargetType = "json"
|
||||
convertTargetPath = outFile
|
||||
convertPackageName = ""
|
||||
convertSchemaFilter = ""
|
||||
convertFlattenSchema = false
|
||||
|
||||
if err := runConvert(nil, nil); err != nil {
|
||||
t.Fatalf("runConvert() with spaced paths error = %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(outFile); os.IsNotExist(err) {
|
||||
t.Error("expected output file to be created")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
const fixturesDir = "../../tests/assets"
|
||||
|
||||
// readableFormats maps each file-based reader format to an existing fixture.
|
||||
var readableFormats = []struct {
|
||||
format string
|
||||
path string
|
||||
}{
|
||||
{"dbml", "dbml/simple.dbml"},
|
||||
{"json", "json/database.json"},
|
||||
{"yaml", "yaml/database.yaml"},
|
||||
{"yml", "yaml/database.yaml"},
|
||||
{"drawdb", "drawdb/simple.json"},
|
||||
{"dctx", "dctx/p1.dctx"},
|
||||
{"graphql", "graphql/simple.graphql"},
|
||||
{"gql", "graphql/simple.graphql"},
|
||||
{"prisma", "prisma/example.prisma"},
|
||||
{"typeorm", "typeorm/example.ts"},
|
||||
{"drizzle", "drizzle/schema.ts"},
|
||||
{"gorm", "gorm/simple.go"},
|
||||
{"bun", "bun/simple.go"},
|
||||
}
|
||||
|
||||
func TestReadDatabaseForConvert_FileFormats(t *testing.T) {
|
||||
for _, tt := range readableFormats {
|
||||
t.Run(tt.format, func(t *testing.T) {
|
||||
db, err := readDatabaseForConvert(tt.format, filepath.Join(fixturesDir, tt.path), "")
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if db == nil || len(db.Schemas) == 0 {
|
||||
t.Fatalf("no schemas read: %+v", db)
|
||||
}
|
||||
// Uppercase format names are accepted.
|
||||
if _, err := readDatabaseForConvert(strings.ToUpper(tt.format), filepath.Join(fixturesDir, tt.path), ""); err != nil {
|
||||
t.Errorf("uppercase format: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadDatabaseForConvert_Errors(t *testing.T) {
|
||||
filePathFormats := []string{"dbml", "dctx", "drawdb", "json", "yaml", "gorm", "bun", "drizzle", "prisma", "typeorm", "graphql"}
|
||||
for _, f := range filePathFormats {
|
||||
t.Run("missing path "+f, func(t *testing.T) {
|
||||
_, err := readDatabaseForConvert(f, "", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "file path is required") {
|
||||
t.Errorf("got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
connFormats := []string{"pgsql", "postgres", "postgresql", "mssql", "sqlserver", "mysql", "mariadb"}
|
||||
for _, f := range connFormats {
|
||||
t.Run("missing conn "+f, func(t *testing.T) {
|
||||
_, err := readDatabaseForConvert(f, "", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "connection string is required") {
|
||||
t.Errorf("got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := readDatabaseForConvert("sqlite", "", ""); err == nil || !strings.Contains(err.Error(), "required for SQLite") {
|
||||
t.Errorf("sqlite: %v", err)
|
||||
}
|
||||
if _, err := readDatabaseForConvert("nope", "x", ""); err == nil || !strings.Contains(err.Error(), "unsupported source format") {
|
||||
t.Errorf("unsupported: %v", err)
|
||||
}
|
||||
if _, err := readDatabaseForConvert("dbml", filepath.Join(t.TempDir(), "missing.dbml"), ""); err == nil || !strings.Contains(err.Error(), "failed to read database") {
|
||||
t.Errorf("missing file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadDatabase_DiffReader(t *testing.T) {
|
||||
for _, f := range []string{"dbml", "json", "yaml", "drawdb", "dctx"} {
|
||||
for _, tt := range readableFormats {
|
||||
if tt.format != f {
|
||||
continue
|
||||
}
|
||||
t.Run(f, func(t *testing.T) {
|
||||
db, err := readDatabase(f, filepath.Join(fixturesDir, tt.path), "", "source")
|
||||
if err != nil || db == nil || len(db.Schemas) == 0 {
|
||||
t.Fatalf("read: %v %+v", err, db)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, f := range []string{"dbml", "dctx", "drawdb", "json", "yaml", "sqldir"} {
|
||||
if _, err := readDatabase(f, "", "", "src"); err == nil || !strings.Contains(err.Error(), "src: file path is required") {
|
||||
t.Errorf("%s missing path: %v", f, err)
|
||||
}
|
||||
}
|
||||
if _, err := readDatabase("pgsql", "", "", "src"); err == nil || !strings.Contains(err.Error(), "connection string is required") {
|
||||
t.Errorf("pgsql: %v", err)
|
||||
}
|
||||
if _, err := readDatabase("sqlite", "", "", "src"); err == nil {
|
||||
t.Error("sqlite without path must fail")
|
||||
}
|
||||
if _, err := readDatabase("nope", "x", "", "src"); err == nil || !strings.Contains(err.Error(), "unsupported database format") {
|
||||
t.Errorf("unsupported: %v", err)
|
||||
}
|
||||
if _, err := readDatabase("json", filepath.Join(t.TempDir(), "missing.json"), "", "src"); err == nil || !strings.Contains(err.Error(), "src: failed to read database") {
|
||||
t.Errorf("missing file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskPassword(t *testing.T) {
|
||||
tests := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"", ""},
|
||||
{"postgres://user:secret@host:5432/db", "postgres://user:***@host:5432/db"},
|
||||
{"postgres://user@host:5432/db", "postgres://user@host:5432/db"},
|
||||
{"host=h user=u password=secret dbname=d", "host=h user=u password=*** dbname=d"},
|
||||
{"host=h user=u", "host=h user=u"},
|
||||
{"/tmp/file.db", "/tmp/file.db"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := maskPassword(tt.in); got != tt.want {
|
||||
t.Errorf("maskPassword(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
if got := maskPasswordInDiff(tt.in); got != tt.want {
|
||||
t.Errorf("maskPasswordInDiff(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSchemaNames(t *testing.T) {
|
||||
db := models.InitDatabase("d")
|
||||
if got := getSchemaNames(db); len(got) != 0 {
|
||||
t.Errorf("empty: %v", got)
|
||||
}
|
||||
db.Schemas = []*models.Schema{{Name: "a"}, {Name: "b"}}
|
||||
if got := strings.Join(getSchemaNames(db), ","); got != "a,b" {
|
||||
t.Errorf("got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadExtraFields(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write := func(name, body string) string {
|
||||
p := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
valid := write("valid.json", `[{"name":"extra"}]`)
|
||||
|
||||
if got, err := loadExtraFields("bun", valid); err != nil || !strings.Contains(got, "extra") {
|
||||
t.Errorf("valid: %q %v", got, err)
|
||||
}
|
||||
if _, err := loadExtraFields("BUN", valid); err != nil {
|
||||
t.Errorf("case-insensitive format: %v", err)
|
||||
}
|
||||
if _, err := loadExtraFields("gorm", valid); err == nil || !strings.Contains(err.Error(), "only supported for Bun") {
|
||||
t.Errorf("non-bun: %v", err)
|
||||
}
|
||||
if _, err := loadExtraFields("bun", filepath.Join(dir, "missing.json")); err == nil || !strings.Contains(err.Error(), "failed to read") {
|
||||
t.Errorf("missing: %v", err)
|
||||
}
|
||||
if _, err := loadExtraFields("bun", write("bad.json", `{not json`)); err == nil || !strings.Contains(err.Error(), "invalid --extra-fields JSON") {
|
||||
t.Errorf("bad json: %v", err)
|
||||
}
|
||||
if _, err := loadExtraFields("bun", write("empty.json", `[]`)); err == nil || !strings.Contains(err.Error(), "at least one field") {
|
||||
t.Errorf("empty: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func multiSchemaDB() *models.Database {
|
||||
db := models.InitDatabase("multi")
|
||||
for _, n := range []string{"a", "b"} {
|
||||
s := models.InitSchema(n)
|
||||
tbl := models.InitTable("t_"+n, n)
|
||||
c := models.InitColumn("id", tbl.Name, n)
|
||||
c.Type = "integer"
|
||||
c.IsPrimaryKey = true
|
||||
tbl.Columns["id"] = c
|
||||
s.Tables = append(s.Tables, tbl)
|
||||
db.Schemas = append(db.Schemas, s)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestValidateWriteTarget(t *testing.T) {
|
||||
db := multiSchemaDB()
|
||||
single := models.InitDatabase("single")
|
||||
single.Schemas = []*models.Schema{models.InitSchema("only")}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
db *models.Database
|
||||
dbType, pkg, schemaFilter, extraFields, wantErrSubstr string
|
||||
}{
|
||||
{"json ok", db, "json", "", "", "", ""},
|
||||
{"pgsql alias ok", db, "sql", "", "", "", ""},
|
||||
{"gorm needs package", db, "gorm", "", "", "", "package name is required"},
|
||||
{"bun needs package", db, "bun", "", "", "", "package name is required"},
|
||||
{"gorm with package", db, "gorm", "models", "", "", ""},
|
||||
{"unsupported", db, "nope", "", "", "", "unsupported target format"},
|
||||
{"schema filter found", db, "json", "", "a", "", ""},
|
||||
{"schema filter missing", db, "json", "", "zzz", "", "not found in database"},
|
||||
{"dctx multi schema", db, "dctx", "", "", "", "multiple schemas found"},
|
||||
{"dctx multi schema with filter", db, "dctx", "", "a", "", ""},
|
||||
{"dctx single schema", single, "dctx", "", "", "", ""},
|
||||
{"dctx no schemas", models.InitDatabase("e"), "dctx", "", "", "", "no schemas found"},
|
||||
{"extra fields non-bun", db, "json", "", "", "x.json", "only supported for Bun"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateWriteTarget(tt.db, tt.dbType, tt.pkg, tt.schemaFilter, tt.extraFields)
|
||||
if tt.wantErrSubstr == "" {
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErrSubstr) {
|
||||
t.Errorf("got %v, want substring %q", err, tt.wantErrSubstr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteDatabase_Formats(t *testing.T) {
|
||||
db := multiSchemaDB()
|
||||
formats := []struct{ format, file string }{
|
||||
{"json", "out.json"},
|
||||
{"yaml", "out.yaml"},
|
||||
{"yml", "out.yml"},
|
||||
{"dbml", "out.dbml"},
|
||||
{"drawdb", "out.drawdb.json"},
|
||||
{"pgsql", "out.sql"},
|
||||
{"postgres", "out2.sql"},
|
||||
{"sql", "out3.sql"},
|
||||
{"mssql", "out_ms.sql"},
|
||||
{"mysql", "out_my.sql"},
|
||||
{"sqlite", "out_lite.sql"},
|
||||
{"graphql", "out.graphql"},
|
||||
{"gql", "out2.graphql"},
|
||||
{"prisma", "out.prisma"},
|
||||
{"typeorm", "out.ts"},
|
||||
{"drizzle", "out_drizzle.ts"},
|
||||
}
|
||||
for _, tt := range formats {
|
||||
t.Run(tt.format, func(t *testing.T) {
|
||||
out := filepath.Join(t.TempDir(), tt.file)
|
||||
if err := writeDatabase(db, tt.format, out, "", "", false, "", "", false, ""); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
info, err := os.Stat(out)
|
||||
if err != nil || info.Size() == 0 {
|
||||
t.Errorf("output missing or empty: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteDatabase_GoFormatsWriteIntoDir(t *testing.T) {
|
||||
db := multiSchemaDB()
|
||||
for _, f := range []string{"gorm", "bun"} {
|
||||
t.Run(f, func(t *testing.T) {
|
||||
out := filepath.Join(t.TempDir(), "models.go")
|
||||
if err := writeDatabase(db, f, out, "models", "", false, "", "", false, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(out); err != nil {
|
||||
t.Errorf("no output: %v", err)
|
||||
}
|
||||
if err := writeDatabase(db, f, out, "", "", false, "", "", false, ""); err == nil || !strings.Contains(err.Error(), "package name is required") {
|
||||
t.Errorf("missing package: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteDatabase_SchemaFilterAndDCTX(t *testing.T) {
|
||||
db := multiSchemaDB()
|
||||
out := filepath.Join(t.TempDir(), "o.json")
|
||||
|
||||
if err := writeDatabase(db, "json", out, "", "a", false, "", "", false, ""); err != nil {
|
||||
t.Errorf("schema filter: %v", err)
|
||||
}
|
||||
if err := writeDatabase(db, "json", out, "", "zzz", false, "", "", false, ""); err == nil || !strings.Contains(err.Error(), "not found in database") {
|
||||
t.Errorf("missing schema: %v", err)
|
||||
}
|
||||
if err := writeDatabase(db, "dctx", out, "", "", false, "", "", false, ""); err == nil || !strings.Contains(err.Error(), "multiple schemas found") {
|
||||
t.Errorf("dctx multi: %v", err)
|
||||
}
|
||||
single := models.InitDatabase("s")
|
||||
single.Schemas = []*models.Schema{db.Schemas[0]}
|
||||
if err := writeDatabase(single, "dctx", filepath.Join(t.TempDir(), "o.dctx"), "", "", false, "", "", false, ""); err != nil {
|
||||
t.Errorf("dctx single: %v", err)
|
||||
}
|
||||
if err := writeDatabase(models.InitDatabase("e"), "dctx", out, "", "", false, "", "", false, ""); err == nil || !strings.Contains(err.Error(), "no schemas found") {
|
||||
t.Errorf("dctx empty: %v", err)
|
||||
}
|
||||
if err := writeDatabase(db, "nope", out, "", "", false, "", "", false, ""); err == nil || !strings.Contains(err.Error(), "unsupported target format") {
|
||||
t.Errorf("unsupported: %v", err)
|
||||
}
|
||||
if err := writeDatabase(db, "json", out, "", "", false, "", "", false, filepath.Join(t.TempDir(), "x.json")); err == nil || !strings.Contains(err.Error(), "only supported for Bun") {
|
||||
t.Errorf("extra fields with json: %v", err)
|
||||
}
|
||||
}
|
||||
+35
-11
@@ -16,6 +16,8 @@ import (
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/drawdb"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/json"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/pgsql"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/sqldir"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/sqlite"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/yaml"
|
||||
)
|
||||
|
||||
@@ -86,11 +88,11 @@ Examples:
|
||||
}
|
||||
|
||||
func init() {
|
||||
diffCmd.Flags().StringVar(&sourceType, "from", "", "Source database format (dbml, dctx, drawdb, json, yaml, pgsql)")
|
||||
diffCmd.Flags().StringVar(&sourceType, "from", "", "Source database format (dbml, dctx, drawdb, json, yaml, pgsql, sqldir)")
|
||||
diffCmd.Flags().StringVar(&sourcePath, "from-path", "", "Source file path (for file-based formats)")
|
||||
diffCmd.Flags().StringVar(&sourceConn, "from-conn", "", "Source connection string (for database formats)")
|
||||
|
||||
diffCmd.Flags().StringVar(&targetType, "to", "", "Target database format (dbml, dctx, drawdb, json, yaml, pgsql)")
|
||||
diffCmd.Flags().StringVar(&targetType, "to", "", "Target database format (dbml, dctx, drawdb, json, yaml, pgsql, sqldir)")
|
||||
diffCmd.Flags().StringVar(&targetPath, "to-path", "", "Target file path (for file-based formats)")
|
||||
diffCmd.Flags().StringVar(&targetConn, "to-conn", "", "Target connection string (for database formats)")
|
||||
|
||||
@@ -128,10 +130,12 @@ func runDiff(cmd *cobra.Command, args []string) error {
|
||||
|
||||
fmt.Fprintf(os.Stderr, " ✓ Successfully read database '%s'\n", sourceDB.Name)
|
||||
sourceTables := 0
|
||||
sourceScripts := 0
|
||||
for _, schema := range sourceDB.Schemas {
|
||||
sourceTables += len(schema.Tables)
|
||||
sourceScripts += len(schema.Scripts)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, " Found: %d schema(s), %d table(s)\n\n", len(sourceDB.Schemas), sourceTables)
|
||||
fmt.Fprintf(os.Stderr, " Found: %d schema(s), %d table(s), %d script(s)\n\n", len(sourceDB.Schemas), sourceTables, sourceScripts)
|
||||
|
||||
// Read target database
|
||||
fmt.Fprintf(os.Stderr, "[2/3] Reading target schema...\n")
|
||||
@@ -150,10 +154,12 @@ func runDiff(cmd *cobra.Command, args []string) error {
|
||||
|
||||
fmt.Fprintf(os.Stderr, " ✓ Successfully read database '%s'\n", targetDB.Name)
|
||||
targetTables := 0
|
||||
targetScripts := 0
|
||||
for _, schema := range targetDB.Schemas {
|
||||
targetTables += len(schema.Tables)
|
||||
targetScripts += len(schema.Scripts)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, " Found: %d schema(s), %d table(s)\n\n", len(targetDB.Schemas), targetTables)
|
||||
fmt.Fprintf(os.Stderr, " Found: %d schema(s), %d table(s), %d script(s)\n\n", len(targetDB.Schemas), targetTables, targetScripts)
|
||||
|
||||
// Compare databases
|
||||
fmt.Fprintf(os.Stderr, "[3/3] Comparing schemas...\n")
|
||||
@@ -164,7 +170,8 @@ func runDiff(cmd *cobra.Command, args []string) error {
|
||||
summary.Tables.Missing + summary.Tables.Extra + summary.Tables.Modified +
|
||||
summary.Columns.Missing + summary.Columns.Extra + summary.Columns.Modified +
|
||||
summary.Indexes.Missing + summary.Indexes.Extra + summary.Indexes.Modified +
|
||||
summary.Constraints.Missing + summary.Constraints.Extra + summary.Constraints.Modified
|
||||
summary.Constraints.Missing + summary.Constraints.Extra + summary.Constraints.Modified +
|
||||
summary.Scripts.Missing + summary.Scripts.Extra + summary.Scripts.Modified
|
||||
|
||||
fmt.Fprintf(os.Stderr, " ✓ Comparison complete\n")
|
||||
fmt.Fprintf(os.Stderr, " Found: %d difference(s)\n\n", totalDiffs)
|
||||
@@ -222,37 +229,54 @@ func readDatabase(dbType, filePath, connString, label string) (*models.Database,
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for DBML format", label)
|
||||
}
|
||||
reader = dbml.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = dbml.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "dctx":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for DCTX format", label)
|
||||
}
|
||||
reader = dctx.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = dctx.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "drawdb":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for DrawDB format", label)
|
||||
}
|
||||
reader = drawdb.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = drawdb.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "json":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for JSON format", label)
|
||||
}
|
||||
reader = json.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = json.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "yaml":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for YAML format", label)
|
||||
}
|
||||
reader = yaml.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = yaml.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "sqldir", "scripts", "scriptdir":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for SQL directory format", label)
|
||||
}
|
||||
reader = sqldir.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "pgsql", "postgres", "postgresql":
|
||||
if connString == "" {
|
||||
return nil, fmt.Errorf("%s: connection string is required for PostgreSQL format", label)
|
||||
}
|
||||
reader = pgsql.NewReader(&readers.ReaderOptions{ConnectionString: connString})
|
||||
reader = pgsql.NewReader(newReaderOptions("", connString))
|
||||
|
||||
case "sqlite", "sqlite3":
|
||||
// SQLite can use either file path or connection string
|
||||
dbPath := filePath
|
||||
if dbPath == "" {
|
||||
dbPath = connString
|
||||
}
|
||||
if dbPath == "" {
|
||||
return nil, fmt.Errorf("%s: file path or connection string is required for SQLite format", label)
|
||||
}
|
||||
reader = sqlite.NewReader(newReaderOptions(dbPath, ""))
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("%s: unsupported database format: %s", label, dbType)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadDatabaseSupportsSQLDir(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(tempDir, "1_001_create_users.sql"), []byte("CREATE TABLE users (id int);"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tempDir, "1_002_seed_users.pgsql"), []byte("INSERT INTO users (id) VALUES (1);"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
db, err := readDatabase("sqldir", tempDir, "", "source")
|
||||
if err != nil {
|
||||
t.Fatalf("readDatabase failed: %v", err)
|
||||
}
|
||||
if len(db.Schemas) != 1 {
|
||||
t.Fatalf("expected 1 schema, got %d", len(db.Schemas))
|
||||
}
|
||||
if got := len(db.Schemas[0].Scripts); got != 2 {
|
||||
t.Fatalf("expected 2 scripts, got %d", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newDryRunCmd() (*cobra.Command, *bytes.Buffer) {
|
||||
var buf bytes.Buffer
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetOut(&buf)
|
||||
return cmd, &buf
|
||||
}
|
||||
|
||||
func TestRunConvert_DryRunWritesNothing(t *testing.T) {
|
||||
defer func(a, b, c, d string, e bool) {
|
||||
convertSourceType, convertSourcePath, convertTargetType, convertTargetPath, convertDryRun = a, b, c, d, e
|
||||
}(convertSourceType, convertSourcePath, convertTargetType, convertTargetPath, convertDryRun)
|
||||
|
||||
dir := t.TempDir()
|
||||
in := filepath.Join(dir, "in.json")
|
||||
out := filepath.Join(dir, "out.json")
|
||||
writeTestJSON(t, in, []string{"users", "posts"})
|
||||
|
||||
convertSourceType, convertSourcePath = "json", in
|
||||
convertTargetType, convertTargetPath = "json", out
|
||||
convertDryRun = true
|
||||
|
||||
cmd, buf := newDryRunCmd()
|
||||
if err := runConvert(cmd, nil); err != nil {
|
||||
t.Fatalf("dry run: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(out); !os.IsNotExist(err) {
|
||||
t.Fatal("dry run must not create the output file")
|
||||
}
|
||||
for _, want := range []string{"dry run", "users", "posts", out} {
|
||||
if !strings.Contains(buf.String(), want) {
|
||||
t.Errorf("plan missing %q:\n%s", want, buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Normal behavior is unchanged.
|
||||
convertDryRun = false
|
||||
if err := runConvert(cmd, nil); err != nil {
|
||||
t.Fatalf("real run: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(out); err != nil {
|
||||
t.Fatalf("real run should write output: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConvert_DryRunValidatesTarget(t *testing.T) {
|
||||
defer func(a, b, c, d string, e bool) {
|
||||
convertSourceType, convertSourcePath, convertTargetType, convertTargetPath, convertDryRun = a, b, c, d, e
|
||||
}(convertSourceType, convertSourcePath, convertTargetType, convertTargetPath, convertDryRun)
|
||||
|
||||
dir := t.TempDir()
|
||||
in := filepath.Join(dir, "in.json")
|
||||
out := filepath.Join(dir, "models")
|
||||
writeTestJSON(t, in, []string{"users"})
|
||||
|
||||
convertSourceType, convertSourcePath = "json", in
|
||||
convertDryRun = true
|
||||
|
||||
// gorm without --package must fail validation, as a real run would.
|
||||
convertTargetType, convertTargetPath = "gorm", out
|
||||
cmd, _ := newDryRunCmd()
|
||||
err := runConvert(cmd, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "package name is required") {
|
||||
t.Fatalf("expected package validation error, got %v", err)
|
||||
}
|
||||
|
||||
convertTargetType = "nope"
|
||||
err = runConvert(cmd, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "unsupported target format") {
|
||||
t.Fatalf("expected unsupported format error, got %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(out); !os.IsNotExist(statErr) {
|
||||
t.Fatal("dry run must not create the output path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSplit_DryRunWritesNothing(t *testing.T) {
|
||||
defer func(a, b, c, d, e string, f bool) {
|
||||
splitSourceType, splitSourcePath, splitTargetType, splitTargetPath, splitTables, splitDryRun = a, b, c, d, e, f
|
||||
}(splitSourceType, splitSourcePath, splitTargetType, splitTargetPath, splitTables, splitDryRun)
|
||||
|
||||
dir := t.TempDir()
|
||||
in := filepath.Join(dir, "in.json")
|
||||
out := filepath.Join(dir, "subset.json")
|
||||
writeTestJSON(t, in, []string{"users", "posts", "comments"})
|
||||
|
||||
splitSourceType, splitSourcePath = "json", in
|
||||
splitTargetType, splitTargetPath = "json", out
|
||||
splitTables = "users,posts"
|
||||
splitDryRun = true
|
||||
|
||||
cmd, buf := newDryRunCmd()
|
||||
if err := runSplit(cmd, nil); err != nil {
|
||||
t.Fatalf("dry run: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(out); !os.IsNotExist(err) {
|
||||
t.Fatal("dry run must not create the output file")
|
||||
}
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, "2 table(s)") || strings.Contains(got, "comments") {
|
||||
t.Errorf("plan should show only the 2 selected tables:\n%s", got)
|
||||
}
|
||||
|
||||
// A selection that matches nothing fails validation in dry-run too.
|
||||
splitTables = "does_not_exist"
|
||||
if err := runSplit(cmd, nil); err == nil {
|
||||
t.Fatal("expected error for empty selection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMerge_DryRunWritesNothing(t *testing.T) {
|
||||
saved := saveMergeState()
|
||||
defer restoreMergeState(saved)
|
||||
defer func(v bool) { mergeDryRun = v }(mergeDryRun)
|
||||
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "target.json")
|
||||
source := filepath.Join(dir, "source.json")
|
||||
out := filepath.Join(dir, "merged.json")
|
||||
writeTestJSON(t, target, []string{"users"})
|
||||
writeTestJSON(t, source, []string{"posts"})
|
||||
|
||||
mergeTargetType, mergeTargetPath, mergeTargetConn = "json", target, ""
|
||||
mergeSourceType, mergeSourcePath, mergeSourceConn = "json", source, ""
|
||||
mergeFromList = nil
|
||||
mergeOutputType, mergeOutputPath, mergeOutputConn = "json", out, ""
|
||||
mergeSkipTables, mergeReportPath = "", ""
|
||||
mergeDryRun = true
|
||||
|
||||
cmd, buf := newDryRunCmd()
|
||||
if err := runMerge(cmd, nil); err != nil {
|
||||
t.Fatalf("dry run: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(out); !os.IsNotExist(err) {
|
||||
t.Fatal("dry run must not create the output file")
|
||||
}
|
||||
for _, want := range []string{"dry run", "users", "posts", out} {
|
||||
if !strings.Contains(buf.String(), want) {
|
||||
t.Errorf("plan missing %q:\n%s", want, buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
mergeOutputType = "nope"
|
||||
if err := runMerge(cmd, nil); err == nil || !strings.Contains(err.Error(), "unsupported format") {
|
||||
t.Fatalf("expected unsupported output format error, got %v", err)
|
||||
}
|
||||
}
|
||||
+61
-34
@@ -19,6 +19,7 @@ import (
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/json"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/pgsql"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/prisma"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/sqlite"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/typeorm"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/yaml"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/ui"
|
||||
@@ -33,6 +34,7 @@ import (
|
||||
wjson "git.warky.dev/wdevs/relspecgo/pkg/writers/json"
|
||||
wpgsql "git.warky.dev/wdevs/relspecgo/pkg/writers/pgsql"
|
||||
wprisma "git.warky.dev/wdevs/relspecgo/pkg/writers/prisma"
|
||||
wsqlite "git.warky.dev/wdevs/relspecgo/pkg/writers/sqlite"
|
||||
wtypeorm "git.warky.dev/wdevs/relspecgo/pkg/writers/typeorm"
|
||||
wyaml "git.warky.dev/wdevs/relspecgo/pkg/writers/yaml"
|
||||
)
|
||||
@@ -73,6 +75,7 @@ Supports reading from and writing to all supported formats:
|
||||
- prisma: Prisma schema files (.prisma)
|
||||
- typeorm: TypeORM entity files (TypeScript)
|
||||
- pgsql: PostgreSQL database (live connection)
|
||||
- sqlite: SQLite database file
|
||||
|
||||
Output formats:
|
||||
- dbml: DBML schema files
|
||||
@@ -87,13 +90,19 @@ Supports reading from and writing to all supported formats:
|
||||
- prisma: Prisma schema files (.prisma)
|
||||
- typeorm: TypeORM entity files (TypeScript)
|
||||
- pgsql: PostgreSQL SQL schema
|
||||
- sqlite: SQLite SQL schema (with automatic schema flattening)
|
||||
|
||||
PostgreSQL Connection String Examples:
|
||||
postgres://username:password@localhost:5432/database_name
|
||||
postgres://username:password@localhost/database_name
|
||||
postgresql://user:pass@host:5432/dbname?sslmode=disable
|
||||
postgresql://user:pass@host/dbname?sslmode=require
|
||||
host=localhost port=5432 user=username password=pass dbname=mydb sslmode=disable
|
||||
Connection String Examples:
|
||||
PostgreSQL:
|
||||
postgres://username:password@localhost:5432/database_name
|
||||
postgres://username:password@localhost/database_name
|
||||
postgresql://user:pass@host:5432/dbname?sslmode=disable
|
||||
postgresql://user:pass@host/dbname?sslmode=require
|
||||
host=localhost port=5432 user=username password=pass dbname=mydb sslmode=disable
|
||||
SQLite:
|
||||
/path/to/database.db
|
||||
./relative/path/database.sqlite
|
||||
database.db
|
||||
|
||||
Examples:
|
||||
# Edit a DBML schema file
|
||||
@@ -107,15 +116,21 @@ Examples:
|
||||
relspec edit --from json --from-path db.json --to gorm --to-path models/
|
||||
|
||||
# Edit GORM models in place
|
||||
relspec edit --from gorm --from-path ./models --to gorm --to-path ./models`,
|
||||
relspec edit --from gorm --from-path ./models --to gorm --to-path ./models
|
||||
|
||||
# Edit SQLite database
|
||||
relspec edit --from sqlite --from-path database.db --to sqlite --to-path database.db
|
||||
|
||||
# Convert SQLite to DBML
|
||||
relspec edit --from sqlite --from-path database.db --to dbml --to-path schema.dbml`,
|
||||
RunE: runEdit,
|
||||
}
|
||||
|
||||
func init() {
|
||||
editCmd.Flags().StringVar(&editSourceType, "from", "", "Source format (dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, pgsql)")
|
||||
editCmd.Flags().StringVar(&editSourceType, "from", "", "Source format (dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, pgsql, sqlite)")
|
||||
editCmd.Flags().StringVar(&editSourcePath, "from-path", "", "Source file path (for file-based formats)")
|
||||
editCmd.Flags().StringVar(&editSourceConn, "from-conn", "", "Source connection string (for database formats)")
|
||||
editCmd.Flags().StringVar(&editTargetType, "to", "", "Target format (dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, pgsql)")
|
||||
editCmd.Flags().StringVar(&editSourceConn, "from-conn", "", "Source connection string (for pgsql) or file path (for sqlite)")
|
||||
editCmd.Flags().StringVar(&editTargetType, "to", "", "Target format (dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, pgsql, sqlite)")
|
||||
editCmd.Flags().StringVar(&editTargetPath, "to-path", "", "Target file path (for file-based formats)")
|
||||
editCmd.Flags().StringVar(&editSchemaFilter, "schema", "", "Filter to a specific schema by name")
|
||||
|
||||
@@ -225,62 +240,72 @@ func readDatabaseForEdit(dbType, filePath, connString, label string) (*models.Da
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for DBML format", label)
|
||||
}
|
||||
reader = dbml.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = dbml.NewReader(newReaderOptions(filePath, ""))
|
||||
case "dctx":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for DCTX format", label)
|
||||
}
|
||||
reader = dctx.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = dctx.NewReader(newReaderOptions(filePath, ""))
|
||||
case "drawdb":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for DrawDB format", label)
|
||||
}
|
||||
reader = drawdb.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = drawdb.NewReader(newReaderOptions(filePath, ""))
|
||||
case "graphql":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for GraphQL format", label)
|
||||
}
|
||||
reader = graphql.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = graphql.NewReader(newReaderOptions(filePath, ""))
|
||||
case "json":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for JSON format", label)
|
||||
}
|
||||
reader = json.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = json.NewReader(newReaderOptions(filePath, ""))
|
||||
case "yaml":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for YAML format", label)
|
||||
}
|
||||
reader = yaml.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = yaml.NewReader(newReaderOptions(filePath, ""))
|
||||
case "gorm":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for GORM format", label)
|
||||
}
|
||||
reader = gorm.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = gorm.NewReader(newReaderOptions(filePath, ""))
|
||||
case "bun":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for Bun format", label)
|
||||
}
|
||||
reader = bun.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = bun.NewReader(newReaderOptions(filePath, ""))
|
||||
case "drizzle":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for Drizzle format", label)
|
||||
}
|
||||
reader = drizzle.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = drizzle.NewReader(newReaderOptions(filePath, ""))
|
||||
case "prisma":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for Prisma format", label)
|
||||
}
|
||||
reader = prisma.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = prisma.NewReader(newReaderOptions(filePath, ""))
|
||||
case "typeorm":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for TypeORM format", label)
|
||||
}
|
||||
reader = typeorm.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = typeorm.NewReader(newReaderOptions(filePath, ""))
|
||||
case "pgsql":
|
||||
if connString == "" {
|
||||
return nil, fmt.Errorf("%s: connection string is required for PostgreSQL format", label)
|
||||
}
|
||||
reader = pgsql.NewReader(&readers.ReaderOptions{ConnectionString: connString})
|
||||
reader = pgsql.NewReader(newReaderOptions("", connString))
|
||||
case "sqlite", "sqlite3":
|
||||
// SQLite can use either file path or connection string
|
||||
dbPath := filePath
|
||||
if dbPath == "" {
|
||||
dbPath = connString
|
||||
}
|
||||
if dbPath == "" {
|
||||
return nil, fmt.Errorf("%s: file path or connection string is required for SQLite format", label)
|
||||
}
|
||||
reader = sqlite.NewReader(newReaderOptions(dbPath, ""))
|
||||
default:
|
||||
return nil, fmt.Errorf("%s: unsupported format: %s", label, dbType)
|
||||
}
|
||||
@@ -298,29 +323,31 @@ func writeDatabaseForEdit(dbType, filePath, connString string, db *models.Databa
|
||||
|
||||
switch strings.ToLower(dbType) {
|
||||
case "dbml":
|
||||
writer = wdbml.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
writer = wdbml.NewWriter(newWriterOptions(filePath, "", false, "", "", false))
|
||||
case "dctx":
|
||||
writer = wdctx.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
writer = wdctx.NewWriter(newWriterOptions(filePath, "", false, "", "", false))
|
||||
case "drawdb":
|
||||
writer = wdrawdb.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
writer = wdrawdb.NewWriter(newWriterOptions(filePath, "", false, "", "", false))
|
||||
case "graphql":
|
||||
writer = wgraphql.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
writer = wgraphql.NewWriter(newWriterOptions(filePath, "", false, "", "", false))
|
||||
case "json":
|
||||
writer = wjson.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
writer = wjson.NewWriter(newWriterOptions(filePath, "", false, "", "", false))
|
||||
case "yaml":
|
||||
writer = wyaml.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
writer = wyaml.NewWriter(newWriterOptions(filePath, "", false, "", "", false))
|
||||
case "gorm":
|
||||
writer = wgorm.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
writer = wgorm.NewWriter(newWriterOptions(filePath, "", false, "", "", false))
|
||||
case "bun":
|
||||
writer = wbun.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
writer = wbun.NewWriter(newWriterOptions(filePath, "", false, "", "", false))
|
||||
case "drizzle":
|
||||
writer = wdrizzle.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
writer = wdrizzle.NewWriter(newWriterOptions(filePath, "", false, "", "", false))
|
||||
case "prisma":
|
||||
writer = wprisma.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
writer = wprisma.NewWriter(newWriterOptions(filePath, "", false, "", "", false))
|
||||
case "typeorm":
|
||||
writer = wtypeorm.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
writer = wtypeorm.NewWriter(newWriterOptions(filePath, "", false, "", "", false))
|
||||
case "sqlite", "sqlite3":
|
||||
writer = wsqlite.NewWriter(newWriterOptions(filePath, "", false, "", "", false))
|
||||
case "pgsql":
|
||||
writer = wpgsql.NewWriter(&writers.WriterOptions{OutputPath: filePath})
|
||||
writer = wpgsql.NewWriter(newWriterOptions(filePath, "", false, "", "", false))
|
||||
default:
|
||||
return fmt.Errorf("%s: unsupported format: %s", label, dbType)
|
||||
}
|
||||
|
||||
+25
-13
@@ -20,6 +20,7 @@ import (
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/json"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/pgsql"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/prisma"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/sqlite"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/typeorm"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/yaml"
|
||||
)
|
||||
@@ -192,7 +193,7 @@ func runInspect(cmd *cobra.Command, args []string) error {
|
||||
|
||||
// Write output
|
||||
if inspectOutputPath != "" {
|
||||
err = os.WriteFile(inspectOutputPath, []byte(formattedReport), 0644)
|
||||
err = os.WriteFile(inspectOutputPath, []byte(formattedReport), 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write output file: %w", err)
|
||||
}
|
||||
@@ -220,73 +221,84 @@ func readDatabaseForInspect(dbType, filePath, connString string) (*models.Databa
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for DBML format")
|
||||
}
|
||||
reader = dbml.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = dbml.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "dctx":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for DCTX format")
|
||||
}
|
||||
reader = dctx.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = dctx.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "drawdb":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for DrawDB format")
|
||||
}
|
||||
reader = drawdb.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = drawdb.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "graphql":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for GraphQL format")
|
||||
}
|
||||
reader = graphql.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = graphql.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "json":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for JSON format")
|
||||
}
|
||||
reader = json.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = json.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "yaml", "yml":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for YAML format")
|
||||
}
|
||||
reader = yaml.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = yaml.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "gorm":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for GORM format")
|
||||
}
|
||||
reader = gorm.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = gorm.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "bun":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for Bun format")
|
||||
}
|
||||
reader = bun.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = bun.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "drizzle":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for Drizzle format")
|
||||
}
|
||||
reader = drizzle.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = drizzle.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "prisma":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for Prisma format")
|
||||
}
|
||||
reader = prisma.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = prisma.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "typeorm":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for TypeORM format")
|
||||
}
|
||||
reader = typeorm.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = typeorm.NewReader(newReaderOptions(filePath, ""))
|
||||
|
||||
case "pgsql", "postgres", "postgresql":
|
||||
if connString == "" {
|
||||
return nil, fmt.Errorf("connection string is required for PostgreSQL format")
|
||||
}
|
||||
reader = pgsql.NewReader(&readers.ReaderOptions{ConnectionString: connString})
|
||||
reader = pgsql.NewReader(newReaderOptions("", connString))
|
||||
|
||||
case "sqlite", "sqlite3":
|
||||
// SQLite can use either file path or connection string
|
||||
dbPath := filePath
|
||||
if dbPath == "" {
|
||||
dbPath = connString
|
||||
}
|
||||
if dbPath == "" {
|
||||
return nil, fmt.Errorf("file path or connection string is required for SQLite format")
|
||||
}
|
||||
reader = sqlite.NewReader(newReaderOptions(dbPath, ""))
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported database type: %s", dbType)
|
||||
|
||||
@@ -0,0 +1,973 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/diff"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/inspector"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/jobs"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/merge"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/sqldir"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
wpgsql "git.warky.dev/wdevs/relspecgo/pkg/writers/pgsql"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers/sqlexec"
|
||||
wtemplate "git.warky.dev/wdevs/relspecgo/pkg/writers/template"
|
||||
)
|
||||
|
||||
var (
|
||||
jobDir string
|
||||
jobFiles []string
|
||||
jobDryRun bool
|
||||
jobNoDeps bool
|
||||
)
|
||||
|
||||
var jobCmd = &cobra.Command{
|
||||
Use: "job",
|
||||
Short: "Run declarative RelSpec jobs from job files",
|
||||
Long: `Run named jobs declared in job files instead of repeating command-line arguments.
|
||||
|
||||
A job file is a YAML manifest (relspec.yml, or relspec.<name>.yml for extra
|
||||
files) describing one or more jobs. Each job names a RelSpec command plus its
|
||||
inputs, output and options:
|
||||
|
||||
version: 1
|
||||
jobs:
|
||||
build-schema:
|
||||
command: convert
|
||||
description: Merge the DBML sources and emit PostgreSQL DDL
|
||||
inputs:
|
||||
- path: schema/core.dbml
|
||||
format: dbml
|
||||
- path: schema/tenant.dbml
|
||||
format: dbml
|
||||
output:
|
||||
format: pgsql
|
||||
path: build/schema.sql
|
||||
overwrite: true
|
||||
options:
|
||||
flatten_schema: false
|
||||
logfile: .relspec/log/build-schema.log
|
||||
|
||||
Rules and guarantees:
|
||||
- command is a closed allow-list (convert, merge, split, scripts-list,
|
||||
scripts-exec, templ, inspect, diff). Arbitrary shell strings are never
|
||||
executed.
|
||||
- Every path is relative to the directory holding the job file and may not
|
||||
escape it. Absolute and home-relative paths are rejected.
|
||||
- Remote database credentials are referenced by environment-variable name
|
||||
via conn_env; connection strings are never stored in the manifest and are
|
||||
redacted from logs and diagnostics.
|
||||
- Discovery and listing are deterministic.
|
||||
- The whole plan is validated - unknown commands/formats, duplicate job
|
||||
names, missing inputs, path traversal, dependency cycles - before any job
|
||||
runs. Nothing is read, written or executed when validation fails.
|
||||
- A failed job propagates the underlying non-zero exit status and writes no
|
||||
success marker.`,
|
||||
}
|
||||
|
||||
var jobListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List jobs discovered in job files (deterministic order)",
|
||||
RunE: runJobList,
|
||||
}
|
||||
|
||||
var jobRunCmd = &cobra.Command{
|
||||
Use: "run <job-name>",
|
||||
Short: "Run a named job (and its dependencies) from a job file",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runJobRun,
|
||||
}
|
||||
|
||||
func init() {
|
||||
for _, c := range []*cobra.Command{jobListCmd, jobRunCmd} {
|
||||
c.Flags().StringVar(&jobDir, "dir", ".", "Directory to discover job files in")
|
||||
c.Flags().StringSliceVar(&jobFiles, "file", nil, "Explicit job file(s) to load (repeatable); disables discovery")
|
||||
}
|
||||
jobRunCmd.Flags().BoolVar(&jobDryRun, "dry-run", false, "Validate and print the execution plan without running anything")
|
||||
jobRunCmd.Flags().BoolVar(&jobDryRun, "plan", false, "Alias for --dry-run")
|
||||
jobRunCmd.Flags().BoolVar(&jobNoDeps, "no-deps", false, "Run only the named job, skipping its declared dependencies")
|
||||
|
||||
jobCmd.AddCommand(jobListCmd)
|
||||
jobCmd.AddCommand(jobRunCmd)
|
||||
}
|
||||
|
||||
// loadJobSet discovers or loads the requested job files and runs full
|
||||
// validation. The returned Set is safe to plan and execute.
|
||||
func loadJobSet() (*jobs.Set, error) {
|
||||
paths := jobFiles
|
||||
if len(paths) == 0 {
|
||||
discovered, err := jobs.Discover(jobDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
paths = discovered
|
||||
} else {
|
||||
for i, p := range paths {
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
return nil, fmt.Errorf("job file %q: %w", p, err)
|
||||
}
|
||||
paths[i] = p
|
||||
}
|
||||
}
|
||||
set, err := jobs.Load(paths)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := set.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, w := range set.Warnings {
|
||||
fmt.Fprintf(os.Stderr, "warning: %s\n", w)
|
||||
}
|
||||
return set, nil
|
||||
}
|
||||
|
||||
func runJobList(cmd *cobra.Command, args []string) error {
|
||||
set, err := loadJobSet()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := cmd.OutOrStdout()
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\n=== RelSpec Jobs ===\n")
|
||||
fmt.Fprintf(os.Stderr, "Job files:\n")
|
||||
for _, f := range set.Files {
|
||||
fmt.Fprintf(os.Stderr, " - %s\n", f)
|
||||
}
|
||||
fmt.Fprintln(os.Stderr)
|
||||
|
||||
names := set.Names()
|
||||
if len(names) == 0 {
|
||||
fmt.Fprintln(out, "(no jobs defined)")
|
||||
return nil
|
||||
}
|
||||
|
||||
nameW, cmdW, srcW := len("NAME"), len("COMMAND"), len("SOURCE")
|
||||
for _, n := range names {
|
||||
j := set.Jobs[n]
|
||||
nameW = maxInt(nameW, len(n))
|
||||
cmdW = maxInt(cmdW, len(j.Command))
|
||||
srcW = maxInt(srcW, len(j.SourceFile))
|
||||
}
|
||||
fmt.Fprintf(out, "%-*s %-*s %-*s %s\n", nameW, "NAME", cmdW, "COMMAND", srcW, "SOURCE", "DESCRIPTION")
|
||||
for _, n := range names {
|
||||
j := set.Jobs[n]
|
||||
fmt.Fprintf(out, "%-*s %-*s %-*s %s\n", nameW, n, cmdW, j.Command, srcW, j.SourceFile, j.Description)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runJobRun(cmd *cobra.Command, args []string) error {
|
||||
set, err := loadJobSet()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return executeJobPlan(set, args[0], jobDryRun, jobNoDeps, cmd.OutOrStdout())
|
||||
}
|
||||
|
||||
// executeJobPlan resolves the plan for name, runs pre-flight checks over
|
||||
// EVERY job in the plan, and only then executes. When dryRun is set it prints
|
||||
// the plan and returns without touching any input, output or database.
|
||||
func executeJobPlan(set *jobs.Set, name string, dryRun, noDeps bool, out io.Writer) error {
|
||||
plan, err := set.Plan(name, !noDeps)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Pre-flight: resolve and check paths, output policy and env vars for the
|
||||
// whole plan before anything runs. A failure here means no job executes.
|
||||
resolved := make([]*resolvedJob, len(plan))
|
||||
byName := make(map[string]*resolvedJob, len(plan))
|
||||
for i, j := range plan {
|
||||
rj, perr := preflightJob(j, byName)
|
||||
if perr != nil {
|
||||
return fmt.Errorf("job %q: %w", j.Name, perr)
|
||||
}
|
||||
resolved[i] = rj
|
||||
byName[j.Name] = rj
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
fmt.Fprintf(out, "RelSpec job plan for %q (dry run - nothing executed):\n\n", name)
|
||||
for i, rj := range resolved {
|
||||
printResolvedJob(out, i+1, len(resolved), rj)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, rj := range resolved {
|
||||
if err := executeResolvedJob(rj); err != nil {
|
||||
// Propagate the underlying failure; no success marker is written.
|
||||
return fmt.Errorf("job %q failed: %w", rj.job.Name, err)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "\n=== Job %q complete ===\n", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolvedJob is a job with every manifest path turned into a checked
|
||||
// absolute filesystem path and every conn_env resolved to its value.
|
||||
type resolvedJob struct {
|
||||
job *jobs.Job
|
||||
root string
|
||||
inputs []resolvedInput
|
||||
scriptDirs []string
|
||||
outputPath string // "" when the output is a database
|
||||
outputConn string // resolved connection string (secret)
|
||||
outputConnEnv string
|
||||
logPath string
|
||||
logPolicy jobs.LogPolicy
|
||||
templatePath string
|
||||
reportPath string // "" for a diff summary written to the log
|
||||
reportFormat string
|
||||
rulesPath string // "" means inspector defaults
|
||||
selection *splitSelection
|
||||
secrets []string // resolved secret values to redact from logs
|
||||
}
|
||||
|
||||
type resolvedInput struct {
|
||||
format string
|
||||
path string // "" when the input is a database
|
||||
conn string // resolved connection string (secret)
|
||||
connEnv string
|
||||
fromJob string // producer job name when this input came from from_job
|
||||
}
|
||||
|
||||
func preflightJob(j *jobs.Job, resolvedByName map[string]*resolvedJob) (*resolvedJob, error) {
|
||||
root := j.Dir()
|
||||
rj := &resolvedJob{job: j, root: root, logPolicy: j.ResolvedLogPolicy()}
|
||||
expandPath := func(label, value string) (string, error) {
|
||||
expanded, err := jobs.ExpandEnv(value)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: %w", label, err)
|
||||
}
|
||||
return expanded, nil
|
||||
}
|
||||
|
||||
if j.Logfile != "" {
|
||||
logfile, err := expandPath("logfile", j.Logfile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := jobs.SafeJoin(root, logfile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("logfile: %w", err)
|
||||
}
|
||||
rj.logPath = p
|
||||
}
|
||||
if j.Template != "" {
|
||||
templatePath, err := expandPath("template", j.Template)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := jobs.SafeJoin(root, templatePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("template: %w", err)
|
||||
}
|
||||
info, err := os.Stat(p)
|
||||
if err != nil || info.IsDir() {
|
||||
return nil, fmt.Errorf("template %q: not found or is a directory", templatePath)
|
||||
}
|
||||
rj.templatePath = p
|
||||
}
|
||||
|
||||
for i, in := range j.Inputs {
|
||||
ri := resolvedInput{format: strings.ToLower(in.Format)}
|
||||
if in.FromJob != "" {
|
||||
producer, ok := resolvedByName[in.FromJob]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("input[%d]: from_job %q is not in this plan (do not use --no-deps with from_job inputs)", i, in.FromJob)
|
||||
}
|
||||
if producer.outputPath == "" {
|
||||
return nil, fmt.Errorf("input[%d]: from_job %q does not write a file output", i, in.FromJob)
|
||||
}
|
||||
ri.path = producer.outputPath
|
||||
ri.format = strings.ToLower(producer.job.Output.Format)
|
||||
ri.fromJob = in.FromJob
|
||||
rj.inputs = append(rj.inputs, ri)
|
||||
continue
|
||||
}
|
||||
if in.ConnEnv != "" {
|
||||
v, ok := os.LookupEnv(in.ConnEnv)
|
||||
if !ok || v == "" {
|
||||
return nil, fmt.Errorf("input[%d]: environment variable %q (conn_env) is not set", i, in.ConnEnv)
|
||||
}
|
||||
ri.conn = v
|
||||
ri.connEnv = in.ConnEnv
|
||||
rj.secrets = append(rj.secrets, v)
|
||||
} else {
|
||||
inputPath, err := expandPath(fmt.Sprintf("input[%d]", i), in.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := jobs.SafeJoin(root, inputPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("input[%d]: %w", i, err)
|
||||
}
|
||||
info, err := os.Stat(p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("input[%d]: %s: file not found", i, inputPath)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil, fmt.Errorf("input[%d]: %s: is a directory, not a file", i, inputPath)
|
||||
}
|
||||
ri.path = p
|
||||
}
|
||||
rj.inputs = append(rj.inputs, ri)
|
||||
}
|
||||
|
||||
for _, d := range j.ScriptDirs {
|
||||
scriptDir, err := expandPath("script_dir", d)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := jobs.SafeJoin(root, scriptDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("script_dir %q: %w", d, err)
|
||||
}
|
||||
info, err := os.Stat(p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("script_dir %q: not found", scriptDir)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("script_dir %q: not a directory", d)
|
||||
}
|
||||
rj.scriptDirs = append(rj.scriptDirs, p)
|
||||
}
|
||||
|
||||
if j.Output != nil {
|
||||
if j.Output.ConnEnv != "" {
|
||||
v, ok := os.LookupEnv(j.Output.ConnEnv)
|
||||
if !ok || v == "" {
|
||||
return nil, fmt.Errorf("output: environment variable %q (conn_env) is not set", j.Output.ConnEnv)
|
||||
}
|
||||
rj.outputConn = v
|
||||
rj.outputConnEnv = j.Output.ConnEnv
|
||||
rj.secrets = append(rj.secrets, v)
|
||||
} else {
|
||||
outputPath, err := expandPath("output", j.Output.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := jobs.SafeJoin(root, outputPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("output: %w", err)
|
||||
}
|
||||
if _, err := os.Stat(p); err == nil && !j.Output.Overwrite {
|
||||
return nil, fmt.Errorf("output %s already exists (set output.overwrite: true to replace it)", outputPath)
|
||||
}
|
||||
rj.outputPath = p
|
||||
}
|
||||
}
|
||||
|
||||
if j.Rules != "" {
|
||||
rulesPath, err := expandPath("rules", j.Rules)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := jobs.SafeJoin(root, rulesPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rules: %w", err)
|
||||
}
|
||||
info, err := os.Stat(p)
|
||||
if err != nil || info.IsDir() {
|
||||
return nil, fmt.Errorf("rules %q: not found or is a directory", rulesPath)
|
||||
}
|
||||
rj.rulesPath = p
|
||||
}
|
||||
|
||||
if j.Report != nil {
|
||||
rj.reportFormat = strings.ToLower(j.Report.Format)
|
||||
if j.Report.Path != "" {
|
||||
reportPath, err := expandPath("report", j.Report.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := jobs.SafeJoin(root, reportPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("report: %w", err)
|
||||
}
|
||||
if _, err := os.Stat(p); err == nil && !j.Report.Overwrite {
|
||||
return nil, fmt.Errorf("report %s already exists (set report.overwrite: true to replace it)", reportPath)
|
||||
}
|
||||
rj.reportPath = p
|
||||
}
|
||||
}
|
||||
|
||||
if j.Select != nil {
|
||||
rj.selection = &splitSelection{
|
||||
Schemas: j.Select.Schemas,
|
||||
Tables: j.Select.Tables,
|
||||
ExcludeSchemas: j.Select.ExcludeSchemas,
|
||||
ExcludeTables: j.Select.ExcludeTables,
|
||||
DatabaseName: j.Select.DatabaseName,
|
||||
}
|
||||
}
|
||||
|
||||
return rj, nil
|
||||
}
|
||||
|
||||
func printResolvedJob(out io.Writer, n, total int, rj *resolvedJob) {
|
||||
j := rj.job
|
||||
fmt.Fprintf(out, "[%d/%d] %s\n", n, total, j.Name)
|
||||
fmt.Fprintf(out, " command: %s\n", j.Command)
|
||||
if j.Description != "" {
|
||||
fmt.Fprintf(out, " description: %s\n", j.Description)
|
||||
}
|
||||
fmt.Fprintf(out, " job file: %s\n", j.SourceFile)
|
||||
for _, ri := range rj.inputs {
|
||||
switch {
|
||||
case ri.fromJob != "":
|
||||
fmt.Fprintf(out, " input: %s (%s) from job %q\n", ri.path, ri.format, ri.fromJob)
|
||||
case ri.path != "":
|
||||
fmt.Fprintf(out, " input: %s (%s)\n", ri.path, ri.format)
|
||||
default:
|
||||
fmt.Fprintf(out, " input: env:%s (%s)\n", ri.connEnv, ri.format)
|
||||
}
|
||||
}
|
||||
for _, d := range rj.scriptDirs {
|
||||
fmt.Fprintf(out, " script dir: %s\n", d)
|
||||
}
|
||||
if rj.outputPath != "" {
|
||||
fmt.Fprintf(out, " output: %s (%s)\n", rj.outputPath, j.Output.Format)
|
||||
} else if rj.outputConnEnv != "" {
|
||||
fmt.Fprintf(out, " output: env:%s (%s)\n", rj.outputConnEnv, j.Output.Format)
|
||||
}
|
||||
if j.Report != nil {
|
||||
format := valueOr(rj.reportFormat, "default")
|
||||
if rj.reportPath != "" {
|
||||
fmt.Fprintf(out, " report: %s (%s)\n", rj.reportPath, format)
|
||||
} else {
|
||||
fmt.Fprintf(out, " report: (log) (%s)\n", format)
|
||||
}
|
||||
}
|
||||
if rj.rulesPath != "" {
|
||||
fmt.Fprintf(out, " rules: %s\n", rj.rulesPath)
|
||||
} else if j.Command == jobs.CommandInspect {
|
||||
fmt.Fprintf(out, " rules: (built-in defaults)\n")
|
||||
}
|
||||
if rj.selection != nil {
|
||||
fmt.Fprintf(out, " select: %s\n", rj.selection.summary())
|
||||
}
|
||||
if rj.logPath != "" {
|
||||
fmt.Fprintf(out, " logfile: %s (rotate >= %d bytes, keep %d)\n", rj.logPath, rj.logPolicy.MaxSizeBytes, rj.logPolicy.Keep)
|
||||
}
|
||||
fmt.Fprintln(out)
|
||||
}
|
||||
|
||||
// executeResolvedJob runs a single already-validated job.
|
||||
func executeResolvedJob(rj *resolvedJob) (err error) {
|
||||
lg, closeLog, lerr := newJobLogger(rj.logPath, rj.logPolicy, rj.secrets)
|
||||
if lerr != nil {
|
||||
return lerr
|
||||
}
|
||||
defer func() { closeLog(err) }()
|
||||
|
||||
lg.logf("=== job %q (%s) started at %s ===", rj.job.Name, rj.job.Command, time.Now().Format(time.RFC3339))
|
||||
|
||||
switch rj.job.Command {
|
||||
case jobs.CommandConvert:
|
||||
err = runConvertJob(rj, lg)
|
||||
case jobs.CommandMerge:
|
||||
err = runMergeJob(rj, lg)
|
||||
case jobs.CommandScriptsList:
|
||||
err = runScriptsListJob(rj, lg)
|
||||
case jobs.CommandTempl:
|
||||
err = runTemplJob(rj, lg)
|
||||
case jobs.CommandSplit:
|
||||
err = runSplitJob(rj, lg)
|
||||
case jobs.CommandInspect:
|
||||
err = runInspectJob(rj, lg)
|
||||
case jobs.CommandDiff:
|
||||
err = runDiffJob(rj, lg)
|
||||
case jobs.CommandScriptsExec:
|
||||
err = runScriptsExecJob(rj, lg)
|
||||
default:
|
||||
err = fmt.Errorf("unsupported command %q", rj.job.Command)
|
||||
}
|
||||
if err != nil {
|
||||
lg.logf("FAILED: %v", err)
|
||||
} else {
|
||||
lg.logf("OK")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func runTemplJob(rj *resolvedJob, lg *jobLogger) error {
|
||||
db, err := readJobInputs(rj, lg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if schema := rj.job.Options.Schema; schema != "" {
|
||||
found := false
|
||||
for _, s := range db.Schemas {
|
||||
if s.Name == schema {
|
||||
db.Schemas = []*models.Schema{s}
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("schema not found: %s", schema)
|
||||
}
|
||||
}
|
||||
mode := rj.job.Mode
|
||||
if mode == "" {
|
||||
mode = "database"
|
||||
}
|
||||
pattern := rj.job.FilenamePattern
|
||||
if pattern == "" {
|
||||
pattern = "{{.Name}}.txt"
|
||||
}
|
||||
writer, err := wtemplate.NewWriter(&writers.WriterOptions{
|
||||
OutputPath: rj.outputPath,
|
||||
Metadata: map[string]interface{}{
|
||||
"template_path": rj.templatePath,
|
||||
"mode": mode,
|
||||
"filename_pattern": pattern,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create template writer: %w", err)
|
||||
}
|
||||
lg.logf("applying template: %s (mode %s)", rj.templatePath, mode)
|
||||
if err := writer.WriteDatabase(db); err != nil {
|
||||
return fmt.Errorf("execute template: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runConvertJob(rj *resolvedJob, lg *jobLogger) error {
|
||||
db, err := readJobInputs(rj, lg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJobOutput(rj, db, lg)
|
||||
}
|
||||
|
||||
func runMergeJob(rj *resolvedJob, lg *jobLogger) error {
|
||||
opts := &merge.MergeOptions{
|
||||
SkipDomains: rj.job.Options.SkipDomains,
|
||||
SkipRelations: rj.job.Options.SkipRelations,
|
||||
SkipEnums: rj.job.Options.SkipEnums,
|
||||
SkipViews: rj.job.Options.SkipViews,
|
||||
SkipSequences: rj.job.Options.SkipSequences,
|
||||
}
|
||||
var base *models.Database
|
||||
for i, ri := range rj.inputs {
|
||||
db, err := readOneJobInput(ri)
|
||||
if err != nil {
|
||||
return fmt.Errorf("input[%d]: %w", i, err)
|
||||
}
|
||||
if base == nil {
|
||||
base = db
|
||||
lg.logf("merge target: %s", inputLabel(ri))
|
||||
continue
|
||||
}
|
||||
lg.logf("merging: %s", inputLabel(ri))
|
||||
merge.MergeDatabases(base, db, opts)
|
||||
}
|
||||
finalizeCommentedRefs(base, func(w string) { lg.logf("warning: %s", w) })
|
||||
base.UpdateDate()
|
||||
return writeJobOutput(rj, base, lg)
|
||||
}
|
||||
|
||||
func runScriptsListJob(rj *resolvedJob, lg *jobLogger) error {
|
||||
type row struct {
|
||||
priority int
|
||||
sequence uint
|
||||
name string
|
||||
dir string
|
||||
lines int
|
||||
}
|
||||
var rows []row
|
||||
for _, dir := range rj.scriptDirs {
|
||||
reader := sqldir.NewReader(&readers.ReaderOptions{
|
||||
FilePath: dir,
|
||||
Metadata: map[string]any{
|
||||
"schema_name": valueOr(rj.job.Options.Schema, "public"),
|
||||
"database_name": "database",
|
||||
},
|
||||
})
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", dir, err)
|
||||
}
|
||||
if len(db.Schemas) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, s := range db.Schemas[0].Scripts {
|
||||
lines := strings.Count(s.SQL, "\n")
|
||||
if len(s.SQL) > 0 && !strings.HasSuffix(s.SQL, "\n") {
|
||||
lines++
|
||||
}
|
||||
rows = append(rows, row{s.Priority, s.Sequence, s.Name, dir, lines})
|
||||
}
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
if rows[i].priority != rows[j].priority {
|
||||
return rows[i].priority < rows[j].priority
|
||||
}
|
||||
if rows[i].sequence != rows[j].sequence {
|
||||
return rows[i].sequence < rows[j].sequence
|
||||
}
|
||||
if rows[i].name != rows[j].name {
|
||||
return rows[i].name < rows[j].name
|
||||
}
|
||||
return rows[i].dir < rows[j].dir
|
||||
})
|
||||
lg.logf("found %d script(s) across %d director(y/ies):", len(rows), len(rj.scriptDirs))
|
||||
lg.logf("%-4s %-9s %-9s %-30s %-6s %s", "No.", "Priority", "Sequence", "Name", "Lines", "Directory")
|
||||
for i, r := range rows {
|
||||
lg.logf("%-4d %-9d %-9d %-30s %-6d %s", i+1, r.priority, r.sequence, r.name, r.lines, r.dir)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSplitJob(rj *resolvedJob, lg *jobLogger) error {
|
||||
db, err := readJobInputs(rj, lg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sel := splitSelection{}
|
||||
if rj.selection != nil {
|
||||
sel = *rj.selection
|
||||
}
|
||||
filtered, err := filterDatabaseSelection(db, sel)
|
||||
if err != nil {
|
||||
return fmt.Errorf("split selection: %w", err)
|
||||
}
|
||||
if sel.DatabaseName != "" {
|
||||
filtered.Name = sel.DatabaseName
|
||||
}
|
||||
tables := 0
|
||||
for _, s := range filtered.Schemas {
|
||||
tables += len(s.Tables)
|
||||
}
|
||||
lg.logf("split: selected %d schema(s), %d table(s)", len(filtered.Schemas), tables)
|
||||
return writeJobOutput(rj, filtered, lg)
|
||||
}
|
||||
|
||||
func runInspectJob(rj *resolvedJob, lg *jobLogger) error {
|
||||
db, err := readJobInputs(rj, lg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
config, err := inspector.LoadConfig(rj.rulesPath) // "" -> built-in defaults
|
||||
if err != nil {
|
||||
return fmt.Errorf("load rules: %w", err)
|
||||
}
|
||||
report, err := inspector.NewInspector(db, config).Inspect()
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspection failed: %w", err)
|
||||
}
|
||||
|
||||
var formatted string
|
||||
switch valueOr(rj.reportFormat, "markdown") {
|
||||
case "json":
|
||||
formatted, err = inspector.NewJSONFormatter().Format(report)
|
||||
default:
|
||||
formatted, err = inspector.NewMarkdownFormatter(io.Discard).Format(report)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("format report: %w", err)
|
||||
}
|
||||
if werr := atomicWrite(rj.reportPath, func(tmp string) error {
|
||||
return os.WriteFile(tmp, []byte(formatted), 0o644)
|
||||
}); werr != nil {
|
||||
return werr
|
||||
}
|
||||
lg.logf("inspect: %d error(s), %d warning(s) -> %s",
|
||||
report.Summary.ErrorCount, report.Summary.WarningCount, rj.reportPath)
|
||||
if report.HasErrors() {
|
||||
return fmt.Errorf("inspection found %d error(s)", report.Summary.ErrorCount)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runDiffJob(rj *resolvedJob, lg *jobLogger) error {
|
||||
if len(rj.inputs) != 2 {
|
||||
return fmt.Errorf("diff requires exactly 2 inputs, got %d", len(rj.inputs))
|
||||
}
|
||||
source, err := readOneJobInput(rj.inputs[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("input[0]: %w", err)
|
||||
}
|
||||
lg.logf("diff source: %s", inputLabel(rj.inputs[0]))
|
||||
target, err := readOneJobInput(rj.inputs[1])
|
||||
if err != nil {
|
||||
return fmt.Errorf("input[1]: %w", err)
|
||||
}
|
||||
lg.logf("diff target: %s", inputLabel(rj.inputs[1]))
|
||||
|
||||
result := diff.CompareDatabases(source, target)
|
||||
s := diff.ComputeSummary(result)
|
||||
lg.logf("diff: schemas %d/%d/%d, tables %d/%d/%d, columns %d/%d/%d (missing/extra/modified)",
|
||||
s.Schemas.Missing, s.Schemas.Extra, s.Schemas.Modified,
|
||||
s.Tables.Missing, s.Tables.Extra, s.Tables.Modified,
|
||||
s.Columns.Missing, s.Columns.Extra, s.Columns.Modified)
|
||||
|
||||
format := diff.FormatSummary
|
||||
switch rj.reportFormat {
|
||||
case "json":
|
||||
format = diff.FormatJSON
|
||||
case "html":
|
||||
format = diff.FormatHTML
|
||||
}
|
||||
|
||||
if rj.reportPath == "" {
|
||||
var buf bytes.Buffer
|
||||
if err := diff.FormatDiff(result, format, &buf); err != nil {
|
||||
return fmt.Errorf("format diff: %w", err)
|
||||
}
|
||||
for _, line := range strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") {
|
||||
lg.logf("%s", line)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if werr := atomicWrite(rj.reportPath, func(tmp string) error {
|
||||
f, err := os.Create(tmp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
return diff.FormatDiff(result, format, f)
|
||||
}); werr != nil {
|
||||
return werr
|
||||
}
|
||||
lg.logf("diff report written: %s", rj.reportPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runScriptsExecJob(rj *resolvedJob, lg *jobLogger) error {
|
||||
schemaName := valueOr(rj.job.Options.Schema, "public")
|
||||
combined := &models.Schema{Name: schemaName}
|
||||
for _, dir := range rj.scriptDirs {
|
||||
reader := sqldir.NewReader(&readers.ReaderOptions{
|
||||
FilePath: dir,
|
||||
Metadata: map[string]any{
|
||||
"schema_name": schemaName,
|
||||
"database_name": "database",
|
||||
},
|
||||
})
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", dir, err)
|
||||
}
|
||||
if len(db.Schemas) == 0 {
|
||||
continue
|
||||
}
|
||||
combined.Scripts = append(combined.Scripts, db.Schemas[0].Scripts...)
|
||||
}
|
||||
if len(combined.Scripts) == 0 {
|
||||
lg.logf("no scripts found; nothing to execute")
|
||||
return nil
|
||||
}
|
||||
lg.logf("executing %d script(s) against database env:%s", len(combined.Scripts), rj.outputConnEnv)
|
||||
|
||||
writer := sqlexec.NewWriter(&writers.WriterOptions{
|
||||
Metadata: map[string]any{
|
||||
"connection_string": rj.outputConn,
|
||||
"ignore_errors": rj.job.Options.ContinueOnError,
|
||||
},
|
||||
})
|
||||
if err := writer.WriteSchema(combined); err != nil {
|
||||
return fmt.Errorf("script execution failed: %w", err)
|
||||
}
|
||||
|
||||
opts := writer.Options()
|
||||
total, _ := opts.Metadata["execution_total"].(int)
|
||||
success, _ := opts.Metadata["execution_success"].(int)
|
||||
failed, _ := opts.Metadata["execution_failed"].(int)
|
||||
lg.logf("executed %d script(s): %d succeeded, %d failed", total, success, failed)
|
||||
if failed > 0 && !rj.job.Options.ContinueOnError {
|
||||
return fmt.Errorf("%d script(s) failed", failed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readJobInputs reads every input and additively merges them into one model.
|
||||
func readJobInputs(rj *resolvedJob, lg *jobLogger) (*models.Database, error) {
|
||||
var base *models.Database
|
||||
for i, ri := range rj.inputs {
|
||||
db, err := readOneJobInput(ri)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("input[%d]: %w", i, err)
|
||||
}
|
||||
lg.logf("read input: %s", inputLabel(ri))
|
||||
if base == nil {
|
||||
base = db
|
||||
} else {
|
||||
merge.MergeDatabases(base, db, &merge.MergeOptions{})
|
||||
}
|
||||
}
|
||||
if base == nil {
|
||||
return nil, fmt.Errorf("no inputs produced a database")
|
||||
}
|
||||
finalizeCommentedRefs(base, func(w string) { lg.logf("warning: %s", w) })
|
||||
return base, nil
|
||||
}
|
||||
|
||||
func readOneJobInput(ri resolvedInput) (*models.Database, error) {
|
||||
if ri.conn != "" {
|
||||
return readDatabaseForConvert(ri.format, "", ri.conn)
|
||||
}
|
||||
return readDatabaseForConvert(ri.format, ri.path, "")
|
||||
}
|
||||
|
||||
func inputLabel(ri resolvedInput) string {
|
||||
if ri.path != "" {
|
||||
return fmt.Sprintf("%s (%s)", ri.path, ri.format)
|
||||
}
|
||||
return fmt.Sprintf("env:%s (%s)", ri.connEnv, ri.format)
|
||||
}
|
||||
|
||||
// writeJobOutput writes db to the job's output target (file or database).
|
||||
func writeJobOutput(rj *resolvedJob, db *models.Database, lg *jobLogger) error {
|
||||
o := rj.job.Options
|
||||
format := strings.ToLower(rj.job.Output.Format)
|
||||
|
||||
if rj.outputConn != "" {
|
||||
if format != "pgsql" {
|
||||
return fmt.Errorf("database output is only supported for pgsql (got %q)", rj.job.Output.Format)
|
||||
}
|
||||
lg.logf("writing output to database env:%s", rj.outputConnEnv)
|
||||
writerOpts := newWriterOptions("", o.Package, o.FlattenSchema, o.Types, o.ArrayNullable, o.ContinueOnError)
|
||||
writerOpts.Metadata = map[string]interface{}{"connection_string": rj.outputConn, "full_ddl": o.FullDDL}
|
||||
return wpgsql.NewWriter(writerOpts).WriteDatabase(db)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(rj.outputPath), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
lg.logf("writing output: %s (%s)", rj.outputPath, format)
|
||||
|
||||
write := func(target string) error {
|
||||
return writeDatabase(db, format, target, o.Package, o.Schema, o.FlattenSchema, o.Types, o.ArrayNullable, o.ContinueOnError, "")
|
||||
}
|
||||
// Single-file formats are written to a temp file and renamed into place so
|
||||
// a failure never leaves a partial or truncated output. Directory-emitting
|
||||
// formats (gorm/bun/drizzle/typeorm/prisma) write in place.
|
||||
if jobs.SingleFileOutputFormat(format) {
|
||||
return atomicWrite(rj.outputPath, write)
|
||||
}
|
||||
return write(rj.outputPath)
|
||||
}
|
||||
|
||||
// atomicWrite calls produce with a temp path in the same directory as
|
||||
// finalPath, then renames it over finalPath. The temp file is removed on any
|
||||
// error so the destination is only ever replaced by a complete file.
|
||||
func atomicWrite(finalPath string, produce func(tmpPath string) error) error {
|
||||
dir := filepath.Dir(finalPath)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
tmp := filepath.Join(dir, fmt.Sprintf(".%s.relspec-tmp-%d", filepath.Base(finalPath), os.Getpid()))
|
||||
if err := produce(tmp); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, finalPath); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("failed to finalize %s: %w", finalPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- logging + redaction ---------------------------------------------------
|
||||
|
||||
type jobLogger struct {
|
||||
file io.Writer
|
||||
secrets []string
|
||||
}
|
||||
|
||||
// newJobLogger returns a logger that mirrors to stderr and, when path is set,
|
||||
// to a job logfile. Connection strings and known secret values are redacted
|
||||
// from everything it writes.
|
||||
func newJobLogger(path string, policy jobs.LogPolicy, secrets []string) (*jobLogger, func(err error), error) {
|
||||
lg := &jobLogger{secrets: secrets}
|
||||
if path == "" {
|
||||
return lg, func(error) {}, nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create log directory: %w", err)
|
||||
}
|
||||
rotateLogIfNeeded(path, policy)
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to open logfile %q: %w", path, err)
|
||||
}
|
||||
lg.file = f
|
||||
return lg, func(runErr error) {
|
||||
if runErr != nil {
|
||||
fmt.Fprintf(f, "%s job ended with error\n", time.Now().Format(time.RFC3339))
|
||||
}
|
||||
_ = f.Close()
|
||||
}, nil
|
||||
}
|
||||
|
||||
// rotateLogIfNeeded renames path -> path.1 -> path.2 ... up to policy.Keep
|
||||
// when path has grown to policy.MaxSizeBytes or more. The oldest file beyond
|
||||
// Keep is deleted. A zero/negative MaxSizeBytes disables rotation.
|
||||
func rotateLogIfNeeded(path string, policy jobs.LogPolicy) {
|
||||
if policy.MaxSizeBytes <= 0 {
|
||||
return
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.Size() < policy.MaxSizeBytes {
|
||||
return
|
||||
}
|
||||
if policy.Keep < 1 {
|
||||
_ = os.Remove(path)
|
||||
return
|
||||
}
|
||||
_ = os.Remove(fmt.Sprintf("%s.%d", path, policy.Keep))
|
||||
for i := policy.Keep - 1; i >= 1; i-- {
|
||||
_ = os.Rename(fmt.Sprintf("%s.%d", path, i), fmt.Sprintf("%s.%d", path, i+1))
|
||||
}
|
||||
_ = os.Rename(path, path+".1")
|
||||
}
|
||||
|
||||
func (l *jobLogger) logf(format string, args ...interface{}) {
|
||||
line := l.redact(fmt.Sprintf(format, args...))
|
||||
fmt.Fprintf(os.Stderr, " %s\n", line)
|
||||
if l.file != nil {
|
||||
fmt.Fprintf(l.file, "%s %s\n", time.Now().Format(time.RFC3339), line)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *jobLogger) redact(s string) string {
|
||||
for _, sec := range l.secrets {
|
||||
if sec != "" {
|
||||
s = strings.ReplaceAll(s, sec, "***")
|
||||
}
|
||||
}
|
||||
return maskPassword(s)
|
||||
}
|
||||
|
||||
// --- small helpers -------------------------------------------------------
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func valueOr(v, def string) string {
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,743 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/jobs"
|
||||
)
|
||||
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// jobFixture creates a job-file project with two DBML sources and returns the
|
||||
// project directory.
|
||||
func jobFixture(t *testing.T, manifest string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
writeFile(t, filepath.Join(dir, "schema", "core.dbml"), "Table users {\n id int [pk]\n name varchar\n}\n")
|
||||
writeFile(t, filepath.Join(dir, "schema", "tenant.dbml"), "Table posts {\n id int [pk]\n title varchar\n}\n")
|
||||
writeFile(t, filepath.Join(dir, "relspec.yml"), manifest)
|
||||
return dir
|
||||
}
|
||||
|
||||
func mustLoadSet(t *testing.T, files ...string) *jobs.Set {
|
||||
t.Helper()
|
||||
set, err := jobs.Load(files)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if err := set.Validate(); err != nil {
|
||||
t.Fatalf("validate: %v", err)
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
const convertMergeManifest = `version: 1
|
||||
jobs:
|
||||
build-schema:
|
||||
command: convert
|
||||
description: Merge DBML sources to PostgreSQL DDL
|
||||
inputs:
|
||||
- path: schema/core.dbml
|
||||
format: dbml
|
||||
- path: schema/tenant.dbml
|
||||
format: dbml
|
||||
output:
|
||||
format: pgsql
|
||||
path: build/schema.sql
|
||||
overwrite: true
|
||||
logfile: .relspec/log/build.log
|
||||
`
|
||||
|
||||
func TestJobRun_ConvertMultiFileMerge(t *testing.T) {
|
||||
dir := jobFixture(t, convertMergeManifest)
|
||||
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
||||
|
||||
if err := executeJobPlan(set, "build-schema", false, false, &bytes.Buffer{}); err != nil {
|
||||
t.Fatalf("executeJobPlan: %v", err)
|
||||
}
|
||||
|
||||
out, err := os.ReadFile(filepath.Join(dir, "build", "schema.sql"))
|
||||
if err != nil {
|
||||
t.Fatalf("expected output file: %v", err)
|
||||
}
|
||||
sql := string(out)
|
||||
if !strings.Contains(sql, "users") || !strings.Contains(sql, "posts") {
|
||||
t.Fatalf("merged output missing tables:\n%s", sql)
|
||||
}
|
||||
|
||||
logData, err := os.ReadFile(filepath.Join(dir, ".relspec", "log", "build.log"))
|
||||
if err != nil {
|
||||
t.Fatalf("expected logfile: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(logData), "OK") {
|
||||
t.Fatalf("logfile missing success marker:\n%s", logData)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobRun_DryRunDoesNotExecute(t *testing.T) {
|
||||
dir := jobFixture(t, convertMergeManifest)
|
||||
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := executeJobPlan(set, "build-schema", true, false, &buf); err != nil {
|
||||
t.Fatalf("dry run error: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "dry run") {
|
||||
t.Fatalf("expected dry-run banner, got: %s", buf.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "build", "schema.sql")); !os.IsNotExist(err) {
|
||||
t.Fatal("dry run must not create the output file")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, ".relspec", "log", "build.log")); !os.IsNotExist(err) {
|
||||
t.Fatal("dry run must not create the logfile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobRun_ValidationFailureNoExecution(t *testing.T) {
|
||||
badManifest := `version: 1
|
||||
jobs:
|
||||
evil:
|
||||
command: convert
|
||||
inputs:
|
||||
- path: ../../../etc/passwd
|
||||
format: dbml
|
||||
output:
|
||||
format: json
|
||||
path: build/out.json
|
||||
logfile: .relspec/evil.log
|
||||
`
|
||||
dir := jobFixture(t, badManifest)
|
||||
if _, err := jobs.Load([]string{filepath.Join(dir, "relspec.yml")}); err != nil {
|
||||
// structural load ok; validation should reject
|
||||
t.Fatalf("unexpected load error: %v", err)
|
||||
}
|
||||
set, _ := jobs.Load([]string{filepath.Join(dir, "relspec.yml")})
|
||||
if err := set.Validate(); err == nil {
|
||||
t.Fatal("expected validation failure for path traversal")
|
||||
}
|
||||
// Nothing should have been produced.
|
||||
if _, err := os.Stat(filepath.Join(dir, "build")); !os.IsNotExist(err) {
|
||||
t.Fatal("validation failure must not create output dir")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, ".relspec")); !os.IsNotExist(err) {
|
||||
t.Fatal("validation failure must not create logfile dir")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobRun_MissingInputNoExecution(t *testing.T) {
|
||||
manifest := `version: 1
|
||||
jobs:
|
||||
x:
|
||||
command: convert
|
||||
inputs:
|
||||
- path: schema/does-not-exist.dbml
|
||||
format: dbml
|
||||
output:
|
||||
format: json
|
||||
path: build/out.json
|
||||
logfile: .relspec/x.log
|
||||
`
|
||||
dir := jobFixture(t, manifest)
|
||||
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
||||
|
||||
err := executeJobPlan(set, "x", false, false, &bytes.Buffer{})
|
||||
if err == nil || !strings.Contains(err.Error(), "not found") {
|
||||
t.Fatalf("expected missing-input error, got %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "build")); !os.IsNotExist(err) {
|
||||
t.Fatal("missing input must not create output dir")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, ".relspec")); !os.IsNotExist(err) {
|
||||
t.Fatal("missing input must not create logfile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobRun_MissingConnEnvNoExecution(t *testing.T) {
|
||||
manifest := `version: 1
|
||||
jobs:
|
||||
remote:
|
||||
command: convert
|
||||
inputs:
|
||||
- format: pgsql
|
||||
conn_env: RELSPEC_TEST_MISSING_CONN
|
||||
output:
|
||||
format: json
|
||||
path: build/out.json
|
||||
logfile: .relspec/remote.log
|
||||
`
|
||||
dir := jobFixture(t, manifest)
|
||||
os.Unsetenv("RELSPEC_TEST_MISSING_CONN")
|
||||
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
||||
|
||||
err := executeJobPlan(set, "remote", false, false, &bytes.Buffer{})
|
||||
if err == nil || !strings.Contains(err.Error(), "conn_env") {
|
||||
t.Fatalf("expected missing conn_env error, got %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, ".relspec")); !os.IsNotExist(err) {
|
||||
t.Fatal("missing conn_env must not create logfile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobRun_ExitCodePropagation(t *testing.T) {
|
||||
// gorm output without options.package makes the underlying writer fail.
|
||||
manifest := `version: 1
|
||||
jobs:
|
||||
fail:
|
||||
command: convert
|
||||
inputs:
|
||||
- path: schema/core.dbml
|
||||
format: dbml
|
||||
output:
|
||||
format: gorm
|
||||
path: build/models
|
||||
overwrite: true
|
||||
logfile: .relspec/fail.log
|
||||
`
|
||||
dir := jobFixture(t, manifest)
|
||||
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
||||
|
||||
err := executeJobPlan(set, "fail", false, false, &bytes.Buffer{})
|
||||
if err == nil {
|
||||
t.Fatal("expected underlying failure to propagate")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "job \"fail\" failed") {
|
||||
t.Fatalf("error should identify the failing job: %v", err)
|
||||
}
|
||||
// Logfile records the failure and no misleading success marker.
|
||||
logData, _ := os.ReadFile(filepath.Join(dir, ".relspec", "fail.log"))
|
||||
if strings.Contains(string(logData), "\nOK\n") || strings.HasSuffix(strings.TrimSpace(string(logData)), "OK") {
|
||||
t.Fatalf("failed job must not log OK:\n%s", logData)
|
||||
}
|
||||
if !strings.Contains(string(logData), "FAILED") {
|
||||
t.Fatalf("failed job should log FAILED:\n%s", logData)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobRun_DependencyChainExecutes(t *testing.T) {
|
||||
manifest := `version: 1
|
||||
jobs:
|
||||
a:
|
||||
command: convert
|
||||
inputs:
|
||||
- path: schema/core.dbml
|
||||
format: dbml
|
||||
output:
|
||||
format: json
|
||||
path: build/a.json
|
||||
overwrite: true
|
||||
b:
|
||||
command: convert
|
||||
depends_on: [a]
|
||||
inputs:
|
||||
- path: schema/tenant.dbml
|
||||
format: dbml
|
||||
output:
|
||||
format: json
|
||||
path: build/b.json
|
||||
overwrite: true
|
||||
`
|
||||
dir := jobFixture(t, manifest)
|
||||
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
||||
|
||||
if err := executeJobPlan(set, "b", false, false, &bytes.Buffer{}); err != nil {
|
||||
t.Fatalf("executeJobPlan: %v", err)
|
||||
}
|
||||
for _, f := range []string{"a.json", "b.json"} {
|
||||
if _, err := os.Stat(filepath.Join(dir, "build", f)); err != nil {
|
||||
t.Fatalf("expected %s to be produced: %v", f, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobRun_ScriptsListMultipleDirs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeFile(t, filepath.Join(dir, "migrations", "core", "1_001_create_users.sql"), "CREATE TABLE users();\n")
|
||||
writeFile(t, filepath.Join(dir, "migrations", "tenant", "1_002_create_posts.sql"), "CREATE TABLE posts();\n")
|
||||
writeFile(t, filepath.Join(dir, "migrations", "tenant", "2_001_add_index.sql"), "CREATE INDEX x ON posts(id);\n")
|
||||
manifest := `version: 1
|
||||
jobs:
|
||||
list-all:
|
||||
command: scripts-list
|
||||
script_dirs:
|
||||
- migrations/core
|
||||
- migrations/tenant
|
||||
logfile: .relspec/scripts.log
|
||||
`
|
||||
writeFile(t, filepath.Join(dir, "relspec.yml"), manifest)
|
||||
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
||||
|
||||
if err := executeJobPlan(set, "list-all", false, false, &bytes.Buffer{}); err != nil {
|
||||
t.Fatalf("executeJobPlan: %v", err)
|
||||
}
|
||||
logData, err := os.ReadFile(filepath.Join(dir, ".relspec", "scripts.log"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(logData)
|
||||
iUsers := strings.Index(s, "create_users")
|
||||
iPosts := strings.Index(s, "create_posts")
|
||||
iIndex := strings.Index(s, "add_index")
|
||||
if iUsers < 0 || iPosts < 0 || iIndex < 0 {
|
||||
t.Fatalf("expected all scripts listed:\n%s", s)
|
||||
}
|
||||
if !(iUsers < iPosts && iPosts < iIndex) {
|
||||
t.Fatalf("scripts not in priority/sequence order:\n%s", s)
|
||||
}
|
||||
if !strings.Contains(s, "found 3 script(s) across 2") {
|
||||
t.Fatalf("expected multi-directory summary:\n%s", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobRun_ConnEnvRedactedInPlan(t *testing.T) {
|
||||
manifest := `version: 1
|
||||
jobs:
|
||||
remote:
|
||||
command: convert
|
||||
inputs:
|
||||
- format: pgsql
|
||||
conn_env: RELSPEC_TEST_PLAN_CONN
|
||||
output:
|
||||
format: json
|
||||
path: build/out.json
|
||||
`
|
||||
dir := jobFixture(t, manifest)
|
||||
secret := "postgres://user:supersecret@db.example/app"
|
||||
t.Setenv("RELSPEC_TEST_PLAN_CONN", secret)
|
||||
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := executeJobPlan(set, "remote", true, false, &buf); err != nil {
|
||||
t.Fatalf("dry run: %v", err)
|
||||
}
|
||||
if strings.Contains(buf.String(), "supersecret") || strings.Contains(buf.String(), secret) {
|
||||
t.Fatalf("plan leaked secret:\n%s", buf.String())
|
||||
}
|
||||
if !strings.Contains(buf.String(), "env:RELSPEC_TEST_PLAN_CONN") {
|
||||
t.Fatalf("plan should reference the env var name:\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobLogger_Redaction(t *testing.T) {
|
||||
lg := &jobLogger{secrets: []string{"topsecret"}}
|
||||
got := lg.redact("connecting with password topsecret and postgres://u:p@h/db")
|
||||
if strings.Contains(got, "topsecret") {
|
||||
t.Fatalf("secret not redacted: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "***") {
|
||||
t.Fatalf("expected redaction marker: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobList_DeterministicOutput(t *testing.T) {
|
||||
manifest := `version: 1
|
||||
jobs:
|
||||
zebra:
|
||||
command: convert
|
||||
inputs: [{path: schema/core.dbml, format: dbml}]
|
||||
output: {format: json, path: build/z.json}
|
||||
alpha:
|
||||
command: convert
|
||||
inputs: [{path: schema/core.dbml, format: dbml}]
|
||||
output: {format: json, path: build/a.json}
|
||||
`
|
||||
dir := jobFixture(t, manifest)
|
||||
|
||||
run := func() string {
|
||||
jobDir = dir
|
||||
jobFiles = nil
|
||||
cmd := &cobra.Command{}
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
if err := runJobList(cmd, nil); err != nil {
|
||||
t.Fatalf("runJobList: %v", err)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
first := run()
|
||||
if strings.Index(first, "alpha") > strings.Index(first, "zebra") {
|
||||
t.Fatalf("jobs not sorted:\n%s", first)
|
||||
}
|
||||
if first != run() {
|
||||
t.Fatal("job list output not deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobRun_SplitJob(t *testing.T) {
|
||||
dir := jobFixture(t, `version: 1
|
||||
jobs:
|
||||
extract:
|
||||
command: split
|
||||
inputs:
|
||||
- path: schema/core.dbml
|
||||
format: dbml
|
||||
- path: schema/tenant.dbml
|
||||
format: dbml
|
||||
select:
|
||||
tables: [users]
|
||||
output:
|
||||
format: json
|
||||
path: build/subset.json
|
||||
overwrite: true
|
||||
`)
|
||||
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
||||
if err := executeJobPlan(set, "extract", false, false, &bytes.Buffer{}); err != nil {
|
||||
t.Fatalf("execute split job: %v", err)
|
||||
}
|
||||
out, err := os.ReadFile(filepath.Join(dir, "build", "subset.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read split output: %v", err)
|
||||
}
|
||||
s := string(out)
|
||||
if !strings.Contains(s, "users") {
|
||||
t.Fatalf("split output missing selected table:\n%s", s)
|
||||
}
|
||||
if strings.Contains(s, "posts") {
|
||||
t.Fatalf("split output should have excluded posts:\n%s", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobRun_InspectJob(t *testing.T) {
|
||||
dir := jobFixture(t, `version: 1
|
||||
jobs:
|
||||
lint:
|
||||
command: inspect
|
||||
inputs:
|
||||
- path: schema/core.dbml
|
||||
format: dbml
|
||||
report:
|
||||
format: json
|
||||
path: build/report.json
|
||||
overwrite: true
|
||||
logfile: .relspec/lint.log
|
||||
`)
|
||||
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
||||
// Default rules only warn, so the job succeeds.
|
||||
if err := executeJobPlan(set, "lint", false, false, &bytes.Buffer{}); err != nil {
|
||||
t.Fatalf("execute inspect job: %v", err)
|
||||
}
|
||||
if _, err := os.ReadFile(filepath.Join(dir, "build", "report.json")); err != nil {
|
||||
t.Fatalf("expected report file: %v", err)
|
||||
}
|
||||
logData, _ := os.ReadFile(filepath.Join(dir, ".relspec", "lint.log"))
|
||||
if !strings.Contains(string(logData), "inspect:") {
|
||||
t.Fatalf("logfile missing inspect summary:\n%s", logData)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobRun_InspectJobFailsOnRuleError(t *testing.T) {
|
||||
dir := jobFixture(t, `version: 1
|
||||
jobs:
|
||||
lint:
|
||||
command: inspect
|
||||
inputs:
|
||||
- path: schema/core.dbml
|
||||
format: dbml
|
||||
rules: rules.yaml
|
||||
report:
|
||||
format: json
|
||||
path: build/report.json
|
||||
overwrite: true
|
||||
logfile: .relspec/lint.log
|
||||
`)
|
||||
// A rule set to "error" level for a violation the fixture triggers.
|
||||
writeFile(t, filepath.Join(dir, "rules.yaml"), `version: "1.0"
|
||||
rules:
|
||||
primary_key_naming:
|
||||
enabled: enforce
|
||||
function: primary_key_naming
|
||||
pattern: "^id_"
|
||||
message: "Primary key columns should start with id_"
|
||||
`)
|
||||
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
||||
err := executeJobPlan(set, "lint", false, false, &bytes.Buffer{})
|
||||
if err == nil || !strings.Contains(err.Error(), "error(s)") {
|
||||
t.Fatalf("expected inspect job to fail on rule error, got %v", err)
|
||||
}
|
||||
logData, _ := os.ReadFile(filepath.Join(dir, ".relspec", "lint.log"))
|
||||
if !strings.Contains(string(logData), "FAILED") {
|
||||
t.Fatalf("failed inspect job should log FAILED:\n%s", logData)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobRun_DiffJob(t *testing.T) {
|
||||
dir := jobFixture(t, `version: 1
|
||||
jobs:
|
||||
compare:
|
||||
command: diff
|
||||
inputs:
|
||||
- path: schema/core.dbml
|
||||
format: dbml
|
||||
- path: schema/tenant.dbml
|
||||
format: dbml
|
||||
report:
|
||||
format: json
|
||||
path: build/diff.json
|
||||
overwrite: true
|
||||
`)
|
||||
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
||||
if err := executeJobPlan(set, "compare", false, false, &bytes.Buffer{}); err != nil {
|
||||
t.Fatalf("execute diff job: %v", err)
|
||||
}
|
||||
out, err := os.ReadFile(filepath.Join(dir, "build", "diff.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read diff report: %v", err)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Fatal("diff report is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobRun_FromJobWiring(t *testing.T) {
|
||||
dir := jobFixture(t, `version: 1
|
||||
jobs:
|
||||
a:
|
||||
command: convert
|
||||
inputs:
|
||||
- path: schema/core.dbml
|
||||
format: dbml
|
||||
output:
|
||||
format: json
|
||||
path: build/a.json
|
||||
overwrite: true
|
||||
b:
|
||||
command: convert
|
||||
inputs:
|
||||
- from_job: a
|
||||
output:
|
||||
format: yaml
|
||||
path: build/b.yaml
|
||||
overwrite: true
|
||||
`)
|
||||
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
||||
if err := executeJobPlan(set, "b", false, false, &bytes.Buffer{}); err != nil {
|
||||
t.Fatalf("execute from_job chain: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "build", "a.json")); err != nil {
|
||||
t.Fatalf("producer output missing: %v", err)
|
||||
}
|
||||
out, err := os.ReadFile(filepath.Join(dir, "build", "b.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("consumer output missing: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(out), "users") {
|
||||
t.Fatalf("consumer did not consume producer output:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobRun_LogRotation(t *testing.T) {
|
||||
dir := jobFixture(t, `version: 1
|
||||
jobs:
|
||||
build:
|
||||
command: scripts-list
|
||||
script_dirs: [migrations]
|
||||
log_max_size: "150B"
|
||||
log_keep: 2
|
||||
logfile: .relspec/build.log
|
||||
`)
|
||||
writeFile(t, filepath.Join(dir, "migrations", "1_001_a.sql"), "CREATE TABLE a();\n")
|
||||
logPath := filepath.Join(dir, ".relspec", "build.log")
|
||||
writeFile(t, logPath, strings.Repeat("x", 300)+"\n")
|
||||
|
||||
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
||||
if err := executeJobPlan(set, "build", false, false, &bytes.Buffer{}); err != nil {
|
||||
t.Fatalf("execute job: %v", err)
|
||||
}
|
||||
rotated, err := os.ReadFile(logPath + ".1")
|
||||
if err != nil {
|
||||
t.Fatalf("expected rotated logfile build.log.1: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(rotated), strings.Repeat("x", 300)) {
|
||||
t.Fatalf("rotated logfile should hold the old content")
|
||||
}
|
||||
fresh, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatalf("expected fresh logfile: %v", err)
|
||||
}
|
||||
if strings.Contains(string(fresh), strings.Repeat("x", 300)) {
|
||||
t.Fatalf("fresh logfile should not contain the rotated-out content:\n%s", fresh)
|
||||
}
|
||||
if !strings.Contains(string(fresh), "OK") {
|
||||
t.Fatalf("fresh logfile should hold the new run:\n%s", fresh)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobRun_AtomicOutputLeavesOriginalOnFailure(t *testing.T) {
|
||||
dir := jobFixture(t, `version: 1
|
||||
jobs:
|
||||
x:
|
||||
command: convert
|
||||
inputs:
|
||||
- path: schema/core.dbml
|
||||
format: dbml
|
||||
output:
|
||||
format: json
|
||||
path: build/out.json
|
||||
overwrite: true
|
||||
`)
|
||||
// Seed the destination, then make its parent directory read-only so the
|
||||
// rename step fails. The seeded file must survive intact.
|
||||
seeded := filepath.Join(dir, "build", "out.json")
|
||||
writeFile(t, seeded, `{"seeded":true}`)
|
||||
if err := os.Chmod(filepath.Join(dir, "build"), 0o500); err != nil {
|
||||
t.Skipf("cannot chmod: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(filepath.Join(dir, "build"), 0o755) })
|
||||
|
||||
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
||||
if err := executeJobPlan(set, "x", false, false, &bytes.Buffer{}); err == nil {
|
||||
t.Skip("write unexpectedly succeeded (running as root?)")
|
||||
}
|
||||
if err := os.Chmod(filepath.Join(dir, "build"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(seeded)
|
||||
if err != nil {
|
||||
t.Fatalf("seeded file gone: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "seeded") {
|
||||
t.Fatalf("seeded file was corrupted: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobRun_ScriptsExecMissingConnEnv(t *testing.T) {
|
||||
dir := jobFixture(t, `version: 1
|
||||
jobs:
|
||||
migrate:
|
||||
command: scripts-exec
|
||||
script_dirs: [migrations]
|
||||
output:
|
||||
conn_env: RELSPEC_TEST_EXEC_MISSING
|
||||
logfile: .relspec/migrate.log
|
||||
`)
|
||||
writeFile(t, filepath.Join(dir, "migrations", "1_001_a.sql"), "CREATE TABLE a();\n")
|
||||
os.Unsetenv("RELSPEC_TEST_EXEC_MISSING")
|
||||
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
||||
err := executeJobPlan(set, "migrate", false, false, &bytes.Buffer{})
|
||||
if err == nil || !strings.Contains(err.Error(), "conn_env") {
|
||||
t.Fatalf("expected missing conn_env error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobRun_ScriptsExecDryRun(t *testing.T) {
|
||||
dir := jobFixture(t, `version: 1
|
||||
jobs:
|
||||
migrate:
|
||||
command: scripts-exec
|
||||
script_dirs: [migrations]
|
||||
output:
|
||||
conn_env: RELSPEC_TEST_EXEC_CONN
|
||||
`)
|
||||
writeFile(t, filepath.Join(dir, "migrations", "1_001_a.sql"), "CREATE TABLE a();\n")
|
||||
t.Setenv("RELSPEC_TEST_EXEC_CONN", "postgres://u:secretpw@h/db")
|
||||
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
||||
var buf bytes.Buffer
|
||||
if err := executeJobPlan(set, "migrate", true, false, &buf); err != nil {
|
||||
t.Fatalf("dry run: %v", err)
|
||||
}
|
||||
if strings.Contains(buf.String(), "secretpw") {
|
||||
t.Fatalf("plan leaked secret:\n%s", buf.String())
|
||||
}
|
||||
if !strings.Contains(buf.String(), "env:RELSPEC_TEST_EXEC_CONN") {
|
||||
t.Fatalf("plan should name the env var:\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobRun_TemplDatabaseMode(t *testing.T) {
|
||||
dir := jobFixture(t, `version: 1
|
||||
jobs:
|
||||
docs:
|
||||
command: templ
|
||||
inputs:
|
||||
- path: schema/core.dbml
|
||||
format: dbml
|
||||
template: templates/schema.tmpl
|
||||
output:
|
||||
format: text
|
||||
path: build/schema.txt
|
||||
overwrite: true
|
||||
`)
|
||||
writeFile(t, filepath.Join(dir, "templates", "schema.tmpl"), "{{range .Database.Schemas}}{{range .Tables}}{{.Name}} {{end}}{{end}}")
|
||||
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
||||
if err := executeJobPlan(set, "docs", false, false, &bytes.Buffer{}); err != nil {
|
||||
t.Fatalf("execute templ job: %v", err)
|
||||
}
|
||||
out, err := os.ReadFile(filepath.Join(dir, "build", "schema.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("read templ output: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(out), "users") {
|
||||
t.Fatalf("templ output missing users table: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobRun_BunSqlTypesOption(t *testing.T) {
|
||||
dir := jobFixture(t, `version: 1
|
||||
jobs:
|
||||
models:
|
||||
command: convert
|
||||
inputs:
|
||||
- path: schema/core.dbml
|
||||
format: dbml
|
||||
output:
|
||||
format: bun
|
||||
path: build/models
|
||||
options:
|
||||
package: models
|
||||
types: sqltypes
|
||||
`)
|
||||
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
||||
if err := executeJobPlan(set, "models", false, false, &bytes.Buffer{}); err != nil {
|
||||
t.Fatalf("execute Bun job: %v", err)
|
||||
}
|
||||
generated, err := os.ReadFile(filepath.Join(dir, "build", "models"))
|
||||
if err != nil {
|
||||
t.Fatalf("read Bun output: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(generated), "git.warky.dev/wdevs/relspecgo/pkg/sqltypes") {
|
||||
t.Fatalf("Bun output did not preserve options.types: %s", generated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobValidation_TemplTextFormat(t *testing.T) {
|
||||
validDir := jobFixture(t, `version: 1
|
||||
jobs:
|
||||
docs:
|
||||
command: templ
|
||||
inputs: [{path: schema/core.dbml, format: dbml}]
|
||||
template: schema.tmpl
|
||||
output: {format: text, path: build/docs.txt}
|
||||
`)
|
||||
valid := mustLoadSet(t, filepath.Join(validDir, "relspec.yml"))
|
||||
if err := valid.Validate(); err != nil {
|
||||
t.Fatalf("text output should be valid for templ: %v", err)
|
||||
}
|
||||
invalidDir := jobFixture(t, `version: 1
|
||||
jobs:
|
||||
docs:
|
||||
command: templ
|
||||
inputs: [{path: schema/core.dbml, format: dbml}]
|
||||
template: schema.tmpl
|
||||
output: {format: json, path: build/docs.txt}
|
||||
`)
|
||||
invalid, err := jobs.Load([]string{filepath.Join(invalidDir, "relspec.yml")})
|
||||
if err != nil {
|
||||
t.Fatalf("load invalid manifest: %v", err)
|
||||
}
|
||||
if err := invalid.Validate(); err == nil || !strings.Contains(err.Error(), "output.format: text") {
|
||||
t.Fatalf("expected templ format rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
+35
-1
@@ -5,9 +5,43 @@ import (
|
||||
"os"
|
||||
)
|
||||
|
||||
// asciiLogo (see version.go) is printed by the `version` command.
|
||||
|
||||
func main() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
args := os.Args[1:]
|
||||
isSilent := hasSilentFlag(args)
|
||||
if !isSilent {
|
||||
printVersionHeader(args)
|
||||
}
|
||||
|
||||
previousStderr := os.Stderr
|
||||
var nullOutput *os.File
|
||||
if isSilent {
|
||||
var err error
|
||||
nullOutput, err = os.OpenFile(os.DevNull, os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
fmt.Fprintln(previousStderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Stderr = nullOutput
|
||||
}
|
||||
|
||||
err := rootCmd.Execute()
|
||||
if nullOutput != nil {
|
||||
os.Stderr = previousStderr
|
||||
nullOutput.Close()
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func hasSilentFlag(args []string) bool {
|
||||
for _, arg := range args {
|
||||
if arg == "--silent" || arg == "--silent=true" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
+134
-43
@@ -21,6 +21,7 @@ import (
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/json"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/pgsql"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/prisma"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/sqlite"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/typeorm"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/yaml"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
@@ -34,6 +35,7 @@ import (
|
||||
wjson "git.warky.dev/wdevs/relspecgo/pkg/writers/json"
|
||||
wpgsql "git.warky.dev/wdevs/relspecgo/pkg/writers/pgsql"
|
||||
wprisma "git.warky.dev/wdevs/relspecgo/pkg/writers/prisma"
|
||||
wsqlite "git.warky.dev/wdevs/relspecgo/pkg/writers/sqlite"
|
||||
wtypeorm "git.warky.dev/wdevs/relspecgo/pkg/writers/typeorm"
|
||||
wyaml "git.warky.dev/wdevs/relspecgo/pkg/writers/yaml"
|
||||
)
|
||||
@@ -45,6 +47,7 @@ var (
|
||||
mergeSourceType string
|
||||
mergeSourcePath string
|
||||
mergeSourceConn string
|
||||
mergeFromList []string
|
||||
mergeOutputType string
|
||||
mergeOutputPath string
|
||||
mergeOutputConn string
|
||||
@@ -56,7 +59,9 @@ var (
|
||||
mergeSkipTables string // Comma-separated table names to skip
|
||||
mergeVerbose bool
|
||||
mergeReportPath string // Path to write merge report
|
||||
mergeFullDDL bool // Execute full DDL instead of diffing the live pgsql database
|
||||
mergeFlattenSchema bool
|
||||
mergeDryRun bool
|
||||
)
|
||||
|
||||
var mergeCmd = &cobra.Command{
|
||||
@@ -107,13 +112,14 @@ func init() {
|
||||
|
||||
// Source database flags
|
||||
mergeCmd.Flags().StringVar(&mergeSourceType, "source", "", "Source format (required): dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, pgsql")
|
||||
mergeCmd.Flags().StringVar(&mergeSourcePath, "source-path", "", "Source file path (required for file-based formats)")
|
||||
mergeCmd.Flags().StringVar(&mergeSourcePath, "source-path", "", "Source file path (required for file-based formats, mutually exclusive with --from-list)")
|
||||
mergeCmd.Flags().StringVar(&mergeSourceConn, "source-conn", "", "Source connection string (required for pgsql)")
|
||||
mergeCmd.Flags().StringSliceVar(&mergeFromList, "from-list", nil, "Comma-separated list of source file paths to merge (mutually exclusive with --source-path)")
|
||||
|
||||
// Output flags
|
||||
mergeCmd.Flags().StringVar(&mergeOutputType, "output", "", "Output format (required): dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, pgsql")
|
||||
mergeCmd.Flags().StringVar(&mergeOutputPath, "output-path", "", "Output file path (required for file-based formats)")
|
||||
mergeCmd.Flags().StringVar(&mergeOutputConn, "output-conn", "", "Output connection string (for pgsql)")
|
||||
mergeCmd.Flags().StringVar(&mergeOutputConn, "output-conn", "", "Output connection string (for pgsql) or database file path (for sqlite, to execute DDL directly instead of writing a .sql file)")
|
||||
|
||||
// Merge options
|
||||
mergeCmd.Flags().BoolVar(&mergeSkipDomains, "skip-domains", false, "Skip domains during merge")
|
||||
@@ -123,7 +129,9 @@ func init() {
|
||||
mergeCmd.Flags().BoolVar(&mergeSkipSequences, "skip-sequences", false, "Skip sequences during merge")
|
||||
mergeCmd.Flags().StringVar(&mergeSkipTables, "skip-tables", "", "Comma-separated list of table names to skip during merge")
|
||||
mergeCmd.Flags().BoolVar(&mergeVerbose, "verbose", false, "Show verbose output")
|
||||
mergeCmd.Flags().BoolVar(&mergeFullDDL, "full-ddl", false, "pgsql database output: execute the full DDL instead of only the differences from the live database")
|
||||
mergeCmd.Flags().StringVar(&mergeReportPath, "merge-report", "", "Path to write merge report (JSON format)")
|
||||
mergeCmd.Flags().BoolVar(&mergeDryRun, "dry-run", false, "Read and merge in memory, then print the merge plan without writing any output")
|
||||
mergeCmd.Flags().BoolVar(&mergeFlattenSchema, "flatten-schema", false, "Flatten schema.table names to schema_table (useful for databases like SQLite that do not support schemas)")
|
||||
}
|
||||
|
||||
@@ -142,6 +150,11 @@ func runMerge(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("--output format is required")
|
||||
}
|
||||
|
||||
// Validate mutually exclusive source flags
|
||||
if mergeSourcePath != "" && len(mergeFromList) > 0 {
|
||||
return fmt.Errorf("--source-path and --from-list are mutually exclusive")
|
||||
}
|
||||
|
||||
// Validate and expand file paths
|
||||
if mergeTargetType != "pgsql" {
|
||||
if mergeTargetPath == "" {
|
||||
@@ -149,14 +162,12 @@ func runMerge(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
mergeTargetPath = expandPath(mergeTargetPath)
|
||||
} else if mergeTargetConn == "" {
|
||||
|
||||
return fmt.Errorf("--target-conn is required for pgsql format")
|
||||
|
||||
}
|
||||
|
||||
if mergeSourceType != "pgsql" {
|
||||
if mergeSourcePath == "" {
|
||||
return fmt.Errorf("--source-path is required for %s format", mergeSourceType)
|
||||
if mergeSourcePath == "" && len(mergeFromList) == 0 {
|
||||
return fmt.Errorf("--source-path or --from-list is required for %s format", mergeSourceType)
|
||||
}
|
||||
mergeSourcePath = expandPath(mergeSourcePath)
|
||||
} else if mergeSourceConn == "" {
|
||||
@@ -171,7 +182,7 @@ func runMerge(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
// Step 1: Read target database
|
||||
fmt.Fprintf(os.Stderr, "[1/3] Reading target database...\n")
|
||||
fmt.Fprintf(os.Stderr, "[1/4] Reading target database...\n")
|
||||
fmt.Fprintf(os.Stderr, " Format: %s\n", mergeTargetType)
|
||||
if mergeTargetPath != "" {
|
||||
fmt.Fprintf(os.Stderr, " Path: %s\n", mergeTargetPath)
|
||||
@@ -187,25 +198,42 @@ func runMerge(cmd *cobra.Command, args []string) error {
|
||||
fmt.Fprintf(os.Stderr, " ✓ Successfully read target database '%s'\n", targetDB.Name)
|
||||
printDatabaseStats(targetDB)
|
||||
|
||||
// Step 2: Read source database
|
||||
fmt.Fprintf(os.Stderr, "\n[2/3] Reading source database...\n")
|
||||
// Step 2: Read source database(s)
|
||||
fmt.Fprintf(os.Stderr, "\n[2/4] Reading source database...\n")
|
||||
fmt.Fprintf(os.Stderr, " Format: %s\n", mergeSourceType)
|
||||
if mergeSourcePath != "" {
|
||||
fmt.Fprintf(os.Stderr, " Path: %s\n", mergeSourcePath)
|
||||
}
|
||||
if mergeSourceConn != "" {
|
||||
fmt.Fprintf(os.Stderr, " Conn: %s\n", maskPassword(mergeSourceConn))
|
||||
}
|
||||
|
||||
sourceDB, err := readDatabaseForMerge(mergeSourceType, mergeSourcePath, mergeSourceConn, "Source")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read source database: %w", err)
|
||||
var sourceDB *models.Database
|
||||
if len(mergeFromList) > 0 {
|
||||
fmt.Fprintf(os.Stderr, " Files: %d file(s)\n", len(mergeFromList))
|
||||
for i, filePath := range mergeFromList {
|
||||
fmt.Fprintf(os.Stderr, " [%d/%d] %s\n", i+1, len(mergeFromList), filePath)
|
||||
db, readErr := readDatabaseForMerge(mergeSourceType, expandPath(filePath), "", "Source")
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("failed to read source file %s: %w", filePath, readErr)
|
||||
}
|
||||
if sourceDB == nil {
|
||||
sourceDB = db
|
||||
} else {
|
||||
merge.MergeDatabases(sourceDB, db, &merge.MergeOptions{})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if mergeSourcePath != "" {
|
||||
fmt.Fprintf(os.Stderr, " Path: %s\n", mergeSourcePath)
|
||||
}
|
||||
if mergeSourceConn != "" {
|
||||
fmt.Fprintf(os.Stderr, " Conn: %s\n", maskPassword(mergeSourceConn))
|
||||
}
|
||||
sourceDB, err = readDatabaseForMerge(mergeSourceType, mergeSourcePath, mergeSourceConn, "Source")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read source database: %w", err)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, " ✓ Successfully read source database '%s'\n", sourceDB.Name)
|
||||
printDatabaseStats(sourceDB)
|
||||
|
||||
// Step 3: Merge databases
|
||||
fmt.Fprintf(os.Stderr, "\n[3/3] Merging databases...\n")
|
||||
fmt.Fprintf(os.Stderr, "\n[3/4] Merging databases...\n")
|
||||
|
||||
opts := &merge.MergeOptions{
|
||||
SkipDomains: mergeSkipDomains,
|
||||
@@ -224,6 +252,7 @@ func runMerge(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
result := merge.MergeDatabases(targetDB, sourceDB, opts)
|
||||
finalizeCommentedRefs(targetDB, stderrWarn)
|
||||
|
||||
// Update timestamp
|
||||
targetDB.UpdateDate()
|
||||
@@ -232,12 +261,43 @@ func runMerge(cmd *cobra.Command, args []string) error {
|
||||
fmt.Fprintf(os.Stderr, " ✓ Merge complete\n\n")
|
||||
fmt.Fprintf(os.Stderr, "%s\n", merge.GetMergeSummary(result))
|
||||
|
||||
if strings.EqualFold(mergeOutputType, "pgsql") && len(result.TypeConflicts) > 0 {
|
||||
return fmt.Errorf("merge detected conflicting existing column types and cannot safely continue with pgsql output\n%s",
|
||||
merge.GetColumnTypeConflictSummary(result, 10))
|
||||
}
|
||||
|
||||
if mergeDryRun {
|
||||
if !isMergeOutputFormat(mergeOutputType) {
|
||||
return fmt.Errorf("dry run validation failed: Output: unsupported format '%s'", mergeOutputType)
|
||||
}
|
||||
out := outWriter(cmd)
|
||||
fmt.Fprintf(out, "RelSpec merge plan (dry run - nothing written):\n")
|
||||
fmt.Fprintf(out, " Target: %s database '%s'\n", mergeTargetType, targetDB.Name)
|
||||
fmt.Fprintf(out, " Source: %s database '%s'\n", mergeSourceType, sourceDB.Name)
|
||||
switch {
|
||||
case mergeOutputPath != "":
|
||||
fmt.Fprintf(out, " Output: %s -> %s\n", mergeOutputType, mergeOutputPath)
|
||||
case mergeOutputConn != "":
|
||||
fmt.Fprintf(out, " Output: %s -> %s\n", mergeOutputType, maskPassword(mergeOutputConn))
|
||||
default:
|
||||
fmt.Fprintf(out, " Output: %s\n", mergeOutputType)
|
||||
}
|
||||
fmt.Fprintf(out, " Result:\n")
|
||||
printDryRunPlan(out, targetDB)
|
||||
fmt.Fprintf(out, "\n%s\n", merge.GetMergeSummary(result))
|
||||
fmt.Fprintf(os.Stderr, "\n=== Dry run complete: no output written ===\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Step 4: Write output
|
||||
fmt.Fprintf(os.Stderr, "\n[4/4] Writing output...\n")
|
||||
fmt.Fprintf(os.Stderr, " Format: %s\n", mergeOutputType)
|
||||
if mergeOutputPath != "" {
|
||||
fmt.Fprintf(os.Stderr, " Path: %s\n", mergeOutputPath)
|
||||
}
|
||||
if mergeOutputConn != "" {
|
||||
fmt.Fprintf(os.Stderr, " Conn: %s\n", maskPassword(mergeOutputConn))
|
||||
}
|
||||
|
||||
err = writeDatabaseForMerge(mergeOutputType, mergeOutputPath, mergeOutputConn, targetDB, "Output", mergeFlattenSchema)
|
||||
if err != nil {
|
||||
@@ -250,6 +310,16 @@ func runMerge(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// isMergeOutputFormat reports whether writeDatabaseForMerge supports dbType.
|
||||
func isMergeOutputFormat(dbType string) bool {
|
||||
switch strings.ToLower(dbType) {
|
||||
case "dbml", "dctx", "drawdb", "graphql", "json", "yaml", "gorm", "bun",
|
||||
"drizzle", "prisma", "typeorm", "sqlite", "sqlite3", "pgsql":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func readDatabaseForMerge(dbType, filePath, connString, label string) (*models.Database, error) {
|
||||
var reader readers.Reader
|
||||
|
||||
@@ -258,62 +328,72 @@ func readDatabaseForMerge(dbType, filePath, connString, label string) (*models.D
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for DBML format", label)
|
||||
}
|
||||
reader = dbml.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = dbml.NewReader(newReaderOptions(filePath, ""))
|
||||
case "dctx":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for DCTX format", label)
|
||||
}
|
||||
reader = dctx.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = dctx.NewReader(newReaderOptions(filePath, ""))
|
||||
case "drawdb":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for DrawDB format", label)
|
||||
}
|
||||
reader = drawdb.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = drawdb.NewReader(newReaderOptions(filePath, ""))
|
||||
case "graphql":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for GraphQL format", label)
|
||||
}
|
||||
reader = graphql.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = graphql.NewReader(newReaderOptions(filePath, ""))
|
||||
case "json":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for JSON format", label)
|
||||
}
|
||||
reader = json.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = json.NewReader(newReaderOptions(filePath, ""))
|
||||
case "yaml":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for YAML format", label)
|
||||
}
|
||||
reader = yaml.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = yaml.NewReader(newReaderOptions(filePath, ""))
|
||||
case "gorm":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for GORM format", label)
|
||||
}
|
||||
reader = gorm.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = gorm.NewReader(newReaderOptions(filePath, ""))
|
||||
case "bun":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for Bun format", label)
|
||||
}
|
||||
reader = bun.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = bun.NewReader(newReaderOptions(filePath, ""))
|
||||
case "drizzle":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for Drizzle format", label)
|
||||
}
|
||||
reader = drizzle.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = drizzle.NewReader(newReaderOptions(filePath, ""))
|
||||
case "prisma":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for Prisma format", label)
|
||||
}
|
||||
reader = prisma.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = prisma.NewReader(newReaderOptions(filePath, ""))
|
||||
case "typeorm":
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("%s: file path is required for TypeORM format", label)
|
||||
}
|
||||
reader = typeorm.NewReader(&readers.ReaderOptions{FilePath: filePath})
|
||||
reader = typeorm.NewReader(newReaderOptions(filePath, ""))
|
||||
case "pgsql":
|
||||
if connString == "" {
|
||||
return nil, fmt.Errorf("%s: connection string is required for PostgreSQL format", label)
|
||||
}
|
||||
reader = pgsql.NewReader(&readers.ReaderOptions{ConnectionString: connString})
|
||||
reader = pgsql.NewReader(newReaderOptions("", connString))
|
||||
case "sqlite", "sqlite3":
|
||||
// SQLite can use either file path or connection string
|
||||
dbPath := filePath
|
||||
if dbPath == "" {
|
||||
dbPath = connString
|
||||
}
|
||||
if dbPath == "" {
|
||||
return nil, fmt.Errorf("%s: file path or connection string is required for SQLite format", label)
|
||||
}
|
||||
reader = sqlite.NewReader(newReaderOptions(dbPath, ""))
|
||||
default:
|
||||
return nil, fmt.Errorf("%s: unsupported format '%s'", label, dbType)
|
||||
}
|
||||
@@ -334,62 +414,73 @@ func writeDatabaseForMerge(dbType, filePath, connString string, db *models.Datab
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("%s: file path is required for DBML format", label)
|
||||
}
|
||||
writer = wdbml.NewWriter(&writers.WriterOptions{OutputPath: filePath, FlattenSchema: flattenSchema})
|
||||
writer = wdbml.NewWriter(newWriterOptions(filePath, "", flattenSchema, "", "", false))
|
||||
case "dctx":
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("%s: file path is required for DCTX format", label)
|
||||
}
|
||||
writer = wdctx.NewWriter(&writers.WriterOptions{OutputPath: filePath, FlattenSchema: flattenSchema})
|
||||
writer = wdctx.NewWriter(newWriterOptions(filePath, "", flattenSchema, "", "", false))
|
||||
case "drawdb":
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("%s: file path is required for DrawDB format", label)
|
||||
}
|
||||
writer = wdrawdb.NewWriter(&writers.WriterOptions{OutputPath: filePath, FlattenSchema: flattenSchema})
|
||||
writer = wdrawdb.NewWriter(newWriterOptions(filePath, "", flattenSchema, "", "", false))
|
||||
case "graphql":
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("%s: file path is required for GraphQL format", label)
|
||||
}
|
||||
writer = wgraphql.NewWriter(&writers.WriterOptions{OutputPath: filePath, FlattenSchema: flattenSchema})
|
||||
writer = wgraphql.NewWriter(newWriterOptions(filePath, "", flattenSchema, "", "", false))
|
||||
case "json":
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("%s: file path is required for JSON format", label)
|
||||
}
|
||||
writer = wjson.NewWriter(&writers.WriterOptions{OutputPath: filePath, FlattenSchema: flattenSchema})
|
||||
writer = wjson.NewWriter(newWriterOptions(filePath, "", flattenSchema, "", "", false))
|
||||
case "yaml":
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("%s: file path is required for YAML format", label)
|
||||
}
|
||||
writer = wyaml.NewWriter(&writers.WriterOptions{OutputPath: filePath, FlattenSchema: flattenSchema})
|
||||
writer = wyaml.NewWriter(newWriterOptions(filePath, "", flattenSchema, "", "", false))
|
||||
case "gorm":
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("%s: file path is required for GORM format", label)
|
||||
}
|
||||
writer = wgorm.NewWriter(&writers.WriterOptions{OutputPath: filePath, FlattenSchema: flattenSchema})
|
||||
writer = wgorm.NewWriter(newWriterOptions(filePath, "", flattenSchema, "", "", false))
|
||||
case "bun":
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("%s: file path is required for Bun format", label)
|
||||
}
|
||||
writer = wbun.NewWriter(&writers.WriterOptions{OutputPath: filePath, FlattenSchema: flattenSchema})
|
||||
writer = wbun.NewWriter(newWriterOptions(filePath, "", flattenSchema, "", "", false))
|
||||
case "drizzle":
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("%s: file path is required for Drizzle format", label)
|
||||
}
|
||||
writer = wdrizzle.NewWriter(&writers.WriterOptions{OutputPath: filePath, FlattenSchema: flattenSchema})
|
||||
writer = wdrizzle.NewWriter(newWriterOptions(filePath, "", flattenSchema, "", "", false))
|
||||
case "prisma":
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("%s: file path is required for Prisma format", label)
|
||||
}
|
||||
writer = wprisma.NewWriter(&writers.WriterOptions{OutputPath: filePath, FlattenSchema: flattenSchema})
|
||||
writer = wprisma.NewWriter(newWriterOptions(filePath, "", flattenSchema, "", "", false))
|
||||
case "typeorm":
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("%s: file path is required for TypeORM format", label)
|
||||
}
|
||||
writer = wtypeorm.NewWriter(&writers.WriterOptions{OutputPath: filePath, FlattenSchema: flattenSchema})
|
||||
writer = wtypeorm.NewWriter(newWriterOptions(filePath, "", flattenSchema, "", "", false))
|
||||
case "sqlite", "sqlite3":
|
||||
writerOpts := newWriterOptions(filePath, "", flattenSchema, "", "", false)
|
||||
if connString != "" {
|
||||
// Execute DDL directly against the SQLite database file instead
|
||||
// of writing a .sql script.
|
||||
writerOpts.Metadata = map[string]interface{}{
|
||||
"connection_string": connString,
|
||||
}
|
||||
}
|
||||
writer = wsqlite.NewWriter(writerOpts)
|
||||
case "pgsql":
|
||||
writerOpts := &writers.WriterOptions{OutputPath: filePath, FlattenSchema: flattenSchema}
|
||||
writerOpts := newWriterOptions(filePath, "", flattenSchema, "", "", false)
|
||||
if connString != "" {
|
||||
writerOpts.Metadata = map[string]interface{}{
|
||||
"connection_string": connString,
|
||||
"full_ddl": mergeFullDDL,
|
||||
}
|
||||
// Add report path if merge report is enabled
|
||||
if mergeReportPath != "" {
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunMerge_FromListMutuallyExclusiveWithSourcePath(t *testing.T) {
|
||||
saved := saveMergeState()
|
||||
defer restoreMergeState(saved)
|
||||
|
||||
dir := t.TempDir()
|
||||
file := filepath.Join(dir, "schema.json")
|
||||
writeTestJSON(t, file, []string{"users"})
|
||||
|
||||
mergeTargetType = "json"
|
||||
mergeTargetPath = file
|
||||
mergeTargetConn = ""
|
||||
mergeSourceType = "json"
|
||||
mergeSourcePath = file
|
||||
mergeSourceConn = ""
|
||||
mergeFromList = []string{file}
|
||||
mergeOutputType = "json"
|
||||
mergeOutputPath = filepath.Join(dir, "out.json")
|
||||
mergeOutputConn = ""
|
||||
mergeSkipTables = ""
|
||||
mergeReportPath = ""
|
||||
|
||||
err := runMerge(nil, nil)
|
||||
if err == nil {
|
||||
t.Error("expected error when --source-path and --from-list are both set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMerge_FromListSingleFile(t *testing.T) {
|
||||
saved := saveMergeState()
|
||||
defer restoreMergeState(saved)
|
||||
|
||||
dir := t.TempDir()
|
||||
targetFile := filepath.Join(dir, "target.json")
|
||||
sourceFile := filepath.Join(dir, "source.json")
|
||||
outFile := filepath.Join(dir, "output.json")
|
||||
writeTestJSON(t, targetFile, []string{"users"})
|
||||
writeTestJSON(t, sourceFile, []string{"posts"})
|
||||
|
||||
mergeTargetType = "json"
|
||||
mergeTargetPath = targetFile
|
||||
mergeTargetConn = ""
|
||||
mergeSourceType = "json"
|
||||
mergeSourcePath = ""
|
||||
mergeSourceConn = ""
|
||||
mergeFromList = []string{sourceFile}
|
||||
mergeOutputType = "json"
|
||||
mergeOutputPath = outFile
|
||||
mergeOutputConn = ""
|
||||
mergeSkipTables = ""
|
||||
mergeReportPath = ""
|
||||
|
||||
if err := runMerge(nil, nil); err != nil {
|
||||
t.Fatalf("runMerge() error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(outFile); os.IsNotExist(err) {
|
||||
t.Error("expected output file to be created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMerge_FromListMultipleFiles(t *testing.T) {
|
||||
saved := saveMergeState()
|
||||
defer restoreMergeState(saved)
|
||||
|
||||
dir := t.TempDir()
|
||||
targetFile := filepath.Join(dir, "target.json")
|
||||
source1 := filepath.Join(dir, "source1.json")
|
||||
source2 := filepath.Join(dir, "source2.json")
|
||||
outFile := filepath.Join(dir, "output.json")
|
||||
writeTestJSON(t, targetFile, []string{"users"})
|
||||
writeTestJSON(t, source1, []string{"posts"})
|
||||
writeTestJSON(t, source2, []string{"comments"})
|
||||
|
||||
mergeTargetType = "json"
|
||||
mergeTargetPath = targetFile
|
||||
mergeTargetConn = ""
|
||||
mergeSourceType = "json"
|
||||
mergeSourcePath = ""
|
||||
mergeSourceConn = ""
|
||||
mergeFromList = []string{source1, source2}
|
||||
mergeOutputType = "json"
|
||||
mergeOutputPath = outFile
|
||||
mergeOutputConn = ""
|
||||
mergeSkipTables = ""
|
||||
mergeReportPath = ""
|
||||
|
||||
if err := runMerge(nil, nil); err != nil {
|
||||
t.Fatalf("runMerge() error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(outFile); os.IsNotExist(err) {
|
||||
t.Error("expected output file to be created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMerge_FromListPathWithSpaces(t *testing.T) {
|
||||
saved := saveMergeState()
|
||||
defer restoreMergeState(saved)
|
||||
|
||||
spacedDir := filepath.Join(t.TempDir(), "my schema files")
|
||||
if err := os.MkdirAll(spacedDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
targetFile := filepath.Join(spacedDir, "target schema.json")
|
||||
sourceFile := filepath.Join(spacedDir, "source schema.json")
|
||||
outFile := filepath.Join(spacedDir, "merged output.json")
|
||||
writeTestJSON(t, targetFile, []string{"users"})
|
||||
writeTestJSON(t, sourceFile, []string{"comments"})
|
||||
|
||||
mergeTargetType = "json"
|
||||
mergeTargetPath = targetFile
|
||||
mergeTargetConn = ""
|
||||
mergeSourceType = "json"
|
||||
mergeSourcePath = ""
|
||||
mergeSourceConn = ""
|
||||
mergeFromList = []string{sourceFile}
|
||||
mergeOutputType = "json"
|
||||
mergeOutputPath = outFile
|
||||
mergeOutputConn = ""
|
||||
mergeSkipTables = ""
|
||||
mergeReportPath = ""
|
||||
|
||||
if err := runMerge(nil, nil); err != nil {
|
||||
t.Fatalf("runMerge() with spaced paths error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(outFile); os.IsNotExist(err) {
|
||||
t.Error("expected output file to be created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMerge_FromListMissingSourceType(t *testing.T) {
|
||||
saved := saveMergeState()
|
||||
defer restoreMergeState(saved)
|
||||
|
||||
dir := t.TempDir()
|
||||
file := filepath.Join(dir, "schema.json")
|
||||
writeTestJSON(t, file, []string{"users"})
|
||||
|
||||
mergeTargetType = "json"
|
||||
mergeTargetPath = file
|
||||
mergeTargetConn = ""
|
||||
mergeSourceType = "json"
|
||||
mergeSourcePath = ""
|
||||
mergeSourceConn = ""
|
||||
mergeFromList = []string{} // empty list, no source-path either
|
||||
mergeOutputType = "json"
|
||||
mergeOutputPath = filepath.Join(dir, "out.json")
|
||||
mergeOutputConn = ""
|
||||
mergeSkipTables = ""
|
||||
mergeReportPath = ""
|
||||
|
||||
err := runMerge(nil, nil)
|
||||
if err == nil {
|
||||
t.Error("expected error when neither --source-path nor --from-list is provided")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMerge_PgsqlOutputRejectsColumnTypeConflict(t *testing.T) {
|
||||
saved := saveMergeState()
|
||||
defer restoreMergeState(saved)
|
||||
|
||||
dir := t.TempDir()
|
||||
targetFile := filepath.Join(dir, "target.json")
|
||||
sourceFile := filepath.Join(dir, "source.json")
|
||||
writeTestJSONWithSingleColumnType(t, targetFile, "users", "integer")
|
||||
writeTestJSONWithSingleColumnType(t, sourceFile, "users", "uuid")
|
||||
|
||||
mergeTargetType = "json"
|
||||
mergeTargetPath = targetFile
|
||||
mergeTargetConn = ""
|
||||
mergeSourceType = "json"
|
||||
mergeSourcePath = sourceFile
|
||||
mergeSourceConn = ""
|
||||
mergeFromList = nil
|
||||
mergeOutputType = "pgsql"
|
||||
mergeOutputPath = ""
|
||||
mergeOutputConn = "postgres://relspec:secret@localhost/testdb"
|
||||
mergeSkipTables = ""
|
||||
mergeReportPath = ""
|
||||
|
||||
err := runMerge(nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected pgsql output merge to fail on column type conflict")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "column type conflicts detected") {
|
||||
t.Fatalf("expected conflict summary in error, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "public.users.id") {
|
||||
t.Fatalf("expected conflicting column path in error, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestReadDatabaseForMerge(t *testing.T) {
|
||||
for _, tt := range readableFormats {
|
||||
t.Run(tt.format, func(t *testing.T) {
|
||||
db, err := readDatabaseForMerge(tt.format, filepath.Join(fixturesDir, tt.path), "", "Target")
|
||||
if err != nil {
|
||||
t.Skipf("format %s not supported by merge reader: %v", tt.format, err)
|
||||
}
|
||||
if db == nil || len(db.Schemas) == 0 {
|
||||
t.Errorf("no schemas: %+v", db)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, f := range []string{"dbml", "dctx", "drawdb", "graphql", "json", "yaml", "gorm", "bun", "drizzle", "prisma", "typeorm"} {
|
||||
if _, err := readDatabaseForMerge(f, "", "", "Src"); err == nil || !strings.Contains(err.Error(), "Src: file path is required") {
|
||||
t.Errorf("%s missing path: %v", f, err)
|
||||
}
|
||||
}
|
||||
if _, err := readDatabaseForMerge("pgsql", "", "", "Src"); err == nil || !strings.Contains(err.Error(), "Src:") {
|
||||
t.Errorf("pgsql: %v", err)
|
||||
}
|
||||
if _, err := readDatabaseForMerge("sqlite", "", "", "Src"); err == nil || !strings.Contains(err.Error(), "Src:") {
|
||||
t.Errorf("sqlite: %v", err)
|
||||
}
|
||||
if _, err := readDatabaseForMerge("nope", "x", "", "Src"); err == nil || !strings.Contains(err.Error(), "unsupported format 'nope'") {
|
||||
t.Errorf("unsupported: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteDatabaseForMerge(t *testing.T) {
|
||||
db := multiSchemaDB()
|
||||
single := multiSchemaDB()
|
||||
single.Schemas = single.Schemas[:1]
|
||||
|
||||
files := map[string]string{
|
||||
"dbml": "o.dbml", "dctx": "o.dctx", "drawdb": "o.drawdb.json", "graphql": "o.graphql",
|
||||
"json": "o.json", "yaml": "o.yaml", "gorm": "gorm.go", "bun": "bun.go",
|
||||
"drizzle": "o.ts", "prisma": "o.prisma", "typeorm": "te.ts",
|
||||
}
|
||||
for f, name := range files {
|
||||
t.Run(f, func(t *testing.T) {
|
||||
out := filepath.Join(t.TempDir(), name)
|
||||
if f == "dctx" {
|
||||
// DCTX cannot write a full database.
|
||||
if err := writeDatabaseForMerge(f, out, "", single, "Output", false); err == nil || !strings.Contains(err.Error(), "not supported for DCTX") {
|
||||
t.Errorf("dctx: %v", err)
|
||||
}
|
||||
if err := writeDatabaseForMerge(f, "", "", single, "Output", false); err == nil || !strings.Contains(err.Error(), "file path is required") {
|
||||
t.Errorf("dctx missing path: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
src := db
|
||||
if err := writeDatabaseForMerge(f, out, "", src, "Output", false); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(out); err != nil {
|
||||
t.Errorf("no output: %v", err)
|
||||
}
|
||||
if err := writeDatabaseForMerge(f, "", "", src, "Output", false); err == nil || !strings.Contains(err.Error(), "Output: file path is required") {
|
||||
t.Errorf("missing path: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, f := range []string{"pgsql", "sqlite"} {
|
||||
out := filepath.Join(t.TempDir(), "o.sql")
|
||||
if err := writeDatabaseForMerge(f, out, "", db, "Output", false); err != nil {
|
||||
t.Errorf("%s script write: %v", f, err)
|
||||
}
|
||||
}
|
||||
if err := writeDatabaseForMerge("pgsql", "", "postgres://u:p@127.0.0.1:1/none?connect_timeout=1", db, "Output", false); err == nil {
|
||||
t.Error("pgsql with unreachable conn must fail")
|
||||
}
|
||||
if err := writeDatabaseForMerge("nope", "x", "", db, "Output", false); err == nil || !strings.Contains(err.Error(), "unsupported") {
|
||||
t.Errorf("unsupported: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsMergeOutputFormat(t *testing.T) {
|
||||
for _, f := range []string{"dbml", "JSON", "pgsql", "sqlite3", "prisma"} {
|
||||
if !isMergeOutputFormat(f) {
|
||||
t.Errorf("%s should be supported", f)
|
||||
}
|
||||
}
|
||||
for _, f := range []string{"", "nope", "mssql"} {
|
||||
if isMergeOutputFormat(f) {
|
||||
t.Errorf("%s should not be supported", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandPath(t *testing.T) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Skip("no home dir")
|
||||
}
|
||||
tests := []struct{ in, want string }{
|
||||
{"", ""},
|
||||
{"/abs/path", "/abs/path"},
|
||||
{"rel/path", "rel/path"},
|
||||
{"~/x/y", filepath.Join(home, "/x/y")},
|
||||
{"~", home},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := expandPath(tt.in); got != tt.want {
|
||||
t.Errorf("expandPath(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSkipTables(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want []string
|
||||
}{
|
||||
{"", nil},
|
||||
{" , ,", nil},
|
||||
{"Users", []string{"users"}},
|
||||
{" Users , ORDERS,,items ", []string{"users", "orders", "items"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := parseSkipTables(tt.in)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Errorf("parseSkipTables(%q) = %v", tt.in, got)
|
||||
}
|
||||
for _, w := range tt.want {
|
||||
if !got[w] {
|
||||
t.Errorf("parseSkipTables(%q) missing %q", tt.in, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadDatabaseForInspect(t *testing.T) {
|
||||
for _, tt := range readableFormats {
|
||||
t.Run(tt.format, func(t *testing.T) {
|
||||
db, err := readDatabaseForInspect(tt.format, filepath.Join(fixturesDir, tt.path), "")
|
||||
if err != nil {
|
||||
t.Skipf("format %s not supported by inspect reader: %v", tt.format, err)
|
||||
}
|
||||
if db == nil || len(db.Schemas) == 0 {
|
||||
t.Errorf("no schemas: %+v", db)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, f := range []string{"dbml", "dctx", "drawdb", "graphql", "json", "yaml", "gorm", "bun", "drizzle", "prisma", "typeorm"} {
|
||||
if _, err := readDatabaseForInspect(f, "", ""); err == nil || !strings.Contains(err.Error(), "file path is required") {
|
||||
t.Errorf("%s missing path: %v", f, err)
|
||||
}
|
||||
}
|
||||
if _, err := readDatabaseForInspect("pgsql", "", ""); err == nil {
|
||||
t.Error("pgsql without conn must fail")
|
||||
}
|
||||
if _, err := readDatabaseForInspect("nope", "x", ""); err == nil || !strings.Contains(err.Error(), "unsupported database type") {
|
||||
t.Errorf("unsupported: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterDatabaseBySchema(t *testing.T) {
|
||||
db := multiSchemaDB()
|
||||
db.Description = "desc"
|
||||
got := filterDatabaseBySchema(db, "b")
|
||||
if len(got.Schemas) != 1 || got.Schemas[0].Name != "b" || got.Name != db.Name || got.Description != "desc" {
|
||||
t.Errorf("filtered: %+v", got)
|
||||
}
|
||||
if got := filterDatabaseBySchema(db, "zzz"); len(got.Schemas) != 0 {
|
||||
t.Errorf("missing schema should yield no schemas: %+v", got.Schemas)
|
||||
}
|
||||
if len(db.Schemas) != 2 {
|
||||
t.Error("input mutated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasSilentFlag(t *testing.T) {
|
||||
tests := []struct {
|
||||
args []string
|
||||
want bool
|
||||
}{
|
||||
{nil, false},
|
||||
{[]string{"convert"}, false},
|
||||
{[]string{"convert", "--silent"}, true},
|
||||
{[]string{"--silent=true"}, true},
|
||||
{[]string{"--silent=false"}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := hasSilentFlag(tt.args); got != tt.want {
|
||||
t.Errorf("hasSilentFlag(%v) = %v", tt.args, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintVersionHeader(t *testing.T) {
|
||||
capture := func(args []string) string {
|
||||
old := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
printVersionHeader(args)
|
||||
w.Close()
|
||||
os.Stdout = old
|
||||
b := make([]byte, 4096)
|
||||
n, _ := r.Read(b)
|
||||
return string(b[:n])
|
||||
}
|
||||
if out := capture([]string{"convert"}); !strings.HasPrefix(out, "RelSpec ") {
|
||||
t.Errorf("header: %q", out)
|
||||
}
|
||||
if out := capture([]string{"convert", "--no-version"}); out != "" {
|
||||
t.Errorf("--no-version: %q", out)
|
||||
}
|
||||
if out := capture([]string{"version"}); out != "" {
|
||||
t.Errorf("version cmd: %q", out)
|
||||
}
|
||||
if out := capture(nil); !strings.HasPrefix(out, "RelSpec ") {
|
||||
t.Errorf("no args: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportState(t *testing.T) {
|
||||
cfg := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", cfg)
|
||||
t.Setenv("HOME", cfg)
|
||||
|
||||
dir, err := reportStateDir()
|
||||
if err != nil || !strings.HasPrefix(dir, cfg) {
|
||||
t.Fatalf("dir: %q %v", dir, err)
|
||||
}
|
||||
|
||||
state, path, err := loadReportState()
|
||||
if err != nil || !state.LastReport.IsZero() || state.MachineID != "" {
|
||||
t.Fatalf("fresh state: %+v %v", state, err)
|
||||
}
|
||||
|
||||
want := reportState{LastReport: time.Now().UTC().Truncate(time.Second), MachineID: "abc"}
|
||||
if err := saveReportState(path, want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _, err := loadReportState()
|
||||
if err != nil || !got.LastReport.Equal(want.LastReport) || got.MachineID != "abc" {
|
||||
t.Errorf("round trip: %+v %v", got, err)
|
||||
}
|
||||
|
||||
// Corrupt state is ignored.
|
||||
if err := os.WriteFile(path, []byte("{bad"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _, err := loadReportState(); err != nil || got.MachineID != "" {
|
||||
t.Errorf("corrupt: %+v %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemUniqueID_NonEmpty(t *testing.T) {
|
||||
cfg := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", cfg)
|
||||
state, path, _ := loadReportState()
|
||||
id, err := systemUniqueID(state, path)
|
||||
if err != nil || id == "" {
|
||||
t.Errorf("id: %q %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportToken_Decodes(t *testing.T) {
|
||||
if _, err := reportToken(); err != nil {
|
||||
t.Errorf("token must decode: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitReport_RateLimited(t *testing.T) {
|
||||
cfg := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", cfg)
|
||||
_, path, _ := loadReportState()
|
||||
if err := saveReportState(path, reportState{LastReport: time.Now()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Rate limit rejects before any network call is made.
|
||||
if err := submitReport("bug", "t", "b", "", ""); err == nil || !strings.Contains(err.Error(), "please wait") {
|
||||
t.Errorf("got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
)
|
||||
|
||||
func newReaderOptions(filePath, connString string) *readers.ReaderOptions {
|
||||
return &readers.ReaderOptions{
|
||||
FilePath: filePath,
|
||||
ConnectionString: connString,
|
||||
Prisma7: prisma7,
|
||||
StrictDirectives: strictDirectives,
|
||||
Progress: func(message string) {
|
||||
fmt.Fprintf(os.Stderr, " → %s\n", message)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newWriterOptions(outputPath, packageName string, flattenSchema bool, nullableTypes, nullableArrays string, continueOnError bool) *writers.WriterOptions {
|
||||
return &writers.WriterOptions{
|
||||
OutputPath: outputPath,
|
||||
PackageName: packageName,
|
||||
FlattenSchema: flattenSchema,
|
||||
NullableTypes: nullableTypes,
|
||||
NullableArrays: nullableArrays,
|
||||
TypeMappings: typeMappings,
|
||||
Prisma7: prisma7,
|
||||
ContinueOnError: continueOnError,
|
||||
StrictDirectives: strictDirectives,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
reportAPIBase = "https://git.warky.dev/api/v1/repos/wdevs/relspecgo/issues"
|
||||
reportRateLimit = time.Minute
|
||||
reportTokenB64 = "OGQ4ODlhNmY2ZjQ5NjY5OTA5MTJhYTIyZjcyNzExMTNjZTEyZTRhMQ=="
|
||||
reportStateFile = "report_state.json"
|
||||
)
|
||||
|
||||
var (
|
||||
reportBody string
|
||||
reportName string
|
||||
reportEmail string
|
||||
)
|
||||
|
||||
var reportCmd = &cobra.Command{
|
||||
Use: "report",
|
||||
Short: "Report a bug or feature request against RelSpec",
|
||||
Long: "Report a bug or feature request directly to the RelSpec issue tracker.",
|
||||
}
|
||||
|
||||
var reportBugCmd = &cobra.Command{
|
||||
Use: "bug <title>",
|
||||
Short: "Report a bug",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return submitReport("Bug", args[0], reportBody, reportName, reportEmail)
|
||||
},
|
||||
}
|
||||
|
||||
var reportFeatureCmd = &cobra.Command{
|
||||
Use: "feature <title>",
|
||||
Short: "Report a feature request",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return submitReport("Feature", args[0], reportBody, reportName, reportEmail)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
for _, c := range []*cobra.Command{reportBugCmd, reportFeatureCmd} {
|
||||
c.Flags().StringVar(&reportBody, "body", "", "Detailed description of the report")
|
||||
c.Flags().StringVar(&reportName, "name", "", "Optional name, if you'd like feedback on this report")
|
||||
c.Flags().StringVar(&reportEmail, "email", "", "Optional email address, if you'd like feedback on this report")
|
||||
}
|
||||
reportCmd.AddCommand(reportBugCmd)
|
||||
reportCmd.AddCommand(reportFeatureCmd)
|
||||
}
|
||||
|
||||
type reportState struct {
|
||||
LastReport time.Time `json:"last_report"`
|
||||
MachineID string `json:"machine_id,omitempty"`
|
||||
}
|
||||
|
||||
func reportStateDir() (string, error) {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir := filepath.Join(configDir, "relspec")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
func loadReportState() (reportState, string, error) {
|
||||
dir, err := reportStateDir()
|
||||
if err != nil {
|
||||
return reportState{}, "", err
|
||||
}
|
||||
path := filepath.Join(dir, reportStateFile)
|
||||
|
||||
var state reportState
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
_ = json.Unmarshal(data, &state)
|
||||
}
|
||||
return state, path, nil
|
||||
}
|
||||
|
||||
func saveReportState(path string, state reportState) error {
|
||||
data, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0o600)
|
||||
}
|
||||
|
||||
// systemUniqueID returns the OS machine id, falling back to a locally
|
||||
// persisted UUID if the platform-specific id cannot be read.
|
||||
func systemUniqueID(state reportState, statePath string) (string, error) {
|
||||
if id, err := osMachineID(); err == nil && id != "" {
|
||||
return id, nil
|
||||
}
|
||||
|
||||
if state.MachineID != "" {
|
||||
return state.MachineID, nil
|
||||
}
|
||||
|
||||
id := uuid.NewString()
|
||||
state.MachineID = id
|
||||
if err := saveReportState(statePath, state); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func osMachineID() (string, error) {
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
for _, path := range []string{"/etc/machine-id", "/var/lib/dbus/machine-id"} {
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
return strings.TrimSpace(string(data)), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no machine-id file found")
|
||||
case "darwin":
|
||||
out, err := exec.Command("ioreg", "-rd1", "-c", "IOPlatformExpertDevice").Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
re := regexp.MustCompile(`"IOPlatformUUID"\s*=\s*"([^"]+)"`)
|
||||
match := re.FindSubmatch(out)
|
||||
if match == nil {
|
||||
return "", fmt.Errorf("IOPlatformUUID not found")
|
||||
}
|
||||
return string(match[1]), nil
|
||||
case "windows":
|
||||
out, err := exec.Command("reg", "query", `HKLM\SOFTWARE\Microsoft\Cryptography`, "/v", "MachineGuid").Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
re := regexp.MustCompile(`MachineGuid\s+REG_SZ\s+(\S+)`)
|
||||
match := re.FindSubmatch(out)
|
||||
if match == nil {
|
||||
return "", fmt.Errorf("MachineGuid not found")
|
||||
}
|
||||
return string(match[1]), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported platform: %s", runtime.GOOS)
|
||||
}
|
||||
}
|
||||
|
||||
func reportToken() (string, error) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(reportTokenB64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decode report token: %w", err)
|
||||
}
|
||||
return string(decoded), nil
|
||||
}
|
||||
|
||||
type createIssueRequest struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
func submitReport(kind, title, body, name, email string) error {
|
||||
state, statePath, err := loadReportState()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load report state: %w", err)
|
||||
}
|
||||
|
||||
if !state.LastReport.IsZero() {
|
||||
if wait := reportRateLimit - time.Since(state.LastReport); wait > 0 {
|
||||
return fmt.Errorf("please wait %s before submitting another report", wait.Round(time.Second))
|
||||
}
|
||||
}
|
||||
|
||||
id, err := systemUniqueID(state, statePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("determine system id: %w", err)
|
||||
}
|
||||
|
||||
token, err := reportToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fullTitle := fmt.Sprintf("[%s] %s (id: %s)", kind, title, id)
|
||||
|
||||
fullBody := body
|
||||
if name != "" || email != "" {
|
||||
var contact []string
|
||||
if name != "" {
|
||||
contact = append(contact, "Name: "+name)
|
||||
}
|
||||
if email != "" {
|
||||
contact = append(contact, "Email: "+email)
|
||||
}
|
||||
fullBody = strings.TrimSpace(fullBody + "\n\n---\n" + strings.Join(contact, "\n"))
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(createIssueRequest{Title: fullTitle, Body: fullBody})
|
||||
if err != nil {
|
||||
return fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, reportAPIBase, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "token "+token)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("submit report: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
return fmt.Errorf("submit report: unexpected status %s", resp.Status)
|
||||
}
|
||||
|
||||
state.LastReport = time.Now()
|
||||
state.MachineID = id
|
||||
if err := saveReportState(statePath, state); err != nil {
|
||||
return fmt.Errorf("save report state: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Report submitted: %s\n", fullTitle)
|
||||
return nil
|
||||
}
|
||||
@@ -1,7 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/buildinfo"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
)
|
||||
|
||||
// version/buildDate mirror pkg/buildinfo so existing call sites keep working.
|
||||
// The actual values are set there via ldflags (see Makefile).
|
||||
var (
|
||||
version = buildinfo.Version
|
||||
buildDate = buildinfo.BuildDate
|
||||
prisma7 bool
|
||||
noVersion bool
|
||||
silent bool
|
||||
strictDirectives bool
|
||||
typeMapFlags []string
|
||||
typeMappings map[string]string
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
@@ -13,15 +31,48 @@ bidirectional conversion between various database schema formats.
|
||||
It reads database schemas from multiple sources (live databases, DBML,
|
||||
DCTX, DrawDB, etc.) and writes them to various formats (GORM, Bun,
|
||||
JSON, YAML, SQL, etc.).`,
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
var err error
|
||||
typeMappings, err = writers.ParseTypeMappings(typeMapFlags)
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(convertCmd)
|
||||
rootCmd.AddCommand(batchCmd)
|
||||
rootCmd.AddCommand(diffCmd)
|
||||
rootCmd.AddCommand(inspectCmd)
|
||||
rootCmd.AddCommand(scriptsCmd)
|
||||
rootCmd.AddCommand(jobCmd)
|
||||
rootCmd.AddCommand(assetsCmd)
|
||||
rootCmd.AddCommand(templCmd)
|
||||
rootCmd.AddCommand(editCmd)
|
||||
rootCmd.AddCommand(mergeCmd)
|
||||
rootCmd.AddCommand(splitCmd)
|
||||
rootCmd.AddCommand(versionCmd)
|
||||
rootCmd.AddCommand(updateCmd)
|
||||
rootCmd.AddCommand(reportCmd)
|
||||
rootCmd.PersistentFlags().BoolVar(&prisma7, "prisma7", false, "Use Prisma 7 generator conventions when reading/writing Prisma schemas")
|
||||
rootCmd.PersistentFlags().StringArrayVar(&typeMapFlags, "type-map", nil, "Override a SQL-to-Go type mapping for bun/gorm output as sqltype=gotype (repeatable), e.g. --type-map uuid=uuid.UUID --type-map numeric=decimal.Decimal")
|
||||
rootCmd.PersistentFlags().BoolVar(&noVersion, "no-version", false, "Suppress the RelSpec version header")
|
||||
rootCmd.PersistentFlags().BoolVar(&silent, "silent", false, "Suppress progress and status messages (errors are still shown)")
|
||||
rootCmd.PersistentFlags().BoolVar(&strictDirectives, "strict-directives", false, "Fail on unknown or untranslatable DBML dialect directives (@postgres:, @sqlite:, …)")
|
||||
}
|
||||
|
||||
// printVersionHeader prints the "RelSpec <version> (built: <date>)" banner
|
||||
// that precedes all command output. It is invoked from main() before cobra
|
||||
// parses/executes anything, so it runs even for --help and bare invocations.
|
||||
// It is skipped when --no-version is present, or when the version subcommand
|
||||
// is being run (which prints its own, more detailed output).
|
||||
func printVersionHeader(args []string) {
|
||||
for _, a := range args {
|
||||
if a == "--no-version" {
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(args) > 0 && args[0] == "version" {
|
||||
return
|
||||
}
|
||||
fmt.Printf("RelSpec %s (built: %s)\n\n", version, buildDate)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunDiff(t *testing.T) {
|
||||
oldS, oldSP, oldSC, oldT, oldTP, oldTC, oldF, oldO := sourceType, sourcePath, sourceConn, targetType, targetPath, targetConn, outputFormat, outputPath
|
||||
t.Cleanup(func() {
|
||||
sourceType, sourcePath, sourceConn, targetType, targetPath, targetConn, outputFormat, outputPath = oldS, oldSP, oldSC, oldT, oldTP, oldTC, oldF, oldO
|
||||
})
|
||||
|
||||
src := filepath.Join(fixturesDir, "dbml/simple.dbml")
|
||||
cmplx := filepath.Join(fixturesDir, "dbml/complex.dbml")
|
||||
|
||||
for _, format := range []string{"summary", "json", "html"} {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
sourceType, sourcePath, sourceConn = "dbml", src, ""
|
||||
targetType, targetPath, targetConn = "dbml", cmplx, ""
|
||||
outputFormat = format
|
||||
outputPath = filepath.Join(t.TempDir(), "diff.out")
|
||||
if format == "summary" {
|
||||
outputPath = ""
|
||||
}
|
||||
if err := runDiff(nil, nil); err != nil {
|
||||
t.Fatalf("runDiff: %v", err)
|
||||
}
|
||||
if outputPath != "" {
|
||||
if b, err := os.ReadFile(outputPath); err != nil || len(b) == 0 {
|
||||
t.Errorf("empty output: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("bad source", func(t *testing.T) {
|
||||
sourceType, sourcePath = "dbml", filepath.Join(t.TempDir(), "missing.dbml")
|
||||
targetType, targetPath = "dbml", src
|
||||
outputFormat, outputPath = "summary", ""
|
||||
if err := runDiff(nil, nil); err == nil || !strings.Contains(err.Error(), "failed to read source database") {
|
||||
t.Errorf("got %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("bad target", func(t *testing.T) {
|
||||
sourceType, sourcePath = "dbml", src
|
||||
targetType, targetPath = "dbml", filepath.Join(t.TempDir(), "missing.dbml")
|
||||
outputFormat, outputPath = "summary", ""
|
||||
if err := runDiff(nil, nil); err == nil || !strings.Contains(err.Error(), "failed to read target database") {
|
||||
t.Errorf("got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunInspect(t *testing.T) {
|
||||
oldT, oldP, oldC, oldR, oldF, oldO, oldS := inspectSourceType, inspectSourcePath, inspectSourceConn, inspectRulesPath, inspectOutputFormat, inspectOutputPath, inspectSchemaFilter
|
||||
t.Cleanup(func() {
|
||||
inspectSourceType, inspectSourcePath, inspectSourceConn, inspectRulesPath, inspectOutputFormat, inspectOutputPath, inspectSchemaFilter = oldT, oldP, oldC, oldR, oldF, oldO, oldS
|
||||
})
|
||||
|
||||
inspectSourceType = "dbml"
|
||||
inspectSourcePath = filepath.Join(fixturesDir, "dbml/simple.dbml")
|
||||
inspectSourceConn = ""
|
||||
inspectRulesPath = filepath.Join(t.TempDir(), "no-rules.yaml") // missing: defaults used or error
|
||||
inspectSchemaFilter = ""
|
||||
|
||||
// Whatever the rules outcome, the run must not panic; formats are exercised.
|
||||
for _, format := range []string{"markdown", "json"} {
|
||||
inspectOutputFormat = format
|
||||
inspectOutputPath = filepath.Join(t.TempDir(), "report."+format)
|
||||
_ = runInspect(nil, nil)
|
||||
}
|
||||
|
||||
inspectOutputFormat = "bogus"
|
||||
inspectOutputPath = ""
|
||||
if err := runInspect(nil, nil); err == nil {
|
||||
t.Error("bogus output format must fail")
|
||||
}
|
||||
|
||||
inspectSourcePath = filepath.Join(t.TempDir(), "missing.dbml")
|
||||
if err := runInspect(nil, nil); err == nil || !strings.Contains(err.Error(), "failed to read source") {
|
||||
t.Errorf("missing source: %v", err)
|
||||
}
|
||||
}
|
||||
+92
-17
@@ -11,17 +11,20 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
splitSourceType string
|
||||
splitSourcePath string
|
||||
splitSourceConn string
|
||||
splitTargetType string
|
||||
splitTargetPath string
|
||||
splitSchemas string
|
||||
splitTables string
|
||||
splitPackageName string
|
||||
splitDatabaseName string
|
||||
splitExcludeSchema string
|
||||
splitExcludeTables string
|
||||
splitSourceType string
|
||||
splitSourcePath string
|
||||
splitSourceConn string
|
||||
splitTargetType string
|
||||
splitTargetPath string
|
||||
splitSchemas string
|
||||
splitTables string
|
||||
splitPackageName string
|
||||
splitDatabaseName string
|
||||
splitExcludeSchema string
|
||||
splitExcludeTables string
|
||||
splitNullableTypes string
|
||||
splitNullableArrays string
|
||||
splitDryRun bool
|
||||
)
|
||||
|
||||
var splitCmd = &cobra.Command{
|
||||
@@ -110,6 +113,10 @@ func init() {
|
||||
splitCmd.Flags().StringVar(&splitTables, "tables", "", "Comma-separated list of table names to include (case-insensitive)")
|
||||
splitCmd.Flags().StringVar(&splitExcludeSchema, "exclude-schema", "", "Comma-separated list of schema names to exclude")
|
||||
splitCmd.Flags().StringVar(&splitExcludeTables, "exclude-tables", "", "Comma-separated list of table names to exclude (case-insensitive)")
|
||||
splitCmd.Flags().StringVar(&splitNullableTypes, "types", "", "Nullable type package for code-gen writers (bun/gorm): 'baselib' (default, Go pointer types), 'stdlib' (database/sql), or 'sqltypes'")
|
||||
splitCmd.Flags().StringVar(&splitNullableArrays, "array-nullable", "", "Nullable PostgreSQL array representation for the Bun writer in stdlib/baselib --types mode: 'slice' (default, plain slice) or 'pointer_slice' (*[]T, distinguishes NULL from '{}')")
|
||||
|
||||
splitCmd.Flags().BoolVar(&splitDryRun, "dry-run", false, "Read, filter and validate the selection and print the plan without writing any output")
|
||||
|
||||
err := splitCmd.MarkFlagRequired("from")
|
||||
if err != nil {
|
||||
@@ -170,6 +177,26 @@ func runSplit(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, " ✓ Filtered to: %d schema(s), %d table(s)\n\n", len(filteredDB.Schemas), filteredTables)
|
||||
|
||||
if splitDryRun {
|
||||
if err := validateWriteTarget(filteredDB, splitTargetType, splitPackageName, "", ""); err != nil {
|
||||
return fmt.Errorf("dry run validation failed: %w", err)
|
||||
}
|
||||
out := outWriter(cmd)
|
||||
fmt.Fprintf(out, "RelSpec split plan (dry run - nothing written):\n")
|
||||
fmt.Fprintf(out, " Input: %s database '%s'\n", splitSourceType, db.Name)
|
||||
fmt.Fprintf(out, " Output: %s -> %s\n", splitTargetType, splitTargetPath)
|
||||
fmt.Fprintf(out, " Selection: %s\n", splitSelection{
|
||||
Schemas: parseCommaSeparated(splitSchemas),
|
||||
Tables: parseCommaSeparated(splitTables),
|
||||
ExcludeSchemas: parseCommaSeparated(splitExcludeSchema),
|
||||
ExcludeTables: parseCommaSeparated(splitExcludeTables),
|
||||
DatabaseName: splitDatabaseName,
|
||||
}.summary())
|
||||
printDryRunPlan(out, filteredDB)
|
||||
fmt.Fprintf(os.Stderr, "=== Dry run complete: no output written ===\n\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Write to target format
|
||||
fmt.Fprintf(os.Stderr, "[3/3] Writing to target format...\n")
|
||||
fmt.Fprintf(os.Stderr, " Format: %s\n", splitTargetType)
|
||||
@@ -185,6 +212,10 @@ func runSplit(cmd *cobra.Command, args []string) error {
|
||||
splitPackageName,
|
||||
"", // no schema filter for split
|
||||
false, // no flatten-schema for split
|
||||
splitNullableTypes,
|
||||
splitNullableArrays,
|
||||
false, // no continue-on-error for split
|
||||
"", // no extra fields for split
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write output: %w", err)
|
||||
@@ -197,8 +228,52 @@ func runSplit(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// filterDatabase filters the database based on provided criteria
|
||||
// splitSelection is the schema/table selection for a split, independent of the
|
||||
// CLI flag globals so the job runner can build one directly.
|
||||
type splitSelection struct {
|
||||
Schemas []string
|
||||
Tables []string
|
||||
ExcludeSchemas []string
|
||||
ExcludeTables []string
|
||||
DatabaseName string
|
||||
}
|
||||
|
||||
// summary renders a one-line human description of the selection.
|
||||
func (s splitSelection) summary() string {
|
||||
var parts []string
|
||||
if len(s.Schemas) > 0 {
|
||||
parts = append(parts, "schemas="+strings.Join(s.Schemas, ","))
|
||||
}
|
||||
if len(s.Tables) > 0 {
|
||||
parts = append(parts, "tables="+strings.Join(s.Tables, ","))
|
||||
}
|
||||
if len(s.ExcludeSchemas) > 0 {
|
||||
parts = append(parts, "exclude_schemas="+strings.Join(s.ExcludeSchemas, ","))
|
||||
}
|
||||
if len(s.ExcludeTables) > 0 {
|
||||
parts = append(parts, "exclude_tables="+strings.Join(s.ExcludeTables, ","))
|
||||
}
|
||||
if s.DatabaseName != "" {
|
||||
parts = append(parts, "database_name="+s.DatabaseName)
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "(all schemas/tables)"
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// filterDatabase filters the database based on the CLI split flags.
|
||||
func filterDatabase(db *models.Database) (*models.Database, error) {
|
||||
return filterDatabaseSelection(db, splitSelection{
|
||||
Schemas: parseCommaSeparated(splitSchemas),
|
||||
Tables: parseCommaSeparated(splitTables),
|
||||
ExcludeSchemas: parseCommaSeparated(splitExcludeSchema),
|
||||
ExcludeTables: parseCommaSeparated(splitExcludeTables),
|
||||
})
|
||||
}
|
||||
|
||||
// filterDatabaseSelection filters db down to the schemas/tables named by sel.
|
||||
func filterDatabaseSelection(db *models.Database, sel splitSelection) (*models.Database, error) {
|
||||
filteredDB := &models.Database{
|
||||
Name: db.Name,
|
||||
Description: db.Description,
|
||||
@@ -212,11 +287,11 @@ func filterDatabase(db *models.Database) (*models.Database, error) {
|
||||
Domains: db.Domains, // Keep domains for now
|
||||
}
|
||||
|
||||
// Parse filter flags
|
||||
includeSchemas := parseCommaSeparated(splitSchemas)
|
||||
includeTables := parseCommaSeparated(splitTables)
|
||||
excludeSchemas := parseCommaSeparated(splitExcludeSchema)
|
||||
excludeTables := parseCommaSeparated(splitExcludeTables)
|
||||
// Selection criteria
|
||||
includeSchemas := sel.Schemas
|
||||
includeTables := sel.Tables
|
||||
excludeSchemas := sel.ExcludeSchemas
|
||||
excludeTables := sel.ExcludeTables
|
||||
|
||||
// Convert table names to lowercase for case-insensitive matching
|
||||
includeTablesLower := make(map[string]bool)
|
||||
|
||||
+16
-2
@@ -15,6 +15,7 @@ var (
|
||||
templSourceType string
|
||||
templSourcePath string
|
||||
templSourceConn string
|
||||
templFromList []string
|
||||
templTemplatePath string
|
||||
templOutputPath string
|
||||
templSchemaFilter string
|
||||
@@ -78,8 +79,9 @@ Examples:
|
||||
|
||||
func init() {
|
||||
templCmd.Flags().StringVar(&templSourceType, "from", "", "Source format (dbml, pgsql, json, etc.)")
|
||||
templCmd.Flags().StringVar(&templSourcePath, "from-path", "", "Source file path (for file-based sources)")
|
||||
templCmd.Flags().StringVar(&templSourcePath, "from-path", "", "Source file path (for file-based sources, mutually exclusive with --from-list)")
|
||||
templCmd.Flags().StringVar(&templSourceConn, "from-conn", "", "Source connection string (for database sources)")
|
||||
templCmd.Flags().StringSliceVar(&templFromList, "from-list", nil, "Comma-separated list of source file paths to read and merge (mutually exclusive with --from-path)")
|
||||
templCmd.Flags().StringVar(&templTemplatePath, "template", "", "Template file path (required)")
|
||||
templCmd.Flags().StringVar(&templOutputPath, "output", "", "Output path (file or directory, empty for stdout)")
|
||||
templCmd.Flags().StringVar(&templSchemaFilter, "schema", "", "Filter to specific schema")
|
||||
@@ -95,12 +97,24 @@ func runTempl(cmd *cobra.Command, args []string) error {
|
||||
fmt.Fprintf(os.Stderr, "=== RelSpec Template Execution ===\n")
|
||||
fmt.Fprintf(os.Stderr, "Started at: %s\n\n", getCurrentTimestamp())
|
||||
|
||||
// Validate mutually exclusive flags
|
||||
if templSourcePath != "" && len(templFromList) > 0 {
|
||||
return fmt.Errorf("--from-path and --from-list are mutually exclusive")
|
||||
}
|
||||
|
||||
// Read database using the same function as convert
|
||||
fmt.Fprintf(os.Stderr, "Reading from %s...\n", templSourceType)
|
||||
db, err := readDatabaseForConvert(templSourceType, templSourcePath, templSourceConn)
|
||||
var db *models.Database
|
||||
var err error
|
||||
if len(templFromList) > 0 {
|
||||
db, err = readDatabaseListForConvert(templSourceType, templFromList)
|
||||
} else {
|
||||
db, err = readDatabaseForConvert(templSourceType, templSourcePath, templSourceConn)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read source: %w", err)
|
||||
}
|
||||
finalizeCommentedRefs(db, stderrWarn)
|
||||
|
||||
// Print database stats
|
||||
schemaCount := len(db.Schemas)
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// writeTestTemplate writes a minimal Go text template file.
|
||||
func writeTestTemplate(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
content := []byte(`{{.Name}}`)
|
||||
if err := os.WriteFile(path, content, 0o644); err != nil {
|
||||
t.Fatalf("failed to write template file %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTempl_FromListMutuallyExclusiveWithFromPath(t *testing.T) {
|
||||
saved := saveTemplState()
|
||||
defer restoreTemplState(saved)
|
||||
|
||||
dir := t.TempDir()
|
||||
file := filepath.Join(dir, "schema.json")
|
||||
tmpl := filepath.Join(dir, "tmpl.tmpl")
|
||||
writeTestJSON(t, file, []string{"users"})
|
||||
writeTestTemplate(t, tmpl)
|
||||
|
||||
templSourceType = "json"
|
||||
templSourcePath = file
|
||||
templFromList = []string{file}
|
||||
templTemplatePath = tmpl
|
||||
templOutputPath = ""
|
||||
templMode = "database"
|
||||
templFilenamePattern = "{{.Name}}.txt"
|
||||
|
||||
err := runTempl(nil, nil)
|
||||
if err == nil {
|
||||
t.Error("expected error when --from-path and --from-list are both set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTempl_FromListSingleFile(t *testing.T) {
|
||||
saved := saveTemplState()
|
||||
defer restoreTemplState(saved)
|
||||
|
||||
dir := t.TempDir()
|
||||
file := filepath.Join(dir, "schema.json")
|
||||
tmpl := filepath.Join(dir, "tmpl.tmpl")
|
||||
outFile := filepath.Join(dir, "output.txt")
|
||||
writeTestJSON(t, file, []string{"users"})
|
||||
writeTestTemplate(t, tmpl)
|
||||
|
||||
templSourceType = "json"
|
||||
templSourcePath = ""
|
||||
templSourceConn = ""
|
||||
templFromList = []string{file}
|
||||
templTemplatePath = tmpl
|
||||
templOutputPath = outFile
|
||||
templSchemaFilter = ""
|
||||
templMode = "database"
|
||||
templFilenamePattern = "{{.Name}}.txt"
|
||||
|
||||
if err := runTempl(nil, nil); err != nil {
|
||||
t.Fatalf("runTempl() error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(outFile); os.IsNotExist(err) {
|
||||
t.Error("expected output file to be created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTempl_FromListMultipleFiles(t *testing.T) {
|
||||
saved := saveTemplState()
|
||||
defer restoreTemplState(saved)
|
||||
|
||||
dir := t.TempDir()
|
||||
file1 := filepath.Join(dir, "users.json")
|
||||
file2 := filepath.Join(dir, "posts.json")
|
||||
tmpl := filepath.Join(dir, "tmpl.tmpl")
|
||||
outFile := filepath.Join(dir, "output.txt")
|
||||
writeTestJSON(t, file1, []string{"users"})
|
||||
writeTestJSON(t, file2, []string{"posts"})
|
||||
writeTestTemplate(t, tmpl)
|
||||
|
||||
templSourceType = "json"
|
||||
templSourcePath = ""
|
||||
templSourceConn = ""
|
||||
templFromList = []string{file1, file2}
|
||||
templTemplatePath = tmpl
|
||||
templOutputPath = outFile
|
||||
templSchemaFilter = ""
|
||||
templMode = "database"
|
||||
templFilenamePattern = "{{.Name}}.txt"
|
||||
|
||||
if err := runTempl(nil, nil); err != nil {
|
||||
t.Fatalf("runTempl() error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(outFile); os.IsNotExist(err) {
|
||||
t.Error("expected output file to be created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTempl_FromListPathWithSpaces(t *testing.T) {
|
||||
saved := saveTemplState()
|
||||
defer restoreTemplState(saved)
|
||||
|
||||
spacedDir := filepath.Join(t.TempDir(), "my schema files")
|
||||
if err := os.MkdirAll(spacedDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file1 := filepath.Join(spacedDir, "users schema.json")
|
||||
file2 := filepath.Join(spacedDir, "posts schema.json")
|
||||
tmpl := filepath.Join(spacedDir, "my template.tmpl")
|
||||
outFile := filepath.Join(spacedDir, "output file.txt")
|
||||
writeTestJSON(t, file1, []string{"users"})
|
||||
writeTestJSON(t, file2, []string{"posts"})
|
||||
writeTestTemplate(t, tmpl)
|
||||
|
||||
templSourceType = "json"
|
||||
templSourcePath = ""
|
||||
templSourceConn = ""
|
||||
templFromList = []string{file1, file2}
|
||||
templTemplatePath = tmpl
|
||||
templOutputPath = outFile
|
||||
templSchemaFilter = ""
|
||||
templMode = "database"
|
||||
templFilenamePattern = "{{.Name}}.txt"
|
||||
|
||||
if err := runTempl(nil, nil); err != nil {
|
||||
t.Fatalf("runTempl() with spaced paths error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(outFile); os.IsNotExist(err) {
|
||||
t.Error("expected output file to be created")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// minimalColumn is used to build test JSON fixtures.
|
||||
type minimalColumn struct {
|
||||
Name string `json:"name"`
|
||||
Table string `json:"table"`
|
||||
Schema string `json:"schema"`
|
||||
Type string `json:"type"`
|
||||
NotNull bool `json:"not_null"`
|
||||
IsPrimaryKey bool `json:"is_primary_key"`
|
||||
AutoIncrement bool `json:"auto_increment"`
|
||||
}
|
||||
|
||||
type minimalTable struct {
|
||||
Name string `json:"name"`
|
||||
Schema string `json:"schema"`
|
||||
Columns map[string]minimalColumn `json:"columns"`
|
||||
}
|
||||
|
||||
type minimalSchema struct {
|
||||
Name string `json:"name"`
|
||||
Tables []minimalTable `json:"tables"`
|
||||
}
|
||||
|
||||
type minimalDatabase struct {
|
||||
Name string `json:"name"`
|
||||
Schemas []minimalSchema `json:"schemas"`
|
||||
}
|
||||
|
||||
// writeTestJSON writes a minimal JSON database file with one schema ("public")
|
||||
// containing tables with the given names. Each table has a single "id" PK column.
|
||||
func writeTestJSON(t *testing.T, path string, tableNames []string) {
|
||||
t.Helper()
|
||||
|
||||
tables := make([]minimalTable, len(tableNames))
|
||||
for i, name := range tableNames {
|
||||
tables[i] = minimalTable{
|
||||
Name: name,
|
||||
Schema: "public",
|
||||
Columns: map[string]minimalColumn{
|
||||
"id": {
|
||||
Name: "id",
|
||||
Table: name,
|
||||
Schema: "public",
|
||||
Type: "bigint",
|
||||
NotNull: true,
|
||||
IsPrimaryKey: true,
|
||||
AutoIncrement: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
db := minimalDatabase{
|
||||
Name: "test_db",
|
||||
Schemas: []minimalSchema{{Name: "public", Tables: tables}},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(db)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal test JSON: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
t.Fatalf("failed to write test file %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestJSONWithSingleColumnType(t *testing.T, path, tableName, columnType string) {
|
||||
t.Helper()
|
||||
|
||||
db := minimalDatabase{
|
||||
Name: "test_db",
|
||||
Schemas: []minimalSchema{{
|
||||
Name: "public",
|
||||
Tables: []minimalTable{{
|
||||
Name: tableName,
|
||||
Schema: "public",
|
||||
Columns: map[string]minimalColumn{
|
||||
"id": {
|
||||
Name: "id",
|
||||
Table: tableName,
|
||||
Schema: "public",
|
||||
Type: columnType,
|
||||
NotNull: true,
|
||||
IsPrimaryKey: true,
|
||||
AutoIncrement: true,
|
||||
},
|
||||
},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(db)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal test JSON: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
t.Fatalf("failed to write test file %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// convertState captures and restores all convert global vars.
|
||||
type convertState struct {
|
||||
sourceType string
|
||||
sourcePath string
|
||||
sourceConn string
|
||||
fromList []string
|
||||
targetType string
|
||||
targetPath string
|
||||
packageName string
|
||||
schemaFilter string
|
||||
flattenSchema bool
|
||||
}
|
||||
|
||||
func saveConvertState() convertState {
|
||||
return convertState{
|
||||
sourceType: convertSourceType,
|
||||
sourcePath: convertSourcePath,
|
||||
sourceConn: convertSourceConn,
|
||||
fromList: convertFromList,
|
||||
targetType: convertTargetType,
|
||||
targetPath: convertTargetPath,
|
||||
packageName: convertPackageName,
|
||||
schemaFilter: convertSchemaFilter,
|
||||
flattenSchema: convertFlattenSchema,
|
||||
}
|
||||
}
|
||||
|
||||
func restoreConvertState(s convertState) {
|
||||
convertSourceType = s.sourceType
|
||||
convertSourcePath = s.sourcePath
|
||||
convertSourceConn = s.sourceConn
|
||||
convertFromList = s.fromList
|
||||
convertTargetType = s.targetType
|
||||
convertTargetPath = s.targetPath
|
||||
convertPackageName = s.packageName
|
||||
convertSchemaFilter = s.schemaFilter
|
||||
convertFlattenSchema = s.flattenSchema
|
||||
}
|
||||
|
||||
// templState captures and restores all templ global vars.
|
||||
type templState struct {
|
||||
sourceType string
|
||||
sourcePath string
|
||||
sourceConn string
|
||||
fromList []string
|
||||
templatePath string
|
||||
outputPath string
|
||||
schemaFilter string
|
||||
mode string
|
||||
filenamePattern string
|
||||
}
|
||||
|
||||
func saveTemplState() templState {
|
||||
return templState{
|
||||
sourceType: templSourceType,
|
||||
sourcePath: templSourcePath,
|
||||
sourceConn: templSourceConn,
|
||||
fromList: templFromList,
|
||||
templatePath: templTemplatePath,
|
||||
outputPath: templOutputPath,
|
||||
schemaFilter: templSchemaFilter,
|
||||
mode: templMode,
|
||||
filenamePattern: templFilenamePattern,
|
||||
}
|
||||
}
|
||||
|
||||
func restoreTemplState(s templState) {
|
||||
templSourceType = s.sourceType
|
||||
templSourcePath = s.sourcePath
|
||||
templSourceConn = s.sourceConn
|
||||
templFromList = s.fromList
|
||||
templTemplatePath = s.templatePath
|
||||
templOutputPath = s.outputPath
|
||||
templSchemaFilter = s.schemaFilter
|
||||
templMode = s.mode
|
||||
templFilenamePattern = s.filenamePattern
|
||||
}
|
||||
|
||||
// mergeState captures and restores all merge global vars.
|
||||
type mergeState struct {
|
||||
targetType string
|
||||
targetPath string
|
||||
targetConn string
|
||||
sourceType string
|
||||
sourcePath string
|
||||
sourceConn string
|
||||
fromList []string
|
||||
outputType string
|
||||
outputPath string
|
||||
outputConn string
|
||||
skipDomains bool
|
||||
skipRelations bool
|
||||
skipEnums bool
|
||||
skipViews bool
|
||||
skipSequences bool
|
||||
skipTables string
|
||||
verbose bool
|
||||
reportPath string
|
||||
flattenSchema bool
|
||||
}
|
||||
|
||||
func saveMergeState() mergeState {
|
||||
return mergeState{
|
||||
targetType: mergeTargetType,
|
||||
targetPath: mergeTargetPath,
|
||||
targetConn: mergeTargetConn,
|
||||
sourceType: mergeSourceType,
|
||||
sourcePath: mergeSourcePath,
|
||||
sourceConn: mergeSourceConn,
|
||||
fromList: mergeFromList,
|
||||
outputType: mergeOutputType,
|
||||
outputPath: mergeOutputPath,
|
||||
outputConn: mergeOutputConn,
|
||||
skipDomains: mergeSkipDomains,
|
||||
skipRelations: mergeSkipRelations,
|
||||
skipEnums: mergeSkipEnums,
|
||||
skipViews: mergeSkipViews,
|
||||
skipSequences: mergeSkipSequences,
|
||||
skipTables: mergeSkipTables,
|
||||
verbose: mergeVerbose,
|
||||
reportPath: mergeReportPath,
|
||||
flattenSchema: mergeFlattenSchema,
|
||||
}
|
||||
}
|
||||
|
||||
func restoreMergeState(s mergeState) {
|
||||
mergeTargetType = s.targetType
|
||||
mergeTargetPath = s.targetPath
|
||||
mergeTargetConn = s.targetConn
|
||||
mergeSourceType = s.sourceType
|
||||
mergeSourcePath = s.sourcePath
|
||||
mergeSourceConn = s.sourceConn
|
||||
mergeFromList = s.fromList
|
||||
mergeOutputType = s.outputType
|
||||
mergeOutputPath = s.outputPath
|
||||
mergeOutputConn = s.outputConn
|
||||
mergeSkipDomains = s.skipDomains
|
||||
mergeSkipRelations = s.skipRelations
|
||||
mergeSkipEnums = s.skipEnums
|
||||
mergeSkipViews = s.skipViews
|
||||
mergeSkipSequences = s.skipSequences
|
||||
mergeSkipTables = s.skipTables
|
||||
mergeVerbose = s.verbose
|
||||
mergeReportPath = s.reportPath
|
||||
mergeFlattenSchema = s.flattenSchema
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/buildinfo"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/updatecheck"
|
||||
)
|
||||
|
||||
// windowsInstallerAsset is the release asset built by the NSIS installer step.
|
||||
const windowsInstallerAsset = "relspec-setup-windows-amd64.exe"
|
||||
|
||||
var (
|
||||
updateCheckOnly bool
|
||||
updateAssumeYes bool
|
||||
updateAPIURL = updatecheck.DefaultAPIURL
|
||||
)
|
||||
|
||||
var updateCmd = &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "Check for a newer release and offer to install it",
|
||||
Long: `Check the project releases for a version newer than this binary.
|
||||
|
||||
If one exists you are prompted to update. On Windows the installer is
|
||||
downloaded and started; on other platforms the release page URL is shown.
|
||||
Use --check to only report, and --yes to skip the prompt.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Minute)
|
||||
defer cancel()
|
||||
return runUpdate(ctx, updateOptions{
|
||||
current: buildinfo.Version,
|
||||
apiURL: updateAPIURL,
|
||||
goos: runtime.GOOS,
|
||||
checkOnly: updateCheckOnly,
|
||||
assumeYes: updateAssumeYes,
|
||||
in: cmd.InOrStdin(),
|
||||
out: cmd.OutOrStdout(),
|
||||
install: downloadAndRunInstaller,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
updateCmd.Flags().BoolVar(&updateCheckOnly, "check", false, "Only report whether an update is available")
|
||||
updateCmd.Flags().BoolVarP(&updateAssumeYes, "yes", "y", false, "Update without prompting")
|
||||
}
|
||||
|
||||
type updateOptions struct {
|
||||
current string
|
||||
apiURL string
|
||||
goos string
|
||||
checkOnly bool
|
||||
assumeYes bool
|
||||
in io.Reader
|
||||
out io.Writer
|
||||
// install downloads and starts the installer found at url.
|
||||
install func(ctx context.Context, url string, out io.Writer) error
|
||||
}
|
||||
|
||||
func runUpdate(ctx context.Context, o updateOptions) error {
|
||||
rel, err := updatecheck.Latest(ctx, nil, o.apiURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !updatecheck.IsNewer(o.current, rel.Tag) {
|
||||
_, _ = fmt.Fprintf(o.out, "RelSpec %s is up to date (latest release: %s)\n", o.current, rel.Tag)
|
||||
return nil
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(o.out, "A newer RelSpec is available: %s (installed: %s)\n", rel.Tag, o.current)
|
||||
if o.checkOnly {
|
||||
_, _ = fmt.Fprintf(o.out, "Release: %s\n", rel.URL)
|
||||
return nil
|
||||
}
|
||||
|
||||
installer, canInstall := rel.FindAsset(windowsInstallerAsset)
|
||||
if o.goos != "windows" || !canInstall {
|
||||
_, _ = fmt.Fprintf(o.out, "Download it from: %s\n", rel.URL)
|
||||
return nil
|
||||
}
|
||||
|
||||
if !o.assumeYes && !confirm(o.in, o.out, "Download and run the installer now?") {
|
||||
_, _ = fmt.Fprintf(o.out, "Skipped. Release: %s\n", rel.URL)
|
||||
return nil
|
||||
}
|
||||
return o.install(ctx, installer.URL, o.out)
|
||||
}
|
||||
|
||||
// confirm asks a yes/no question, defaulting to no.
|
||||
func confirm(in io.Reader, out io.Writer, question string) bool {
|
||||
_, _ = fmt.Fprintf(out, "%s [y/N]: ", question)
|
||||
line, _ := bufio.NewReader(in).ReadString('\n')
|
||||
switch strings.ToLower(strings.TrimSpace(line)) {
|
||||
case "y", "yes":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// downloadAndRunInstaller saves the installer to a temp directory and starts
|
||||
// it detached so this process can exit and release relspec.exe.
|
||||
func downloadAndRunInstaller(ctx context.Context, url string, out io.Writer) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("downloading installer: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("downloading installer: unexpected status %s", resp.Status)
|
||||
}
|
||||
|
||||
dir, err := os.MkdirTemp("", "relspec-update-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(dir, windowsInstallerAsset)
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(f, resp.Body); err != nil {
|
||||
_ = f.Close()
|
||||
return fmt.Errorf("downloading installer: %w", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(out, "Starting installer: %s\n", path)
|
||||
return exec.Command(path).Start()
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func releaseServer(t *testing.T, tag string, withInstaller bool) *httptest.Server {
|
||||
t.Helper()
|
||||
assets := ""
|
||||
if withInstaller {
|
||||
assets = fmt.Sprintf(`{"name":%q,"browser_download_url":"https://x/setup.exe"}`, windowsInstallerAsset)
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprintf(w, `{"tag_name":%q,"html_url":"https://x/release","assets":[%s]}`, tag, assets)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestRunUpdate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
current string
|
||||
latest string
|
||||
withInstaller bool
|
||||
goos string
|
||||
checkOnly bool
|
||||
assumeYes bool
|
||||
stdin string
|
||||
wantOut []string
|
||||
wantInstall bool
|
||||
}{
|
||||
{name: "up to date", current: "v1.0.5", latest: "v1.0.5", goos: "windows", withInstaller: true, wantOut: []string{"up to date"}},
|
||||
{name: "dev build never prompts", current: "dev", latest: "v9.0.0", goos: "windows", withInstaller: true, wantOut: []string{"up to date"}},
|
||||
{name: "check only", current: "v1.0.5", latest: "v1.0.6", goos: "windows", withInstaller: true, checkOnly: true, wantOut: []string{"v1.0.6", "https://x/release"}},
|
||||
{name: "non-windows shows url", current: "v1.0.5", latest: "v1.0.6", goos: "linux", withInstaller: true, wantOut: []string{"Download it from: https://x/release"}},
|
||||
{name: "windows without installer asset", current: "v1.0.5", latest: "v1.0.6", goos: "windows", wantOut: []string{"Download it from"}},
|
||||
{name: "windows prompt yes", current: "v1.0.5", latest: "v1.0.6", goos: "windows", withInstaller: true, stdin: "y\n", wantOut: []string{"[y/N]"}, wantInstall: true},
|
||||
{name: "windows prompt no", current: "v1.0.5", latest: "v1.0.6", goos: "windows", withInstaller: true, stdin: "n\n", wantOut: []string{"Skipped"}},
|
||||
{name: "windows prompt empty", current: "v1.0.5", latest: "v1.0.6", goos: "windows", withInstaller: true, wantOut: []string{"Skipped"}},
|
||||
{name: "windows --yes", current: "v1.0.5", latest: "v1.0.6", goos: "windows", withInstaller: true, assumeYes: true, wantInstall: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
srv := releaseServer(t, tt.latest, tt.withInstaller)
|
||||
var out bytes.Buffer
|
||||
var installedURL string
|
||||
err := runUpdate(context.Background(), updateOptions{
|
||||
current: tt.current, apiURL: srv.URL, goos: tt.goos,
|
||||
checkOnly: tt.checkOnly, assumeYes: tt.assumeYes,
|
||||
in: strings.NewReader(tt.stdin), out: &out,
|
||||
install: func(_ context.Context, url string, _ io.Writer) error {
|
||||
installedURL = url
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range tt.wantOut {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Errorf("output missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
if (installedURL != "") != tt.wantInstall {
|
||||
t.Errorf("installer called = %v, want %v", installedURL != "", tt.wantInstall)
|
||||
}
|
||||
if tt.wantInstall && installedURL != "https://x/setup.exe" {
|
||||
t.Errorf("installer url = %q", installedURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUpdate_Errors(t *testing.T) {
|
||||
bad := httptest.NewServer(http.NotFoundHandler())
|
||||
defer bad.Close()
|
||||
if err := runUpdate(context.Background(), updateOptions{current: "v1.0.0", apiURL: bad.URL, out: io.Discard}); err == nil {
|
||||
t.Error("expected lookup error")
|
||||
}
|
||||
|
||||
srv := releaseServer(t, "v1.0.6", true)
|
||||
want := errors.New("boom")
|
||||
err := runUpdate(context.Background(), updateOptions{
|
||||
current: "v1.0.5", apiURL: srv.URL, goos: "windows", assumeYes: true, out: io.Discard,
|
||||
install: func(context.Context, string, io.Writer) error { return want },
|
||||
})
|
||||
if !errors.Is(err, want) {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadAndRunInstaller_BadStatus(t *testing.T) {
|
||||
srv := httptest.NewServer(http.NotFoundHandler())
|
||||
defer srv.Close()
|
||||
if err := downloadAndRunInstaller(context.Background(), srv.URL, io.Discard); err == nil {
|
||||
t.Error("expected error for non-200")
|
||||
}
|
||||
if err := downloadAndRunInstaller(context.Background(), "://bad", io.Discard); err == nil {
|
||||
t.Error("expected error for bad url")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/buildinfo"
|
||||
)
|
||||
|
||||
var versionCmd = &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print version information",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Print(buildinfo.AsciiLogo)
|
||||
fmt.Printf("RelSpec %s\n", buildinfo.Version)
|
||||
fmt.Printf("Built: %s\n", buildinfo.BuildDate)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// watchSnapshot maps a file path to its modification time and size.
|
||||
type watchSnapshot map[string]string
|
||||
|
||||
// takeWatchSnapshot records the state of every file under the given paths.
|
||||
// Directories are walked recursively. Anything at or below the excluded path
|
||||
// (typically the output path) is skipped so regenerating output does not
|
||||
// retrigger the watcher. Missing paths are simply absent from the snapshot, so
|
||||
// creating them later counts as a change.
|
||||
func takeWatchSnapshot(paths []string, exclude string) watchSnapshot {
|
||||
snap := watchSnapshot{}
|
||||
exclude = absPathOrSelf(exclude)
|
||||
for _, root := range paths {
|
||||
_ = filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if exclude != "" && isWithin(absPathOrSelf(p), exclude) {
|
||||
if d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
snap[p] = fmt.Sprintf("%d-%d", info.ModTime().UnixNano(), info.Size())
|
||||
return nil
|
||||
})
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
func (s watchSnapshot) equal(o watchSnapshot) bool {
|
||||
if len(s) != len(o) {
|
||||
return false
|
||||
}
|
||||
for k, v := range s {
|
||||
if o[k] != v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func absPathOrSelf(p string) string {
|
||||
if p == "" {
|
||||
return ""
|
||||
}
|
||||
if abs, err := filepath.Abs(p); err == nil {
|
||||
return abs
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// isWithin reports whether path equals dir or is located below it.
|
||||
func isWithin(path, dir string) bool {
|
||||
if path == dir {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(path, dir+string(filepath.Separator))
|
||||
}
|
||||
|
||||
// watchLoop runs fn once immediately and again whenever the watched paths
|
||||
// change, until ctx is cancelled. Changes are debounced: fn runs only after
|
||||
// the snapshot has stayed unchanged for one poll interval. Errors from fn are
|
||||
// reported to w and do not stop the loop.
|
||||
func watchLoop(ctx context.Context, w io.Writer, paths []string, exclude string, interval time.Duration, fn func() error) {
|
||||
run := func() {
|
||||
if err := fn(); err != nil {
|
||||
fmt.Fprintf(w, "Error: %v\n", err)
|
||||
}
|
||||
fmt.Fprintf(w, "Watching for changes (Ctrl-C to stop)...\n")
|
||||
}
|
||||
|
||||
last := takeWatchSnapshot(paths, exclude)
|
||||
run()
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
cur := takeWatchSnapshot(paths, exclude)
|
||||
if cur.equal(last) {
|
||||
continue
|
||||
}
|
||||
// Debounce: wait until writes settle.
|
||||
for settled := false; !settled; {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(interval):
|
||||
}
|
||||
next := takeWatchSnapshot(paths, exclude)
|
||||
settled = next.equal(cur)
|
||||
cur = next
|
||||
}
|
||||
fmt.Fprintf(w, "\nChange detected, regenerating...\n")
|
||||
last = cur
|
||||
run()
|
||||
}
|
||||
}
|
||||
|
||||
// runConvertWatch runs the conversion once and then again whenever the source
|
||||
// files change, until interrupted.
|
||||
func runConvertWatch(parent context.Context, w io.Writer, run func() error) error {
|
||||
var paths []string
|
||||
switch {
|
||||
case len(convertFromList) > 0:
|
||||
paths = convertFromList
|
||||
case convertSourcePath != "":
|
||||
paths = []string{convertSourcePath}
|
||||
default:
|
||||
return fmt.Errorf("--watch requires --from-path or --from-list (live database connections cannot be watched)")
|
||||
}
|
||||
if convertDryRun {
|
||||
return fmt.Errorf("--watch cannot be combined with --dry-run")
|
||||
}
|
||||
if convertWatchInterval <= 0 {
|
||||
return fmt.Errorf("--watch-interval must be positive")
|
||||
}
|
||||
if parent == nil {
|
||||
parent = context.Background()
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(parent, os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
watchLoop(ctx, w, paths, convertTargetPath, convertWatchInterval, run)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWatchSnapshotExcludesOutput(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "out")
|
||||
if err := os.MkdirAll(out, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := filepath.Join(dir, "schema.dbml")
|
||||
if err := os.WriteFile(src, []byte("a"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := takeWatchSnapshot([]string{dir}, out)
|
||||
if err := os.WriteFile(filepath.Join(out, "gen.go"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after := takeWatchSnapshot([]string{dir}, out); !before.equal(after) {
|
||||
t.Errorf("writing into the excluded output path changed the snapshot")
|
||||
}
|
||||
if err := os.WriteFile(src, []byte("changed"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after := takeWatchSnapshot([]string{dir}, out); before.equal(after) {
|
||||
t.Errorf("modifying a source file did not change the snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchLoopRerunsOnChange(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "schema.dbml")
|
||||
if err := os.WriteFile(src, []byte("a"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var runs atomic.Int32
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
watchLoop(ctx, io.Discard, []string{src}, "", 10*time.Millisecond, func() error {
|
||||
runs.Add(1)
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
|
||||
waitFor(t, func() bool { return runs.Load() == 1 })
|
||||
if err := os.WriteFile(src, []byte("changed content"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitFor(t, func() bool { return runs.Load() == 2 })
|
||||
cancel()
|
||||
<-done
|
||||
}
|
||||
|
||||
func TestRunConvertWatchValidation(t *testing.T) {
|
||||
oldPath, oldList, oldDry, oldInt := convertSourcePath, convertFromList, convertDryRun, convertWatchInterval
|
||||
defer func() {
|
||||
convertSourcePath, convertFromList, convertDryRun, convertWatchInterval = oldPath, oldList, oldDry, oldInt
|
||||
}()
|
||||
convertSourcePath, convertFromList, convertDryRun, convertWatchInterval = "", nil, false, time.Second
|
||||
if err := runConvertWatch(context.Background(), &bytes.Buffer{}, nil); err == nil {
|
||||
t.Error("expected error without --from-path/--from-list")
|
||||
}
|
||||
convertSourcePath, convertDryRun = "x.dbml", true
|
||||
if err := runConvertWatch(context.Background(), &bytes.Buffer{}, nil); err == nil {
|
||||
t.Error("expected error combining --watch with --dry-run")
|
||||
}
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("condition not met in time")
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// Package relspecgo provides bidirectional conversion between database schema formats.
|
||||
//
|
||||
// RelSpec is a comprehensive database schema tool that reads, writes, and transforms
|
||||
// database schemas across multiple formats including live databases, ORM models,
|
||||
// schema definition languages, and data interchange formats.
|
||||
//
|
||||
// # Features
|
||||
//
|
||||
// - Read from 15+ formats: PostgreSQL, SQLite, DBML, GORM, Prisma, Drizzle, and more
|
||||
// - Write to 15+ formats: SQL, ORM models, schema definitions, JSON/YAML
|
||||
// - Interactive TUI editor for visual schema management
|
||||
// - Schema diff and merge capabilities
|
||||
// - Format-agnostic intermediate representation
|
||||
//
|
||||
// # Architecture
|
||||
//
|
||||
// RelSpec uses a hub-and-spoke architecture with models.Database as the central type:
|
||||
//
|
||||
// Input Format → Reader → models.Database → Writer → Output Format
|
||||
//
|
||||
// This allows any supported input format to be converted to any supported output format
|
||||
// without requiring N² conversion implementations.
|
||||
//
|
||||
// # Key Packages
|
||||
//
|
||||
// - pkg/models: Core data structures (Database, Schema, Table, Column, etc.)
|
||||
// - pkg/readers: Input format readers (dbml, pgsql, gorm, etc.)
|
||||
// - pkg/writers: Output format writers (dbml, pgsql, gorm, etc.)
|
||||
// - pkg/ui: Interactive terminal UI for schema editing
|
||||
// - pkg/diff: Schema comparison and difference detection
|
||||
// - pkg/merge: Schema merging utilities
|
||||
// - pkg/transform: Validation and normalization
|
||||
//
|
||||
// # Installation
|
||||
//
|
||||
// go install git.warky.dev/wdevs/relspecgo/cmd/relspec@latest
|
||||
//
|
||||
// # Usage
|
||||
//
|
||||
// Command-line conversion:
|
||||
//
|
||||
// relspec convert --from dbml --from-path schema.dbml \
|
||||
// --to gorm --to-path ./models
|
||||
//
|
||||
// Interactive editor:
|
||||
//
|
||||
// relspec edit --from pgsql --from-conn "postgres://..." \
|
||||
// --to dbml --to-path schema.dbml
|
||||
//
|
||||
// Schema comparison:
|
||||
//
|
||||
// relspec diff --source-type pgsql --source-conn "postgres://..." \
|
||||
// --target-type dbml --target-path schema.dbml
|
||||
//
|
||||
// Merge schemas:
|
||||
//
|
||||
// relspec merge --target schema1.dbml --sources schema2.dbml,schema3.dbml
|
||||
//
|
||||
// # Supported Formats
|
||||
//
|
||||
// Input/Output Formats:
|
||||
// - dbml: Database Markup Language
|
||||
// - dctx: DCTX schema files
|
||||
// - drawdb: DrawDB JSON format
|
||||
// - graphql: GraphQL schema definition
|
||||
// - json: JSON schema representation
|
||||
// - yaml: YAML schema representation
|
||||
// - gorm: Go GORM models
|
||||
// - bun: Go Bun models
|
||||
// - drizzle: TypeScript Drizzle ORM
|
||||
// - prisma: Prisma schema language
|
||||
// - typeorm: TypeScript TypeORM entities
|
||||
// - pgsql: PostgreSQL (live DB or SQL)
|
||||
// - sqlite: SQLite (database file or SQL)
|
||||
//
|
||||
// # Library Usage
|
||||
//
|
||||
// RelSpec can be used as a Go library:
|
||||
//
|
||||
// import (
|
||||
// "git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
// "git.warky.dev/wdevs/relspecgo/pkg/readers/dbml"
|
||||
// "git.warky.dev/wdevs/relspecgo/pkg/writers/gorm"
|
||||
// )
|
||||
//
|
||||
// // Read DBML
|
||||
// reader := dbml.NewReader(&readers.ReaderOptions{
|
||||
// FilePath: "schema.dbml",
|
||||
// })
|
||||
// db, err := reader.ReadDatabase()
|
||||
//
|
||||
// // Write GORM models
|
||||
// writer := gorm.NewWriter(&writers.WriterOptions{
|
||||
// OutputPath: "./models",
|
||||
// PackageName: "models",
|
||||
// })
|
||||
// err = writer.WriteDatabase(db)
|
||||
//
|
||||
// # Documentation
|
||||
//
|
||||
// Full documentation available at: https://git.warky.dev/wdevs/relspecgo
|
||||
//
|
||||
// API documentation: go doc git.warky.dev/wdevs/relspecgo/...
|
||||
//
|
||||
// # License
|
||||
//
|
||||
// See LICENSE file in the repository root.
|
||||
package relspecgo
|
||||
@@ -1,6 +1,21 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
mssql:
|
||||
image: mcr.microsoft.com/mssql/server:2022-latest
|
||||
environment:
|
||||
- ACCEPT_EULA=Y
|
||||
- SA_PASSWORD=StrongPassword123!
|
||||
- MSSQL_PID=Express
|
||||
ports:
|
||||
- "1433:1433"
|
||||
volumes:
|
||||
- ./test_data/mssql/test_schema.sql:/test_schema.sql
|
||||
healthcheck:
|
||||
test: ["CMD", "/opt/mssql-tools/bin/sqlcmd", "-S", "localhost", "-U", "sa", "-P", "StrongPassword123!", "-Q", "SELECT 1"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: relspec-test-postgres
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# DBML Dialect Directives
|
||||
|
||||
DBML has no dialect-neutral way to express database-specific features such as
|
||||
PostgreSQL table partitioning or SQLite `WITHOUT ROWID`. RelSpec adds **dialect
|
||||
directives** — explicit, parseable lines embedded in a `.dbml` file that are:
|
||||
|
||||
- stored losslessly in the intermediate model (under each object's `Metadata`),
|
||||
- preserved unchanged through a `DBML → model → DBML` round-trip,
|
||||
- translated to SQL **only** by the writer for the matching dialect
|
||||
(`@postgres:` clauses appear in PostgreSQL output, never in SQLite output, and
|
||||
vice-versa).
|
||||
|
||||
## Grammar
|
||||
|
||||
A directive is a single line, matched on its trimmed content:
|
||||
|
||||
```
|
||||
@<namespace>[(<target>)]: <args>
|
||||
```
|
||||
|
||||
| Part | Rules |
|
||||
|------|-------|
|
||||
| `namespace` | `^[a-z][a-z0-9_]*$` — e.g. `postgres`, `sqlite`. Future dialects allowed. |
|
||||
| `(target)` | Optional. A **column name** only, valid only on a directive line inside a table body. Bare or single/double quoted. |
|
||||
| `args` | Everything after the first `:`, trimmed. Otherwise preserved **verbatim**. Must be non-empty. |
|
||||
|
||||
The **key** of a directive is derived: the lowercased first whitespace-delimited
|
||||
token of `args` (`partition by RANGE (created_at)` → `partition`). It drives
|
||||
duplicate detection and writer dispatch.
|
||||
|
||||
## Location
|
||||
|
||||
Where the line appears determines which object it attaches to:
|
||||
|
||||
| Position in the file | Attaches to |
|
||||
|----------------------|-------------|
|
||||
| Before the first `Table {` | database (`db.Metadata`) |
|
||||
| Table body, no `(target)` | that table |
|
||||
| Table body, `(col)` target | column `col` of that table (error if `col` is unknown) |
|
||||
| Inside an `indexes { }` block | the **most recently listed** index entry in that block; `(target)` is not allowed |
|
||||
|
||||
```dbml
|
||||
@postgres: search_path myapp -- database
|
||||
|
||||
Table myapp.events {
|
||||
id bigint [pk]
|
||||
created_at timestamp [not null]
|
||||
@postgres(id): identity always -- column "id"
|
||||
@postgres: partition by RANGE (created_at) -- table
|
||||
@postgres: tablespace fast_data -- table
|
||||
@sqlite: without rowid -- table
|
||||
|
||||
indexes {
|
||||
(created_at) [name: 'idx_events_created']
|
||||
@postgres: with (fillfactor=90) -- index "idx_events_created"
|
||||
@postgres: tablespace idx_space -- index "idx_events_created"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Duplicate policy
|
||||
|
||||
- **Repeatable by default** — every directive with the same `(namespace, key)` at
|
||||
one location is kept, in source order.
|
||||
- **Singletons** raise a line-numbered error on a second occurrence at the same
|
||||
location. Current singletons: `postgres` `partition`, `tablespace`, `inherits`,
|
||||
`storage`, `compression`, `identity`; `sqlite` `without`, `strict`, `collate`.
|
||||
|
||||
## Strict mode
|
||||
|
||||
CLI flag `--strict-directives` (also `ReaderOptions.StrictDirectives` /
|
||||
`WriterOptions.StrictDirectives`):
|
||||
|
||||
- **Reader**: an unknown namespace or key is a hard error. Without strict mode it
|
||||
is stored and preserved silently, and round-trips unchanged.
|
||||
- **PostgreSQL / SQLite writer**: a directive for **that** writer's own dialect
|
||||
whose key it cannot translate is a hard error. Without strict mode, translatable
|
||||
keys are emitted and the rest are skipped. Directives for other dialects are
|
||||
always ignored, never emitted.
|
||||
|
||||
## Errors
|
||||
|
||||
All are line-numbered (`dbml: line N: …`):
|
||||
|
||||
- no colon, or empty `args`
|
||||
- namespace empty or not matching `[a-z][a-z0-9_]*`
|
||||
- `(target)` naming an unknown column, or used at the top level / in an `indexes` block
|
||||
- a directive in the catalog used at a location it is not valid for
|
||||
- duplicate singleton at the same location
|
||||
- (strict mode) unknown `(namespace, key)`
|
||||
|
||||
## Supported directive matrix
|
||||
|
||||
### `@postgres`
|
||||
|
||||
| Key | Locations | SQL emitted | Notes |
|
||||
|-----|-----------|-------------|-------|
|
||||
| `partition` | table | `PARTITION BY <args>` appended to `CREATE TABLE` | e.g. `@postgres: partition by RANGE (created_at)` |
|
||||
| `inherits` | table | `INHERITS (<args>)` — args verbatim | |
|
||||
| `with` | table, index | `WITH (<params>)` | On an index, wins over `WITH` derived from the index comment. `@postgres: with (fillfactor=90)` |
|
||||
| `tablespace` | table, index | `TABLESPACE <name>` | Emitted after `WITH`, before `WHERE` on indexes |
|
||||
| `storage` | column | `STORAGE <mode>` in the column definition | e.g. `@postgres(blob): storage external` |
|
||||
| `compression` | column | `COMPRESSION <method>` | |
|
||||
| `identity` | column | `identity always` → `GENERATED ALWAYS AS IDENTITY`; `identity default` / `identity by default` → `GENERATED BY DEFAULT AS IDENTITY` | |
|
||||
|
||||
### `@sqlite`
|
||||
|
||||
| Key | Locations | SQL emitted | Notes |
|
||||
|-----|-----------|-------------|-------|
|
||||
| `without` | table | `WITHOUT ROWID` table option | `@sqlite: without rowid` |
|
||||
| `strict` | table | `STRICT` table option | `WITHOUT ROWID` is emitted before `STRICT` |
|
||||
| `collate` | column | ` COLLATE <name>` in the column definition | e.g. `@sqlite(name): collate NOCASE` |
|
||||
|
||||
Unknown namespaces and keys not in these tables are still preserved losslessly
|
||||
(and round-trip through the DBML writer) whenever strict mode is off.
|
||||
@@ -0,0 +1,103 @@
|
||||
# Format Usage Examples
|
||||
|
||||
Examples for `relspec convert` covering the file-based reader and writer
|
||||
formats. The "Writers" and "Readers" sections below were run against
|
||||
`examples/test_schema.dbml`. The cross-format and live-database examples were
|
||||
not run; they follow the flags shown in `relspec convert --help` and require
|
||||
matching input files or reachable databases.
|
||||
|
||||
Any reader can be combined with any writer: pick `--from`/`--from-path` for the
|
||||
source and `--to`/`--to-path` for the target. Add `--silent` to suppress progress
|
||||
output.
|
||||
|
||||
## Writers: DBML to every format
|
||||
|
||||
```bash
|
||||
S="--from dbml --from-path examples/test_schema.dbml"
|
||||
|
||||
relspec convert $S --to json --to-path schema.json
|
||||
relspec convert $S --to yaml --to-path schema.yaml
|
||||
relspec convert $S --to dctx --to-path schema.dctx
|
||||
relspec convert $S --to drawdb --to-path schema.drawdb.json
|
||||
relspec convert $S --to graphql --to-path schema.graphql
|
||||
relspec convert $S --to prisma --to-path schema.prisma
|
||||
relspec convert $S --to pgsql --to-path schema.pg.sql
|
||||
relspec convert $S --to mssql --to-path schema.mssql.sql
|
||||
relspec convert $S --to sqlite --to-path schema.sqlite.sql
|
||||
relspec convert $S --to drizzle --to-path schema.ts
|
||||
relspec convert $S --to typeorm --to-path entities.ts
|
||||
relspec convert $S --to gorm --to-path models.go --package models
|
||||
relspec convert $S --to bun --to-path models.go --package models
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Code-generation writers (`gorm`, `bun`) take `--package`. They also accept
|
||||
`--types baselib|stdlib|sqltypes` to choose the nullable type package.
|
||||
- When `--to-path` is a directory it must already exist.
|
||||
- `sqlite` output automatically flattens `schema.table` names. Use
|
||||
`--flatten-schema` for other formats if the target has no schema support.
|
||||
- `dctx` supports a single schema only; use `--schema <name>` to select one.
|
||||
|
||||
## Readers: file-based formats into DBML (or JSON where noted)
|
||||
|
||||
```bash
|
||||
relspec convert --from json --from-path schema.json --to dbml --to-path out.dbml
|
||||
relspec convert --from yaml --from-path schema.yaml --to dbml --to-path out.dbml
|
||||
relspec convert --from dctx --from-path schema.dctx --to dbml --to-path out.dbml
|
||||
relspec convert --from drawdb --from-path schema.drawdb.json --to dbml --to-path out.dbml
|
||||
relspec convert --from graphql --from-path schema.graphql --to dbml --to-path out.dbml
|
||||
relspec convert --from prisma --from-path schema.prisma --to dbml --to-path out.dbml
|
||||
relspec convert --from drizzle --from-path schema.ts --to dbml --to-path out.dbml
|
||||
relspec convert --from typeorm --from-path entities.ts --to dbml --to-path out.dbml
|
||||
relspec convert --from bun --from-path models.go --to dbml --to-path out.dbml
|
||||
relspec convert --from gorm --from-path models.go --to json --to-path out.json
|
||||
```
|
||||
|
||||
Code-first readers (`gorm`, `bun`, `drizzle`, `typeorm`) accept a single file or a
|
||||
directory of model files.
|
||||
|
||||
> Known issue: reading GORM models and writing DBML currently panics in the DBML
|
||||
> writer (`pkg/writers/dbml/writer.go`, `constraintToDBML`). Use another target
|
||||
> such as JSON until this is fixed.
|
||||
|
||||
## Cross-format combinations
|
||||
|
||||
```bash
|
||||
# ORM models to SQL DDL
|
||||
relspec convert --from gorm --from-path models.go --to pgsql --to-path schema.sql
|
||||
|
||||
# Prisma to Drizzle
|
||||
relspec convert --from prisma --from-path schema.prisma --to drizzle --to-path schema.ts
|
||||
|
||||
# DrawDB diagram to GraphQL
|
||||
relspec convert --from drawdb --from-path diagram.json --to graphql --to-path schema.graphql
|
||||
|
||||
# Merge several files while converting
|
||||
relspec convert --from json --from-list "a.json,b.json" --to yaml --to-path merged.yaml
|
||||
```
|
||||
|
||||
## Live databases
|
||||
|
||||
These need a reachable database:
|
||||
|
||||
```bash
|
||||
# PostgreSQL
|
||||
relspec convert --from pgsql --from-conn "postgres://user:pass@localhost:5432/mydb" \
|
||||
--to dbml --to-path schema.dbml
|
||||
|
||||
# SQL Server
|
||||
relspec convert --from mssql --from-conn "<mssql connection string>" \
|
||||
--to json --to-path schema.json
|
||||
|
||||
# SQLite database file (--from-conn takes the file path)
|
||||
relspec convert --from sqlite --from-conn ./app.db --to dbml --to-path schema.dbml
|
||||
```
|
||||
|
||||
## Formats outside `convert`
|
||||
|
||||
- `sqldir` (SQL script directory reader) and `sqlexec` (SQL execution writer) are
|
||||
used by `relspec scripts` and `relspec job`, and `sqldir` by `relspec diff`.
|
||||
See [SCRIPTS_COMMAND.md](SCRIPTS_COMMAND.md) and [JOB_FILES.md](JOB_FILES.md).
|
||||
- The `template` writer is exposed through `relspec templ`. See
|
||||
[TEMPLATE_MODE.md](TEMPLATE_MODE.md).
|
||||
@@ -0,0 +1,376 @@
|
||||
# RelSpec Job Files
|
||||
|
||||
Job files let you declare named, repeatable RelSpec workflows in YAML and run
|
||||
them with `relspec job run <name>` instead of retyping long command lines.
|
||||
|
||||
```bash
|
||||
relspec job list # deterministic list of discovered jobs
|
||||
relspec job run build-schema --plan # validate + print plan, execute nothing
|
||||
relspec job run build-schema # run the job (and its dependencies)
|
||||
```
|
||||
|
||||
## Design contract
|
||||
|
||||
This is a deliberately small, safe contract. Every capability is offline-testable
|
||||
except live database execution (`scripts-exec`), which is validated and planned
|
||||
offline and only connects at run time.
|
||||
|
||||
### Not a shell
|
||||
|
||||
`command` is a **closed allow-list**. There is no field anywhere that accepts a
|
||||
shell string, an executable path, or arbitrary arguments. Adding a new command
|
||||
means adding a vetted adapter in the RelSpec source.
|
||||
|
||||
| command | what it does |
|
||||
|----------------|--------------------------------------------------------------------|
|
||||
| `convert` | read one or more input schemas, additively merge them, write one output |
|
||||
| `merge` | like `convert` but requires ≥2 inputs and exposes `skip_*` merge options |
|
||||
| `split` | read one or more schemas, keep the selected schemas/tables, write one output |
|
||||
| `scripts-list` | deterministically list SQL scripts across one or more directories |
|
||||
| `scripts-exec` | execute SQL scripts across one or more directories against a live PostgreSQL database |
|
||||
| `templ` | apply a custom Go text template to one or more input schemas |
|
||||
| `inspect` | validate one or more schemas against rules and write a report |
|
||||
| `diff` | compare exactly two schemas and write a differences report |
|
||||
|
||||
`convert`, `merge` and `split` are **producers**: their file output can be fed
|
||||
directly into another job with `from_job` (see below).
|
||||
|
||||
### Discovery and precedence
|
||||
|
||||
`relspec job` (no `--file`) scans `--dir` (default `.`) for:
|
||||
|
||||
1. `relspec.yml` / `relspec.yaml` (the default file), then
|
||||
2. `relspec.<name>.yml` / `relspec.<name>.yaml` (extra files),
|
||||
|
||||
each group sorted lexically. Order is stable across runs. Use `--file <path>`
|
||||
(repeatable) to load explicit files and skip discovery.
|
||||
|
||||
All discovered/selected files are merged into one job namespace. A job name
|
||||
defined by **more than one file is a hard error** naming both files. YAML maps
|
||||
already forbid duplicate keys within a single file.
|
||||
|
||||
### Paths
|
||||
|
||||
* Every path (`inputs[].path`, `output.path`, `report.path`, `rules`,
|
||||
`script_dirs[]`, `template`, `logfile`) is **relative to the directory
|
||||
containing the job file that declared the job**, not the process working
|
||||
directory.
|
||||
* Those path fields support `${NAME}` environment-variable references. They
|
||||
are expanded immediately before execution; a missing variable is an error.
|
||||
The expanded value is still subject to all relative-path and symlink safety
|
||||
checks.
|
||||
* Absolute paths, `~`-relative paths and any path that resolves outside the job
|
||||
file directory (`../`, `a/../../b`, …) are **rejected during validation** —
|
||||
before anything runs.
|
||||
* At run time each path is additionally resolved through its symlinks: a symlink
|
||||
inside the job-file directory that points outside it is rejected before the
|
||||
path is opened.
|
||||
|
||||
### Credentials
|
||||
|
||||
* Database inputs (`format: pgsql` / `mssql`) and database execution outputs
|
||||
(`format: pgsql` with `conn_env`) reference an **environment variable name**
|
||||
via `conn_env:`. The connection string itself is never stored in the
|
||||
manifest.
|
||||
* A `conn_env` value that looks like a connection string (contains `:`, `/`,
|
||||
`@`, `=`, spaces) is rejected.
|
||||
* Missing/empty environment variables are reported during pre-flight, before
|
||||
execution.
|
||||
* Job logs and `--plan` output show `env:<NAME>`, never the value. Resolved
|
||||
secret values and anything matching a connection-string password are
|
||||
redacted (`***`) from the logfile and diagnostics.
|
||||
|
||||
### Validation happens before execution
|
||||
|
||||
`relspec job list` and `relspec job run` both fully validate the selected set
|
||||
first. Nothing is read, written, connected to, or executed if validation fails.
|
||||
Checks include:
|
||||
|
||||
* schema `version` — **forward-permissive**: any version `>= 1` is accepted.
|
||||
An omitted `version` is treated as the current one. A version newer than this
|
||||
build understands loads best-effort (unknown YAML fields are ignored and a
|
||||
warning is printed); at the current version unknown YAML fields are still
|
||||
rejected.
|
||||
* duplicate job names across files
|
||||
* unknown / missing `command`
|
||||
* per-command input/output shape:
|
||||
* `convert` needs ≥1 input + output; `merge` needs ≥2 inputs + output
|
||||
* `split` needs ≥1 input + a file output, plus an optional `select:` block
|
||||
* `scripts-list` needs `script_dirs` and forbids inputs/output
|
||||
* `scripts-exec` needs `script_dirs` and `output.conn_env` (pgsql only)
|
||||
* `inspect` needs ≥1 input + `report:` (format `markdown`|`json`)
|
||||
* `diff` needs **exactly 2** inputs + `report:` (format `summary`|`json`|`html`)
|
||||
* unknown input/output `format`
|
||||
* `from_job` targets exist, are producers (`convert`/`merge`/`split`) and write a
|
||||
single-file output
|
||||
* path traversal / absolute / home-relative paths
|
||||
* `depends_on` and `from_job` targets exist
|
||||
* dependency cycles over the combined `depends_on` + `from_job` graph
|
||||
(reported as `a -> b -> c -> a`)
|
||||
|
||||
Then, immediately before running, per-job pre-flight resolves paths and checks:
|
||||
|
||||
* every input file exists and is a file (a `from_job` input is exempt — its
|
||||
producer runs earlier in the same plan)
|
||||
* every `script_dir` exists and is a directory
|
||||
* every `conn_env` variable is set
|
||||
* `output.path` / `report.path` does not already exist unless the matching
|
||||
`overwrite: true` is set
|
||||
* `rules` (inspect), when given, exists and is a file
|
||||
* symlinks in every resolved path stay inside the job-file directory
|
||||
|
||||
If any pre-flight check fails for **any** job in the plan, **no** job runs.
|
||||
|
||||
### Execution and exit codes
|
||||
|
||||
* `relspec job run <name>` runs the job's dependency closure first
|
||||
(`depends_on` plus any `from_job` producers), in topological order
|
||||
(deterministic), then the job. `--no-deps` runs only the named job and is
|
||||
incompatible with `from_job` inputs.
|
||||
* `--dry-run` (alias `--plan`) prints the resolved plan and exits 0 without
|
||||
touching inputs, outputs or databases.
|
||||
* A failing job returns the underlying non-zero status (the process exits 1)
|
||||
and the error names the job. The logfile records `FAILED: <error>`; a
|
||||
successful job records `OK`. No separate success-marker file is written, so a
|
||||
failure can never leave a stale "success".
|
||||
* `inspect` fails the job when the report contains rule **errors** (enforced
|
||||
rules); warnings do not fail it. `diff` never fails on differences.
|
||||
* Single-file outputs and reports are written to a temporary file in the target
|
||||
directory and atomically renamed into place, so an interrupted run never
|
||||
leaves a partial file. Directory-emitting formats (`gorm`, `bun`, `drizzle`,
|
||||
`typeorm`, `prisma`) are written in place.
|
||||
|
||||
### Logfile rotation
|
||||
|
||||
When a job has a `logfile`, it is size-rotated before each run. Defaults are
|
||||
**5 MB** with **3** rotated files kept (`build.log` → `build.log.1` → …). Override
|
||||
per job with `log_max_size` / `log_keep`, or for a whole file with a top-level
|
||||
`defaults:` block. `log_max_size` accepts `B`/`KB`/`MB`/`GB` suffixes (e.g.
|
||||
`"512KB"`, `"5MB"`).
|
||||
|
||||
## Schema reference
|
||||
|
||||
```yaml
|
||||
version: 1 # optional; any value >= 1 is accepted
|
||||
defaults: # optional, file-wide
|
||||
log_max_size: 5MB # B / KB / MB / GB
|
||||
log_keep: 3
|
||||
jobs:
|
||||
<job-name>:
|
||||
command: convert | merge | split | scripts-list | scripts-exec | templ | inspect | diff
|
||||
description: "free text" # optional, shown by `job list`
|
||||
depends_on: [other-job, ...] # optional
|
||||
inputs: # convert (≥1) / merge (≥2) / split (≥1) / inspect (≥1) / diff (exactly 2)
|
||||
- path: relative/file.dbml # file inputs
|
||||
format: dbml
|
||||
- format: pgsql # live-connection inputs
|
||||
conn_env: SOURCE_DB_URL # env var NAME
|
||||
- from_job: build-schema # consume another job's file output
|
||||
script_dirs: # scripts-list / scripts-exec (≥1)
|
||||
- migrations/core
|
||||
- migrations/tenant
|
||||
template: templates/schema.tmpl # templ (required)
|
||||
mode: table # templ: database/schema/script/table
|
||||
filename_pattern: "{{.Name}}.go" # templ multi-output modes
|
||||
select: # split (optional; default = keep everything)
|
||||
schemas: [public]
|
||||
tables: [users, orders]
|
||||
exclude_schemas: []
|
||||
exclude_tables: []
|
||||
database_name: SubsetDB # optional rename of the output database
|
||||
rules: .relspec-rules.yaml # inspect (optional; built-in defaults if omitted)
|
||||
report: # inspect (required) / diff (required)
|
||||
format: json # inspect: markdown|json ; diff: summary|json|html
|
||||
path: build/report.json # required, except a diff "summary" (goes to the log)
|
||||
overwrite: false
|
||||
output: # convert / merge / split (required); scripts-exec (required, conn_env)
|
||||
format: pgsql
|
||||
path: build/schema.sql # file output, OR:
|
||||
conn_env: TARGET_DB_URL # execute against DB (pgsql only)
|
||||
# default: reads live DB, executes only the diff
|
||||
# (file output = full DDL); set options.full_ddl to force full DDL
|
||||
overwrite: false # default false
|
||||
options:
|
||||
flatten_schema: false
|
||||
schema: public
|
||||
package: models # for gorm/bun output
|
||||
types: sqltypes # Bun/GORM nullable types: baselib|stdlib|sqltypes
|
||||
array_nullable: pointer_slice # Bun: slice|pointer_slice
|
||||
continue_on_error: false # pgsql / scripts-exec output
|
||||
full_ddl: false # pgsql database output: true = run full DDL; default diffs against live DB
|
||||
skip_relations: false # merge only
|
||||
skip_enums: false
|
||||
skip_views: false
|
||||
skip_domains: false
|
||||
skip_sequences: false
|
||||
logfile: .relspec/log/<job-name>.log # optional; appended to, size-rotated
|
||||
log_max_size: 5MB # optional per-job override
|
||||
log_keep: 3 # optional per-job override
|
||||
```
|
||||
|
||||
For `templ`, `inputs` use the same file or `pgsql`/`conn_env` source forms as
|
||||
schema conversion. `output` is optional (empty means stdout); when present it
|
||||
contains `path` and `overwrite`, plus the explicit `format: text` marker. The
|
||||
marker is templ-only: it describes arbitrary text produced by a Go template,
|
||||
not a database schema format. It is optional for compatibility with older
|
||||
manifests and has no effect on template execution.
|
||||
|
||||
A `from_job` input takes no `path`, `format` or `conn_env`: it resolves to the
|
||||
named job's `output.path` and inherits its format, and implies a dependency on
|
||||
that job. The producer must be a `convert`, `merge` or `split` job writing a
|
||||
single-file output.
|
||||
|
||||
### Supported input formats
|
||||
|
||||
`dbml`, `dctx`, `drawdb`, `graphql`, `json`, `yaml`, `gorm`, `bun`, `drizzle`,
|
||||
`prisma`, `typeorm`, `sqlite` (file, via `path`); `pgsql`, `mssql`
|
||||
(live, via `conn_env`).
|
||||
|
||||
### Supported output formats
|
||||
|
||||
`dbml`, `dctx`, `drawdb`, `graphql`, `json`, `yaml`, `gorm`, `bun`, `drizzle`,
|
||||
`prisma`, `typeorm`, `pgsql`, `mssql`, `sqlite` (file, via `path`); `pgsql` also
|
||||
supports `conn_env` to execute the generated DDL against a live database.
|
||||
|
||||
## Examples
|
||||
|
||||
### Merge many schema files, emit PostgreSQL DDL
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
jobs:
|
||||
build-schema:
|
||||
command: convert
|
||||
inputs:
|
||||
- { path: schema/core.dbml, format: dbml }
|
||||
- { path: schema/billing.dbml, format: dbml }
|
||||
- { path: schema/tenant.dbml, format: dbml }
|
||||
output:
|
||||
format: pgsql
|
||||
path: build/schema.sql
|
||||
overwrite: true
|
||||
logfile: .relspec/log/build-schema.log
|
||||
```
|
||||
|
||||
### Multiple script directories
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
jobs:
|
||||
migration-order:
|
||||
command: scripts-list
|
||||
script_dirs:
|
||||
- migrations/core
|
||||
- migrations/tenant
|
||||
- migrations/reporting
|
||||
logfile: .relspec/log/migration-order.log
|
||||
```
|
||||
|
||||
### Job depending on another job
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
jobs:
|
||||
build-schema:
|
||||
command: convert
|
||||
inputs:
|
||||
- { path: schema/core.dbml, format: dbml }
|
||||
- { path: schema/tenant.dbml, format: dbml }
|
||||
output: { format: json, path: build/schema.json, overwrite: true }
|
||||
build-docs:
|
||||
command: convert
|
||||
depends_on: [build-schema]
|
||||
inputs:
|
||||
- { path: schema/core.dbml, format: dbml }
|
||||
output: { format: yaml, path: build/schema.yaml, overwrite: true }
|
||||
```
|
||||
|
||||
### Reading from a remote database
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
jobs:
|
||||
snapshot-prod:
|
||||
command: convert
|
||||
inputs:
|
||||
- format: pgsql
|
||||
conn_env: PROD_DB_URL # export PROD_DB_URL=postgres://...
|
||||
output:
|
||||
format: dbml
|
||||
path: snapshots/prod.dbml
|
||||
overwrite: true
|
||||
```
|
||||
|
||||
### Chain jobs with `from_job`, then lint the result
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
jobs:
|
||||
build-json:
|
||||
command: convert
|
||||
inputs:
|
||||
- { path: schema/core.dbml, format: dbml }
|
||||
- { path: schema/tenant.dbml, format: dbml }
|
||||
output: { format: json, path: build/schema.json, overwrite: true }
|
||||
lint-schema:
|
||||
command: inspect
|
||||
inputs:
|
||||
- from_job: build-json # implies depends_on: [build-json]
|
||||
rules: .relspec-rules.yaml # optional; built-in rules if omitted
|
||||
report:
|
||||
format: markdown
|
||||
path: build/lint-report.md
|
||||
overwrite: true
|
||||
```
|
||||
|
||||
`relspec job run lint-schema` runs `build-json` first, then inspects its output.
|
||||
The job fails (exit 1) if any enforced rule is violated.
|
||||
|
||||
### Split a subset out of a larger schema
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
jobs:
|
||||
posts-only:
|
||||
command: split
|
||||
inputs:
|
||||
- { path: schema/core.dbml, format: dbml }
|
||||
- { path: schema/tenant.dbml, format: dbml }
|
||||
select:
|
||||
tables: [posts]
|
||||
output: { format: dbml, path: build/posts.dbml, overwrite: true }
|
||||
```
|
||||
|
||||
### Diff two schemas
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
jobs:
|
||||
drift:
|
||||
command: diff
|
||||
inputs: # exactly two
|
||||
- { path: build/schema.json, format: json }
|
||||
- format: pgsql
|
||||
conn_env: PROD_DB_URL
|
||||
report:
|
||||
format: summary # summary → logfile; json/html need a path
|
||||
```
|
||||
|
||||
`diff` reports differences and always exits 0.
|
||||
|
||||
### Execute migration scripts against a live database
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
jobs:
|
||||
apply-migrations:
|
||||
command: scripts-exec
|
||||
script_dirs:
|
||||
- migrations/core
|
||||
- migrations/tenant
|
||||
output:
|
||||
conn_env: TARGET_DB_URL # pgsql only; no path
|
||||
options:
|
||||
continue_on_error: false
|
||||
logfile: .relspec/log/apply-migrations.log
|
||||
```
|
||||
@@ -85,6 +85,23 @@ migrations/
|
||||
|
||||
All files will be found and executed in Priority→Sequence order regardless of directory structure.
|
||||
|
||||
## External File Embedding
|
||||
|
||||
Script SQL can embed nearby text or binary files before execution using `-- @embed` directives:
|
||||
|
||||
```sql
|
||||
-- @embed: path=assets/message.txt var=:message mode=text
|
||||
-- @embed: path=assets/photo.bin var=:payload mode=base64
|
||||
INSERT INTO assets (message, payload)
|
||||
VALUES (:message, decode(:payload, 'base64')::bytea);
|
||||
```
|
||||
|
||||
- `path`: File path resolved relative to the SQL file containing the directive
|
||||
- `var`: Named placeholder to replace, such as `:message`
|
||||
- `mode`: `text` embeds an escaped SQL string literal; `base64` embeds a base64 string literal
|
||||
|
||||
The directive comment is removed from the SQL, and every matching placeholder is replaced before the script is listed or executed.
|
||||
|
||||
## Commands
|
||||
|
||||
### relspec scripts list
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
# TUI mouse support plan
|
||||
|
||||
Issue: #46
|
||||
Status: design only; this document does not implement mouse input.
|
||||
|
||||
## 1. Current implementation and scope
|
||||
|
||||
The editor is created in `cmd/relspec/edit.go` by
|
||||
`ui.NewSchemaEditorWithConfigs(...).Run()`. `pkg/ui/editor.go` owns the
|
||||
`tview.Application`, `tview.Pages`, and application lifecycle. The current
|
||||
code never calls `Application.EnableMouse`, so tcell mouse reporting is off.
|
||||
The module uses tview v0.42.0 and tcell/v2 v2.13.9.
|
||||
|
||||
The first implementation should add `--no-mouse` to the `edit` Cobra command
|
||||
only. The flag is a local boolean, defaulting to false, and should be passed
|
||||
explicitly into the editor (prefer an options/config field rather than a
|
||||
package-global or environment variable). It must not affect convert, inspect,
|
||||
merge, or other commands. There is no environment-variable or persistent
|
||||
configuration setting in this issue: a command-line opt-out is predictable,
|
||||
visible in `edit --help`, and avoids adding configuration precedence rules.
|
||||
|
||||
At startup, the editor should call `app.EnableMouse(!noMouse)` before
|
||||
`Run()`. `--no-mouse` must mean that the application does not enable terminal
|
||||
mouse reporting and that no custom mouse handlers are relied upon. Keyboard
|
||||
behavior must remain identical in both modes.
|
||||
|
||||
Likely implementation files are `cmd/relspec/edit.go`,
|
||||
`pkg/ui/editor.go`, focused TUI mouse helpers/tests under `pkg/ui`, and a
|
||||
short user-facing note in the command help or TUI documentation. Do not
|
||||
refactor unrelated screens or data operations.
|
||||
|
||||
## 2. Widget and screen coverage
|
||||
|
||||
The application composes `Pages`, `Flex`, `TextView`, `List`, `Table`, `Form`,
|
||||
`Button`, `InputField`, `DropDown`, `TextArea`, `CheckBox`, and `Modal`.
|
||||
Vendored tview confirms mouse handlers exist for all of those relevant
|
||||
primitives, including focus on left-down, list/table selection, button clicks,
|
||||
form child dispatch, dropdown opening/drag selection, text-area cursor and
|
||||
scrolling, and modal button dispatch. `Pages`, `Flex`, and `Form` forward events
|
||||
to their children.
|
||||
|
||||
The coverage plan is:
|
||||
|
||||
* Main menu (`pkg/ui/main_menu.go`): left click focuses/selects a list entry;
|
||||
second activation opens it; buttons and exit confirmation remain reachable.
|
||||
* Schema, table, domain, object, relation, and database screens: click a row
|
||||
to select it; double-click the row to perform the same action as the
|
||||
keyboard Enter/selected callback where opening is meaningful; scroll lists
|
||||
and tables; click each action button.
|
||||
* Tables (`schema_screens.go`, `table_screens.go`, and object/relation tables):
|
||||
tview's table handler provides selection and scrolling, but it does not
|
||||
provide application-specific double-click activation. Add a small reusable
|
||||
wrapper/helper for the table instances that need it. It must preserve the
|
||||
existing selected row/column behavior and invoke the same callback as Enter,
|
||||
not duplicate mutation logic.
|
||||
* Forms (`load_save_screens.go` and the form-building screen files): click an
|
||||
input to focus it, click buttons to activate them, click a dropdown to open
|
||||
it and choose an option, scroll multiline help/text areas, and retain all
|
||||
existing keyboard Tab/Shift-Tab, shortcut, Enter, and Escape behavior.
|
||||
* Dialogs (`pkg/ui/dialogs.go` plus confirmation/error/success modals): modal
|
||||
buttons are clickable and the modal keeps focus above the underlying page.
|
||||
Clicking outside a modal must not activate the hidden page or dismiss a
|
||||
destructive confirmation. Escape and the existing button-key behavior stay
|
||||
authoritative.
|
||||
* The planned file browser and connection-string builder from issue #44 must
|
||||
use the same contracts: clickable entries/buttons and scrolling, with
|
||||
keyboard navigation and explicit cancel/accept paths. #46 should not
|
||||
implement #44's widgets; it should define the integration point and test
|
||||
them when #44 lands.
|
||||
|
||||
Do not promise drag semantics for every widget. Drag is appropriate for text
|
||||
selection/cursor movement and dropdown selection where tview already supports
|
||||
it. For ordinary list/table navigation, a click selects and the wheel scrolls;
|
||||
row dragging should not mutate data.
|
||||
|
||||
## 3. Exact mouse action contract
|
||||
|
||||
| Widget/type | Left down/click | Double click | Wheel/drag | Keyboard fallback |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Main/list menu | focus and select row | invoke row selected callback | scroll list | arrows, Enter, shortcuts |
|
||||
| Data table | focus and select cell/row | invoke the screen's existing open/edit action for the selected row | vertical/horizontal scroll as supported by tview | arrows, PageUp/PageDown, Enter, existing shortcuts |
|
||||
| Button | focus | same as one activation, never duplicate the callback | none | Tab/Shift-Tab, Enter/Space and existing shortcut |
|
||||
| Input field | focus; place cursor if supported | no destructive action | text-area behavior if provided by tview | typing, arrows, Home/End, Tab, Escape |
|
||||
| Text area/help | focus and position cursor | select word only where tview supports it; no application action | scroll; drag text selection if supported | arrows, PageUp/PageDown, standard editing keys |
|
||||
| Dropdown | focus/open and choose the hit option | same as click; no duplicate selection | drag through options only while open | arrows, Enter, Escape, Tab |
|
||||
| Checkbox | toggle on click | no second toggle | none | Space and existing form navigation |
|
||||
| Modal | focus/click visible button | same button action once | no underlying-page scrolling | Tab/Shift-Tab, Enter, Escape, existing button keys |
|
||||
| Blank/border/title area | focus containing primitive where useful | none | no mutation | current screen shortcuts |
|
||||
|
||||
Right and middle clicks should have no application action in the first
|
||||
release. Wheel events should be consumed only by the scrollable primitive
|
||||
under the pointer. Double-click timing/translation should come from tview/
|
||||
tcell; custom code must not fire the action once for both the click and the
|
||||
double-click. Any custom table wrapper needs a small state machine or tview
|
||||
mouse action handling that is tested for this property.
|
||||
|
||||
## 4. tview gaps and implementation boundaries
|
||||
|
||||
Enabling mouse support is not sufficient for the desired behavior. tview's
|
||||
built-in Table handler selects cells and scrolls but has no repository-specific
|
||||
row-open callback on double click. Existing screen code also wires keyboard
|
||||
input captures directly on individual widgets, so mouse actions must call the
|
||||
same screen callbacks rather than route through synthetic key events.
|
||||
|
||||
Use tview's `MouseHandler`/`WrapMouseHandler` contracts and `setFocus` rather
|
||||
than reading terminal coordinates in each screen. A reusable table adapter
|
||||
may embed `*tview.Table`, delegate ordinary actions to the original table
|
||||
handler, and add the screen's double-click callback. Keep the adapter in
|
||||
`pkg/ui` and use it only where a row-opening action exists. Do not modify the
|
||||
vendored tview copy.
|
||||
|
||||
The `Pages`/`Modal` dispatch order must be verified: a visible modal consumes
|
||||
its click before the page below it. Page transitions should happen only in the
|
||||
existing callbacks, so a stale hidden page cannot receive a click.
|
||||
|
||||
## 5. Keyboard, terminal, and copy/paste behavior
|
||||
|
||||
Mouse is an enhancement, never a requirement. Every acceptance path must be
|
||||
reachable with the existing keyboard controls, including load/save, navigation,
|
||||
editing, confirmations, cancel, and exit. `--no-mouse` is the regression mode
|
||||
for proving this contract.
|
||||
|
||||
Mouse reporting is terminal capability dependent. On local terminals it is
|
||||
negotiated by tcell; tmux and SSH can suppress, translate, or fail to pass
|
||||
mouse reporting depending on their configuration. The application must still
|
||||
start and remain keyboard usable if mouse reporting is unavailable or broken.
|
||||
Documentation should state that terminal/tmux configuration may be required,
|
||||
and that SSH behavior depends on the remote terminal path. Windows Terminal and
|
||||
other Windows console hosts should be treated as supported only insofar as the
|
||||
selected tcell backend reports mouse events; the CLI must not assume POSIX
|
||||
escape sequences or add platform-specific code in this issue.
|
||||
|
||||
Enabling mouse capture normally prevents terminal-native selection/copy from
|
||||
seeing ordinary button-drag events. Document the standard workaround: hold the
|
||||
terminal's bypass modifier (commonly Shift, terminal-dependent) for selection,
|
||||
or use `--no-mouse` when native copy/paste is the priority. Do not implement a
|
||||
second clipboard protocol. Input-field/text-area copy/paste must continue to
|
||||
use tview/tcell paste handling and keyboard shortcuts; verify that enabling
|
||||
mouse does not intercept paste events.
|
||||
|
||||
## 6. Test strategy using tcell simulation
|
||||
|
||||
Add focused tests rather than attempting a full interactive end-to-end test.
|
||||
Use `tcell.NewSimulationScreen("")`, `screen.Init()`, construct the editor or
|
||||
an isolated primitive tree, and inject events with the actual API:
|
||||
`SimulationScreen.InjectMouse(x, y, buttons, mod)` and `InjectKey(...)`.
|
||||
Coordinates must be derived from the primitive's drawn rectangle or fixed by a
|
||||
small deterministic test layout; do not use arbitrary coordinates without
|
||||
checking the rendered screen.
|
||||
|
||||
Minimum cases:
|
||||
|
||||
1. Default editor configuration enables mouse; the explicit disabled option
|
||||
leaves it disabled. If the Application API is not observable directly,
|
||||
test through the simulation screen's event path plus a constructor-level
|
||||
option assertion.
|
||||
2. A list click changes focus/selection, and double-click invokes the existing
|
||||
selected action exactly once.
|
||||
3. A table click selects the expected row/cell, wheel events change the visible
|
||||
offset, and double-click invokes the row action exactly once.
|
||||
4. Form button, input field, checkbox, and dropdown clicks match their
|
||||
keyboard callbacks.
|
||||
5. A modal button click acts on the modal and cannot activate the underlying
|
||||
page; Escape still cancels.
|
||||
6. `--no-mouse` leaves keyboard selection/activation unchanged and mouse
|
||||
injection has no application effect.
|
||||
7. Existing dialogs and screen transitions do not leave a stale mouse capture
|
||||
after a page is removed.
|
||||
|
||||
Prefer callback counters and selected-index assertions over screen-text-only
|
||||
assertions. Run the relevant `pkg/ui` tests with `go test -race ./pkg/ui` and
|
||||
run the full repository test suite if time/resources permit.
|
||||
|
||||
## 7. Rollout and acceptance criteria
|
||||
|
||||
Implementation is ready for review when:
|
||||
|
||||
* `relspec edit --help` documents `--no-mouse` and mouse is enabled by default.
|
||||
* Only the edit TUI is affected; non-TUI commands have no changed behavior.
|
||||
* Main screens, tables, lists, forms, dropdowns, buttons, text areas, and
|
||||
visible dialogs support the action contract above.
|
||||
* Keyboard-only operation is complete and verified with `--no-mouse`.
|
||||
* Modal clicks cannot fall through to an underlying page.
|
||||
* Table double-click behavior is explicit, tested, and does not duplicate
|
||||
activation.
|
||||
* tcell simulation tests cover default-on, opt-out, selection, scrolling,
|
||||
activation, dialog focus, and keyboard fallback.
|
||||
* `go test -race ./pkg/ui`, appropriate command tests, `go test ./...`,
|
||||
formatting, and `git diff --check` pass (or any limitation is recorded).
|
||||
* User-facing docs explain tmux/SSH/Windows variability and the terminal
|
||||
modifier workaround for native copy/paste.
|
||||
|
||||
Roll out in two implementation slices if needed: first application option,
|
||||
standard tview handlers, tests, and documentation; second only the reusable
|
||||
table double-click adapter and screen wiring. Do not block the first slice on
|
||||
issue #44, but do not claim #44's future widgets are covered until they use the
|
||||
same contract.
|
||||
|
||||
## 8. Open decisions and dependencies
|
||||
|
||||
* Confirm whether the project wants a public editor options type or a small
|
||||
`SetMouseEnabled`/constructor parameter; avoid a global flag.
|
||||
* Confirm the preferred terminal copy modifier in project documentation, since
|
||||
tmux, SSH clients, and Windows Terminal differ.
|
||||
* Decide whether horizontal wheel events should be supported where tview/table
|
||||
exposes them; vertical scrolling is mandatory, horizontal is optional.
|
||||
* Decide whether double-click opens every data table or only tables with an
|
||||
unambiguous row action. The plan recommends the latter.
|
||||
* Confirm #44's file-browser and connection-builder primitive choices before
|
||||
wiring their mouse tests.
|
||||
* Confirm CI has a stable non-terminal environment for simulation-screen tests;
|
||||
no real terminal, tmux session, database, or network should be required.
|
||||
@@ -3,17 +3,17 @@ package models_bun
|
||||
// //ModelCoreMasterprocess - Generated Table for Schema core
|
||||
// type ModelCoreMasterprocess struct {
|
||||
// bun.BaseModel `bun:"table:core.masterprocess,alias:masterprocess"`
|
||||
// Description resolvespec_common.SqlString `json:"description" bun:"description,type:citext,"`
|
||||
// GUID resolvespec_common.SqlUUID `json:"guid" bun:"guid,type:uuid,default:newid(),"`
|
||||
// Inactive resolvespec_common.SqlInt16 `json:"inactive" bun:"inactive,type:smallint,"`
|
||||
// Jsonvalue resolvespec_common.SqlJSONB `json:"jsonvalue" bun:"jsonvalue,type:jsonb,"`
|
||||
// Ridjsonschema resolvespec_common.SqlInt32 `json:"rid_jsonschema" bun:"rid_jsonschema,type:integer,"`
|
||||
// Ridmasterprocess resolvespec_common.SqlInt32 `json:"rid_masterprocess" bun:"rid_masterprocess,type:integer,pk,default:nextval('core.identity_masterprocess_rid_masterprocess'::regclass),"`
|
||||
// Ridmastertypehubtype resolvespec_common.SqlInt32 `json:"rid_mastertype_hubtype" bun:"rid_mastertype_hubtype,type:integer,"`
|
||||
// Ridmastertypeprocesstype resolvespec_common.SqlInt32 `json:"rid_mastertype_processtype" bun:"rid_mastertype_processtype,type:integer,"`
|
||||
// Ridprogrammodule resolvespec_common.SqlInt32 `json:"rid_programmodule" bun:"rid_programmodule,type:integer,"`
|
||||
// Sequenceno resolvespec_common.SqlInt32 `json:"sequenceno" bun:"sequenceno,type:integer,"`
|
||||
// Singleprocess resolvespec_common.SqlInt16 `json:"singleprocess" bun:"singleprocess,type:smallint,"`
|
||||
// Description sql_types.SqlString `json:"description" bun:"description,type:citext,"`
|
||||
// GUID sql_types.SqlUUID `json:"guid" bun:"guid,type:uuid,default:newid(),"`
|
||||
// Inactive sql_types.SqlInt16 `json:"inactive" bun:"inactive,type:smallint,"`
|
||||
// Jsonvalue sql_types.SqlJSONB `json:"jsonvalue" bun:"jsonvalue,type:jsonb,"`
|
||||
// Ridjsonschema sql_types.SqlInt32 `json:"rid_jsonschema" bun:"rid_jsonschema,type:integer,"`
|
||||
// Ridmasterprocess sql_types.SqlInt32 `json:"rid_masterprocess" bun:"rid_masterprocess,type:integer,pk,default:nextval('core.identity_masterprocess_rid_masterprocess'::regclass),"`
|
||||
// Ridmastertypehubtype sql_types.SqlInt32 `json:"rid_mastertype_hubtype" bun:"rid_mastertype_hubtype,type:integer,"`
|
||||
// Ridmastertypeprocesstype sql_types.SqlInt32 `json:"rid_mastertype_processtype" bun:"rid_mastertype_processtype,type:integer,"`
|
||||
// Ridprogrammodule sql_types.SqlInt32 `json:"rid_programmodule" bun:"rid_programmodule,type:integer,"`
|
||||
// Sequenceno sql_types.SqlInt32 `json:"sequenceno" bun:"sequenceno,type:integer,"`
|
||||
// Singleprocess sql_types.SqlInt16 `json:"singleprocess" bun:"singleprocess,type:smallint,"`
|
||||
// Updatecnt int64 `json:"updatecnt" bun:"updatecnt,type:integer,default:0,"`
|
||||
// JSON *ModelCoreJsonschema `json:"JSON,omitempty" bun:"rel:has-one,join:rid_jsonschema=rid_jsonschema"`
|
||||
// MTT_RID_MASTERTYPE_HUBTYPE *ModelCoreMastertype `json:"MTT_RID_MASTERTYPE_HUBTYPE,omitempty" bun:"rel:has-one,join:rid_mastertype_hubtype=rid_mastertype"`
|
||||
|
||||
@@ -3,26 +3,26 @@ package models_bun
|
||||
// //ModelCoreMastertask - Generated Table for Schema core
|
||||
// type ModelCoreMastertask struct {
|
||||
// bun.BaseModel `bun:"table:core.mastertask,alias:mastertask"`
|
||||
// Allactionsmustcomplete resolvespec_common.SqlInt16 `json:"allactionsmustcomplete" bun:"allactionsmustcomplete,type:smallint,"`
|
||||
// Condition resolvespec_common.SqlString `json:"condition" bun:"condition,type:citext,"`
|
||||
// Description resolvespec_common.SqlString `json:"description" bun:"description,type:citext,"`
|
||||
// Dueday resolvespec_common.SqlInt16 `json:"dueday" bun:"dueday,type:smallint,"`
|
||||
// Dueoption resolvespec_common.SqlString `json:"dueoption" bun:"dueoption,type:citext,"`
|
||||
// Escalation resolvespec_common.SqlInt32 `json:"escalation" bun:"escalation,type:integer,"`
|
||||
// Escalationoption resolvespec_common.SqlString `json:"escalationoption" bun:"escalationoption,type:citext,"`
|
||||
// GUID resolvespec_common.SqlUUID `json:"guid" bun:"guid,type:uuid,default:newid(),"`
|
||||
// Inactive resolvespec_common.SqlInt16 `json:"inactive" bun:"inactive,type:smallint,"`
|
||||
// Jsonvalue resolvespec_common.SqlJSONB `json:"jsonvalue" bun:"jsonvalue,type:jsonb,"`
|
||||
// Mastertasknote resolvespec_common.SqlString `json:"mastertasknote" bun:"mastertasknote,type:citext,"`
|
||||
// Repeatinterval resolvespec_common.SqlInt16 `json:"repeatinterval" bun:"repeatinterval,type:smallint,"`
|
||||
// Repeattype resolvespec_common.SqlString `json:"repeattype" bun:"repeattype,type:citext,"`
|
||||
// Ridjsonschema resolvespec_common.SqlInt32 `json:"rid_jsonschema" bun:"rid_jsonschema,type:integer,"`
|
||||
// Ridmasterprocess resolvespec_common.SqlInt32 `json:"rid_masterprocess" bun:"rid_masterprocess,type:integer,"`
|
||||
// Ridmastertask resolvespec_common.SqlInt32 `json:"rid_mastertask" bun:"rid_mastertask,type:integer,pk,default:nextval('core.identity_mastertask_rid_mastertask'::regclass),"`
|
||||
// Ridmastertypetasktype resolvespec_common.SqlInt32 `json:"rid_mastertype_tasktype" bun:"rid_mastertype_tasktype,type:integer,"`
|
||||
// Sequenceno resolvespec_common.SqlInt32 `json:"sequenceno" bun:"sequenceno,type:integer,"`
|
||||
// Singletask resolvespec_common.SqlInt16 `json:"singletask" bun:"singletask,type:smallint,"`
|
||||
// Startday resolvespec_common.SqlInt16 `json:"startday" bun:"startday,type:smallint,"`
|
||||
// Allactionsmustcomplete sql_types.SqlInt16 `json:"allactionsmustcomplete" bun:"allactionsmustcomplete,type:smallint,"`
|
||||
// Condition sql_types.SqlString `json:"condition" bun:"condition,type:citext,"`
|
||||
// Description sql_types.SqlString `json:"description" bun:"description,type:citext,"`
|
||||
// Dueday sql_types.SqlInt16 `json:"dueday" bun:"dueday,type:smallint,"`
|
||||
// Dueoption sql_types.SqlString `json:"dueoption" bun:"dueoption,type:citext,"`
|
||||
// Escalation sql_types.SqlInt32 `json:"escalation" bun:"escalation,type:integer,"`
|
||||
// Escalationoption sql_types.SqlString `json:"escalationoption" bun:"escalationoption,type:citext,"`
|
||||
// GUID sql_types.SqlUUID `json:"guid" bun:"guid,type:uuid,default:newid(),"`
|
||||
// Inactive sql_types.SqlInt16 `json:"inactive" bun:"inactive,type:smallint,"`
|
||||
// Jsonvalue sql_types.SqlJSONB `json:"jsonvalue" bun:"jsonvalue,type:jsonb,"`
|
||||
// Mastertasknote sql_types.SqlString `json:"mastertasknote" bun:"mastertasknote,type:citext,"`
|
||||
// Repeatinterval sql_types.SqlInt16 `json:"repeatinterval" bun:"repeatinterval,type:smallint,"`
|
||||
// Repeattype sql_types.SqlString `json:"repeattype" bun:"repeattype,type:citext,"`
|
||||
// Ridjsonschema sql_types.SqlInt32 `json:"rid_jsonschema" bun:"rid_jsonschema,type:integer,"`
|
||||
// Ridmasterprocess sql_types.SqlInt32 `json:"rid_masterprocess" bun:"rid_masterprocess,type:integer,"`
|
||||
// Ridmastertask sql_types.SqlInt32 `json:"rid_mastertask" bun:"rid_mastertask,type:integer,pk,default:nextval('core.identity_mastertask_rid_mastertask'::regclass),"`
|
||||
// Ridmastertypetasktype sql_types.SqlInt32 `json:"rid_mastertype_tasktype" bun:"rid_mastertype_tasktype,type:integer,"`
|
||||
// Sequenceno sql_types.SqlInt32 `json:"sequenceno" bun:"sequenceno,type:integer,"`
|
||||
// Singletask sql_types.SqlInt16 `json:"singletask" bun:"singletask,type:smallint,"`
|
||||
// Startday sql_types.SqlInt16 `json:"startday" bun:"startday,type:smallint,"`
|
||||
// Updatecnt int64 `json:"updatecnt" bun:"updatecnt,type:integer,default:0,"`
|
||||
// JSON *ModelCoreJsonschema `json:"JSON,omitempty" bun:"rel:has-one,join:rid_jsonschema=rid_jsonschema"`
|
||||
// MPR *ModelCoreMasterprocess `json:"MPR,omitempty" bun:"rel:has-one,join:rid_masterprocess=rid_masterprocess"`
|
||||
|
||||
@@ -3,18 +3,18 @@ package models_bun
|
||||
// //ModelCoreMastertype - Generated Table for Schema core
|
||||
// type ModelCoreMastertype struct {
|
||||
// bun.BaseModel `bun:"table:core.mastertype,alias:mastertype"`
|
||||
// Category resolvespec_common.SqlString `json:"category" bun:"category,type:citext,"`
|
||||
// Description resolvespec_common.SqlString `json:"description" bun:"description,type:citext,"`
|
||||
// Disableedit resolvespec_common.SqlInt16 `json:"disableedit" bun:"disableedit,type:smallint,"`
|
||||
// Forprefix resolvespec_common.SqlString `json:"forprefix" bun:"forprefix,type:citext,"`
|
||||
// GUID resolvespec_common.SqlUUID `json:"guid" bun:"guid,type:uuid,default:newid(),"`
|
||||
// Hidden resolvespec_common.SqlInt16 `json:"hidden" bun:"hidden,type:smallint,"`
|
||||
// Inactive resolvespec_common.SqlInt16 `json:"inactive" bun:"inactive,type:smallint,"`
|
||||
// Jsonvalue resolvespec_common.SqlJSONB `json:"jsonvalue" bun:"jsonvalue,type:jsonb,"`
|
||||
// Mastertype resolvespec_common.SqlString `json:"mastertype" bun:"mastertype,type:citext,"`
|
||||
// Note resolvespec_common.SqlString `json:"note" bun:"note,type:citext,"`
|
||||
// Ridmastertype resolvespec_common.SqlInt32 `json:"rid_mastertype" bun:"rid_mastertype,type:integer,pk,default:nextval('core.identity_mastertype_rid_mastertype'::regclass),"`
|
||||
// Ridparent resolvespec_common.SqlInt32 `json:"rid_parent" bun:"rid_parent,type:integer,"`
|
||||
// Category sql_types.SqlString `json:"category" bun:"category,type:citext,"`
|
||||
// Description sql_types.SqlString `json:"description" bun:"description,type:citext,"`
|
||||
// Disableedit sql_types.SqlInt16 `json:"disableedit" bun:"disableedit,type:smallint,"`
|
||||
// Forprefix sql_types.SqlString `json:"forprefix" bun:"forprefix,type:citext,"`
|
||||
// GUID sql_types.SqlUUID `json:"guid" bun:"guid,type:uuid,default:newid(),"`
|
||||
// Hidden sql_types.SqlInt16 `json:"hidden" bun:"hidden,type:smallint,"`
|
||||
// Inactive sql_types.SqlInt16 `json:"inactive" bun:"inactive,type:smallint,"`
|
||||
// Jsonvalue sql_types.SqlJSONB `json:"jsonvalue" bun:"jsonvalue,type:jsonb,"`
|
||||
// Mastertype sql_types.SqlString `json:"mastertype" bun:"mastertype,type:citext,"`
|
||||
// Note sql_types.SqlString `json:"note" bun:"note,type:citext,"`
|
||||
// Ridmastertype sql_types.SqlInt32 `json:"rid_mastertype" bun:"rid_mastertype,type:integer,pk,default:nextval('core.identity_mastertype_rid_mastertype'::regclass),"`
|
||||
// Ridparent sql_types.SqlInt32 `json:"rid_parent" bun:"rid_parent,type:integer,"`
|
||||
// Updatecnt int64 `json:"updatecnt" bun:"updatecnt,type:integer,default:0,"`
|
||||
// MTT *ModelCoreMastertype `json:"MTT,omitempty" bun:"rel:has-one,join:rid_mastertype=rid_parent"`
|
||||
|
||||
|
||||
@@ -3,15 +3,15 @@ package models_bun
|
||||
// //ModelCoreProcess - Generated Table for Schema core
|
||||
// type ModelCoreProcess struct {
|
||||
// bun.BaseModel `bun:"table:core.process,alias:process"`
|
||||
// Completedate resolvespec_common.SqlDate `json:"completedate" bun:"completedate,type:date,"`
|
||||
// Completedate sql_types.SqlDate `json:"completedate" bun:"completedate,type:date,"`
|
||||
// Completetime types.CustomIntTime `json:"completetime" bun:"completetime,type:integer,"`
|
||||
// Description resolvespec_common.SqlString `json:"description" bun:"description,type:citext,"`
|
||||
// GUID resolvespec_common.SqlUUID `json:"guid" bun:"guid,type:uuid,default:newid(),"`
|
||||
// Ridcompleteuser resolvespec_common.SqlInt32 `json:"rid_completeuser" bun:"rid_completeuser,type:integer,"`
|
||||
// Ridhub resolvespec_common.SqlInt32 `json:"rid_hub" bun:"rid_hub,type:integer,"`
|
||||
// Ridmasterprocess resolvespec_common.SqlInt32 `json:"rid_masterprocess" bun:"rid_masterprocess,type:integer,"`
|
||||
// Ridprocess resolvespec_common.SqlInt32 `json:"rid_process" bun:"rid_process,type:integer,pk,default:nextval('core.identity_process_rid_process'::regclass),"`
|
||||
// Status resolvespec_common.SqlString `json:"status" bun:"status,type:citext,"`
|
||||
// Description sql_types.SqlString `json:"description" bun:"description,type:citext,"`
|
||||
// GUID sql_types.SqlUUID `json:"guid" bun:"guid,type:uuid,default:newid(),"`
|
||||
// Ridcompleteuser sql_types.SqlInt32 `json:"rid_completeuser" bun:"rid_completeuser,type:integer,"`
|
||||
// Ridhub sql_types.SqlInt32 `json:"rid_hub" bun:"rid_hub,type:integer,"`
|
||||
// Ridmasterprocess sql_types.SqlInt32 `json:"rid_masterprocess" bun:"rid_masterprocess,type:integer,"`
|
||||
// Ridprocess sql_types.SqlInt32 `json:"rid_process" bun:"rid_process,type:integer,pk,default:nextval('core.identity_process_rid_process'::regclass),"`
|
||||
// Status sql_types.SqlString `json:"status" bun:"status,type:citext,"`
|
||||
// Updatecnt int64 `json:"updatecnt" bun:"updatecnt,type:integer,default:0,"`
|
||||
// HUB *ModelCoreHub `json:"HUB,omitempty" bun:"rel:has-one,join:rid_hub=rid_hub"`
|
||||
// MPR *ModelCoreMasterprocess `json:"MPR,omitempty" bun:"rel:has-one,join:rid_masterprocess=rid_masterprocess"`
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Generated by `relspec job run` in this example project.
|
||||
/build/
|
||||
/.relspec/
|
||||
@@ -0,0 +1,80 @@
|
||||
# RelSpec job examples
|
||||
|
||||
Run these commands from this directory:
|
||||
|
||||
```bash
|
||||
cd examples/jobs
|
||||
|
||||
# List every job from relspec.yml and the additional relspec.*.yml files.
|
||||
relspec job list
|
||||
|
||||
# Validate a job and print its resolved plan without reading or writing data.
|
||||
relspec job run build-schema --plan
|
||||
|
||||
# Build PostgreSQL DDL from the two DBML inputs.
|
||||
relspec job run build-schema
|
||||
|
||||
# Run a job's dependency chain. This runs build-schema first, then build-json.
|
||||
relspec job run build-json
|
||||
|
||||
# Inspect build-json's output. The from_job input adds the dependency automatically.
|
||||
relspec job run lint-schema
|
||||
|
||||
# Extract only the posts table.
|
||||
relspec job run posts-only
|
||||
|
||||
# List migration scripts in deterministic order without connecting to a database.
|
||||
relspec job run migration-order
|
||||
|
||||
# Render one TypeScript file per table using the example template.
|
||||
relspec job run generate-typescript
|
||||
|
||||
# Compare the two example schemas and write the summary to the logfile.
|
||||
relspec job run compare-schemas
|
||||
|
||||
# Use an environment variable in an output path.
|
||||
export VST=build/typescript
|
||||
relspec job run generate-typescript --plan
|
||||
relspec job run generate-typescript
|
||||
```
|
||||
|
||||
Database jobs use environment variables for connection strings. Set them in
|
||||
the shell before running the jobs; do not put connection strings in YAML:
|
||||
|
||||
```bash
|
||||
export SOURCE_DB_URL='postgres://user:password@localhost/source_db'
|
||||
export TARGET_DB_URL='postgres://user:password@localhost/target_db'
|
||||
|
||||
# Read the source database and write a local DBML snapshot.
|
||||
relspec job run snapshot-database
|
||||
|
||||
# Preview migration execution without connecting to PostgreSQL.
|
||||
relspec job run apply-migrations --plan
|
||||
|
||||
# Execute the migrations against TARGET_DB_URL.
|
||||
relspec job run apply-migrations
|
||||
```
|
||||
|
||||
The `apply-migrations` job demonstrates execution from multiple directories:
|
||||
|
||||
```yaml
|
||||
script_dirs:
|
||||
- migrations/core
|
||||
- migrations/tenant
|
||||
output:
|
||||
format: pgsql
|
||||
conn_env: TARGET_DB_URL
|
||||
```
|
||||
|
||||
RelSpec combines the SQL files from both folders in deterministic order before
|
||||
executing them against the configured PostgreSQL database.
|
||||
|
||||
To use a different directory or manifest, pass `--dir` or `--file`:
|
||||
|
||||
```bash
|
||||
relspec job list --dir /path/to/project
|
||||
relspec job run build-schema --file /path/to/project/relspec.yml --plan
|
||||
```
|
||||
|
||||
See [../../docs/JOB_FILES.md](../../docs/JOB_FILES.md) for the complete job
|
||||
file reference.
|
||||
@@ -0,0 +1,4 @@
|
||||
CREATE TABLE users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
email VARCHAR NOT NULL UNIQUE
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE posts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INT NOT NULL REFERENCES users(id),
|
||||
title VARCHAR NOT NULL
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
CREATE INDEX posts_user_id_idx ON posts(user_id);
|
||||
@@ -0,0 +1,29 @@
|
||||
# Database-oriented examples. Set the referenced environment variables before
|
||||
# running these jobs; connection strings never belong in a job file.
|
||||
version: 1
|
||||
|
||||
jobs:
|
||||
snapshot-database:
|
||||
command: convert
|
||||
description: Read a PostgreSQL database and write a DBML snapshot
|
||||
inputs:
|
||||
- format: pgsql
|
||||
conn_env: SOURCE_DB_URL
|
||||
output:
|
||||
format: dbml
|
||||
path: build/database-snapshot.dbml
|
||||
overwrite: true
|
||||
|
||||
apply-migrations:
|
||||
command: scripts-exec
|
||||
description: Execute migrations from core and tenant folders against PostgreSQL
|
||||
# All SQL files from these directories are discovered in deterministic order.
|
||||
script_dirs:
|
||||
- migrations/core
|
||||
- migrations/tenant
|
||||
output:
|
||||
format: pgsql
|
||||
conn_env: TARGET_DB_URL
|
||||
options:
|
||||
continue_on_error: false
|
||||
logfile: .relspec/log/apply-migrations.log
|
||||
@@ -0,0 +1,32 @@
|
||||
# Reporting and templating examples. These jobs are discovered together with
|
||||
# relspec.yml when running `relspec job list` from this directory.
|
||||
version: 1
|
||||
|
||||
jobs:
|
||||
generate-typescript:
|
||||
command: templ
|
||||
description: Render one TypeScript file per table from the merged schema
|
||||
inputs:
|
||||
- path: schema/core.dbml
|
||||
format: dbml
|
||||
- path: schema/tenant.dbml
|
||||
format: dbml
|
||||
template: templates/schema.tmpl
|
||||
mode: table
|
||||
filename_pattern: "{{.Name}}.ts"
|
||||
output:
|
||||
format: text
|
||||
path: "${VST}"
|
||||
overwrite: true
|
||||
|
||||
compare-schemas:
|
||||
command: diff
|
||||
description: Compare the two example schemas and print a summary
|
||||
inputs:
|
||||
- path: schema/core.dbml
|
||||
format: dbml
|
||||
- path: schema/tenant.dbml
|
||||
format: dbml
|
||||
report:
|
||||
format: summary
|
||||
logfile: .relspec/log/compare-schemas.log
|
||||
@@ -0,0 +1,79 @@
|
||||
# Example RelSpec job file. See docs/JOB_FILES.md for the full reference.
|
||||
#
|
||||
# cd examples/jobs
|
||||
# relspec job list
|
||||
# relspec job run build-schema --plan
|
||||
# relspec job run build-schema
|
||||
# relspec job run lint-schema # inspect, consuming build-json's output
|
||||
version: 1
|
||||
|
||||
# File-wide defaults. Individual jobs may override log_max_size / log_keep.
|
||||
defaults:
|
||||
log_max_size: 2MB
|
||||
log_keep: 5
|
||||
|
||||
jobs:
|
||||
build-schema:
|
||||
command: convert
|
||||
description: Merge the DBML sources and emit PostgreSQL DDL
|
||||
inputs:
|
||||
- path: schema/core.dbml
|
||||
format: dbml
|
||||
- path: schema/tenant.dbml
|
||||
format: dbml
|
||||
output:
|
||||
format: pgsql
|
||||
path: build/schema.sql
|
||||
overwrite: true
|
||||
options:
|
||||
flatten_schema: false
|
||||
logfile: .relspec/log/build-schema.log
|
||||
|
||||
build-json:
|
||||
command: convert
|
||||
description: Also emit a JSON schema once build-schema succeeds
|
||||
depends_on: [build-schema]
|
||||
inputs:
|
||||
- path: schema/core.dbml
|
||||
format: dbml
|
||||
- path: schema/tenant.dbml
|
||||
format: dbml
|
||||
output:
|
||||
format: json
|
||||
path: build/schema.json
|
||||
overwrite: true
|
||||
|
||||
migration-order:
|
||||
command: scripts-list
|
||||
description: Show the combined execution order across script directories
|
||||
script_dirs:
|
||||
- migrations/core
|
||||
- migrations/tenant
|
||||
logfile: .relspec/log/migration-order.log
|
||||
|
||||
lint-schema:
|
||||
command: inspect
|
||||
description: Validate build-json's output against the built-in rules
|
||||
# No depends_on needed: the from_job input implies a dependency on build-json.
|
||||
inputs:
|
||||
- from_job: build-json
|
||||
report:
|
||||
format: markdown
|
||||
path: build/lint-report.md
|
||||
overwrite: true
|
||||
logfile: .relspec/log/lint-schema.log
|
||||
|
||||
posts-only:
|
||||
command: split
|
||||
description: Extract just the posts table into its own DBML file
|
||||
inputs:
|
||||
- path: schema/core.dbml
|
||||
format: dbml
|
||||
- path: schema/tenant.dbml
|
||||
format: dbml
|
||||
select:
|
||||
tables: [posts]
|
||||
output:
|
||||
format: dbml
|
||||
path: build/posts.dbml
|
||||
overwrite: true
|
||||
@@ -0,0 +1,5 @@
|
||||
Table users {
|
||||
id int [pk, increment]
|
||||
email varchar [not null, unique]
|
||||
created_at timestamp
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
Table posts {
|
||||
id int [pk, increment]
|
||||
user_id int [not null, ref: > users.id]
|
||||
title varchar [not null]
|
||||
body text
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Code generated by RelSpec. DO NOT EDIT.
|
||||
export interface {{.Name}} {
|
||||
{{- range values .Table.Columns}}
|
||||
{{.Name}}: {{.Type}};
|
||||
{{- end}}
|
||||
}
|
||||
@@ -1,38 +1,55 @@
|
||||
module git.warky.dev/wdevs/relspecgo
|
||||
|
||||
go 1.24.0
|
||||
go 1.25.13
|
||||
|
||||
require (
|
||||
github.com/gdamore/tcell/v2 v2.8.1
|
||||
github.com/gdamore/tcell/v2 v2.13.9
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.7.6
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/microsoft/go-mssqldb v1.10.0
|
||||
github.com/rivo/tview v0.42.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/uptrace/bun v1.2.16
|
||||
golang.org/x/text v0.28.0
|
||||
github.com/uptrace/bun v1.2.18
|
||||
github.com/uptrace/bun/dialect/pgdialect v1.2.18
|
||||
golang.org/x/text v0.39.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.50.1
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gdamore/encoding v1.0.1 // indirect
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
||||
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
golang.org/x/crypto v0.41.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/term v0.34.0 // indirect
|
||||
golang.org/x/crypto v0.53.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/term v0.44.0 // indirect
|
||||
modernc.org/libc v1.72.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,23 +1,50 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 h1:E4MgwLBGeVB5f2MdcIVD3ELVAWpr+WD6MUe1i+tM/PA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0/go.mod h1:Y2b/1clN4zsAoUd/pgNAQHjLDnTis/6ROkUfyob6psM=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
|
||||
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
|
||||
github.com/gdamore/tcell/v2 v2.8.1 h1:KPNxyqclpWpWQlPLx6Xui1pMk8S+7+R37h3g07997NU=
|
||||
github.com/gdamore/tcell/v2 v2.8.1/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/gdamore/tcell/v2 v2.13.9 h1:uI5l3DYPcFvHINKlGft+en23evOKL+dwtD21QR8ejVA=
|
||||
github.com/gdamore/tcell/v2 v2.13.9/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
||||
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
@@ -26,25 +53,35 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/microsoft/go-mssqldb v1.10.0 h1:pHEt+Qz6YFPWqREq10mqSE524QQo+/QremwTCQht7TY=
|
||||
github.com/microsoft/go-mssqldb v1.10.0/go.mod h1:mnG7lGa9iYJbzJqGCXyuQCegStKMr3kogDLD6+bmggg=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c=
|
||||
github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
@@ -57,8 +94,10 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo=
|
||||
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs=
|
||||
github.com/uptrace/bun v1.2.16 h1:QlObi6ZIK5Ao7kAALnh91HWYNZUBbVwye52fmlQM9kc=
|
||||
github.com/uptrace/bun v1.2.16/go.mod h1:jMoNg2n56ckaawi/O/J92BHaECmrz6IRjuMWqlMaMTM=
|
||||
github.com/uptrace/bun v1.2.18 h1:3HnRcMfS6OBPMG1eSOzlbFJ/X/AyMEJb7rMxE6VQvDU=
|
||||
github.com/uptrace/bun v1.2.18/go.mod h1:wNltaKJk4JtOt4SG5I5zmA7v0/Mzjh1+/S906Rayd3Y=
|
||||
github.com/uptrace/bun/dialect/pgdialect v1.2.18 h1:IZ6nM2+OYrL8lkEAy7UkSEZvoa3vluTAUlZfPtlRB2k=
|
||||
github.com/uptrace/bun/dialect/pgdialect v1.2.18/go.mod h1:Tqdf4QP1okrGYpXfodXvCOK6Ob1OOTwSaoAzCgBB3IU=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
@@ -67,74 +106,49 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
|
||||
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
|
||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
|
||||
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
@@ -142,3 +156,31 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
|
||||
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Maintainer: Hein (Warky Devs) <hein@warky.dev>
|
||||
pkgname=relspec
|
||||
pkgver=1.0.85
|
||||
pkgrel=1
|
||||
pkgdesc="RelSpec is a comprehensive database relations management tool that reads, transforms, and writes database table specifications across multiple formats and ORMs."
|
||||
arch=('x86_64' 'aarch64')
|
||||
url="https://git.warky.dev/wdevs/relspecgo"
|
||||
license=('MIT')
|
||||
makedepends=('go')
|
||||
source=("$pkgname-$pkgver.zip::$url/archive/v$pkgver.zip")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
build() {
|
||||
cd "relspecgo"
|
||||
export CGO_ENABLED=0
|
||||
go build \
|
||||
-trimpath \
|
||||
-ldflags "-X 'git.warky.dev/wdevs/relspecgo/pkg/buildinfo.Version=v$pkgver' -X 'git.warky.dev/wdevs/relspecgo/pkg/buildinfo.BuildDate=$(date -u +"%Y-%m-%d %H:%M:%S UTC")'" \
|
||||
-o "$pkgname" ./cmd/relspec
|
||||
}
|
||||
|
||||
check() {
|
||||
cd "relspecgo"
|
||||
go test ./...
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "relspecgo"
|
||||
|
||||
# Binary
|
||||
install -Dm755 "$pkgname" "$pkgdir/usr/bin/$pkgname"
|
||||
|
||||
# Default config dir
|
||||
install -dm755 "$pkgdir/etc/relspec"
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
Name: relspec
|
||||
Version: 1.0.85
|
||||
Release: 1%{?dist}
|
||||
Summary: RelSpec is a comprehensive database relations management tool that reads, transforms, and writes database table specifications across multiple formats and ORMs.
|
||||
|
||||
License: MIT
|
||||
URL: https://git.warky.dev/wdevs/relspecgo
|
||||
Source0: %{name}-%{version}.tar.gz
|
||||
|
||||
BuildRequires: golang >= 1.24
|
||||
|
||||
%global debug_package %{nil}
|
||||
%define _debugsource_packages 0
|
||||
%define _debuginfo_subpackages 0
|
||||
|
||||
%description
|
||||
RelSpec provides bidirectional conversion between various database schema
|
||||
formats including PostgreSQL, MySQL, SQLite, Prisma, TypeORM, GORM, Drizzle,
|
||||
DBML, GraphQL, and more.
|
||||
|
||||
%prep
|
||||
%autosetup
|
||||
|
||||
%build
|
||||
export CGO_ENABLED=0
|
||||
go build \
|
||||
-trimpath \
|
||||
-ldflags "-X 'git.warky.dev/wdevs/relspecgo/pkg/buildinfo.Version=v%{version}' -X 'git.warky.dev/wdevs/relspecgo/pkg/buildinfo.BuildDate=$(date -u +"%%Y-%%m-%%d %%H:%%M:%%S UTC")'" \
|
||||
-o %{name} ./cmd/relspec
|
||||
|
||||
%install
|
||||
install -Dm755 %{name} %{buildroot}%{_bindir}/%{name}
|
||||
install -Dm644 LICENSE %{buildroot}%{_licensedir}/%{name}/LICENSE
|
||||
install -dm755 %{buildroot}%{_sysconfdir}/relspec
|
||||
|
||||
%files
|
||||
%license LICENSE
|
||||
%{_bindir}/%{name}
|
||||
%dir %{_sysconfdir}/relspec
|
||||
|
||||
%changelog
|
||||
* Wed Apr 08 2026 Hein (Warky Devs) <hein@warky.dev> - 1.0.42-1
|
||||
- Initial package
|
||||
@@ -0,0 +1,11 @@
|
||||
Package: relspec
|
||||
Version: VERSION
|
||||
Architecture: ARCH
|
||||
Maintainer: Hein (Warky Devs) <hein@warky.dev>
|
||||
Section: database
|
||||
Priority: optional
|
||||
Homepage: https://git.warky.dev/wdevs/relspecgo
|
||||
Description: Database schema conversion and analysis tool
|
||||
RelSpec provides bidirectional conversion between various database schema
|
||||
formats including PostgreSQL, MySQL, SQLite, Prisma, TypeORM, GORM, Drizzle,
|
||||
DBML, GraphQL, and more.
|
||||
@@ -0,0 +1,165 @@
|
||||
package assetloader
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const ScriptSourcePathMetadataKey = "source_path"
|
||||
|
||||
var (
|
||||
embedDirectivePattern = regexp.MustCompile(`(?m)^\s*--\s*@embed:\s*(.+?)\s*$`)
|
||||
embedAttrPattern = regexp.MustCompile(`([a-zA-Z_][a-zA-Z0-9_]*)=("[^"]*"|'[^']*'|\S+)`)
|
||||
embedVarPattern = regexp.MustCompile(`^:[a-zA-Z_][a-zA-Z0-9_]*$`)
|
||||
)
|
||||
|
||||
// ProcessEmbedDirectives expands SQL comments in the form:
|
||||
//
|
||||
// -- @embed: path=... var=:... mode=text|base64
|
||||
//
|
||||
// Paths are resolved relative to sqlPath. Text mode embeds a quoted UTF-8 SQL
|
||||
// string literal. Base64 mode embeds a quoted base64 literal suitable for
|
||||
// decode(:var, 'base64').
|
||||
func ProcessEmbedDirectives(sqlPath, sql string) (string, error) {
|
||||
directives := embedDirectivePattern.FindAllStringSubmatch(sql, -1)
|
||||
if len(directives) == 0 {
|
||||
return sql, nil
|
||||
}
|
||||
|
||||
if sqlPath == "" {
|
||||
return "", fmt.Errorf("sql path is required for embed directives")
|
||||
}
|
||||
|
||||
result := embedDirectivePattern.ReplaceAllString(sql, "")
|
||||
for i, directive := range directives {
|
||||
literal, placeholder, err := embedDirectiveLiteral(sqlPath, directive[1], i+1)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !embedPlaceholderPattern(placeholder).MatchString(result) {
|
||||
return "", fmt.Errorf("%s embed directive %d: placeholder %s not found", sqlPath, i+1, placeholder)
|
||||
}
|
||||
result = replaceEmbedPlaceholder(result, placeholder, literal)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func embedDirectiveLiteral(sqlPath, raw string, directiveNumber int) (literal, placeholder string, err error) {
|
||||
attrs, err := parseEmbedAttrs(raw)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("%s embed directive %d: %w", sqlPath, directiveNumber, err)
|
||||
}
|
||||
|
||||
pathValue := attrs["path"]
|
||||
varValue := attrs["var"]
|
||||
modeValue := attrs["mode"]
|
||||
if pathValue == "" {
|
||||
return "", "", fmt.Errorf("%s embed directive %d: missing path", sqlPath, directiveNumber)
|
||||
}
|
||||
if !embedVarPattern.MatchString(varValue) {
|
||||
return "", "", fmt.Errorf("%s embed directive %d: var must be a named placeholder like :asset", sqlPath, directiveNumber)
|
||||
}
|
||||
if modeValue != "text" && modeValue != "base64" {
|
||||
return "", "", fmt.Errorf("%s embed directive %d: mode must be text or base64", sqlPath, directiveNumber)
|
||||
}
|
||||
|
||||
resolved := filepath.Join(filepath.Dir(sqlPath), filepath.Clean(pathValue))
|
||||
data, err := os.ReadFile(resolved)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("%s embed directive %d: reading %s: %w", sqlPath, directiveNumber, resolved, err)
|
||||
}
|
||||
|
||||
switch modeValue {
|
||||
case "text":
|
||||
if !utf8.Valid(data) {
|
||||
return "", "", fmt.Errorf("%s embed directive %d: %s is not valid UTF-8", sqlPath, directiveNumber, resolved)
|
||||
}
|
||||
literal, err := sqlStringLiteral(string(data))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("%s embed directive %d: %w", sqlPath, directiveNumber, err)
|
||||
}
|
||||
return literal, varValue, nil
|
||||
case "base64":
|
||||
literal, err := sqlStringLiteral(base64.StdEncoding.EncodeToString(data))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("%s embed directive %d: %w", sqlPath, directiveNumber, err)
|
||||
}
|
||||
return literal, varValue, nil
|
||||
default:
|
||||
return "", "", fmt.Errorf("%s embed directive %d: mode must be text or base64", sqlPath, directiveNumber)
|
||||
}
|
||||
}
|
||||
|
||||
func parseEmbedAttrs(raw string) (map[string]string, error) {
|
||||
attrs := map[string]string{}
|
||||
matches := embedAttrPattern.FindAllStringSubmatchIndex(raw, -1)
|
||||
if len(matches) == 0 {
|
||||
return nil, fmt.Errorf("expected path, var, and mode attributes")
|
||||
}
|
||||
|
||||
lastEnd := 0
|
||||
for _, match := range matches {
|
||||
gap := strings.TrimSpace(raw[lastEnd:match[0]])
|
||||
if gap != "" {
|
||||
return nil, fmt.Errorf("invalid attribute syntax near %q", gap)
|
||||
}
|
||||
|
||||
key := raw[match[2]:match[3]]
|
||||
value := raw[match[4]:match[5]]
|
||||
if _, exists := attrs[key]; exists {
|
||||
return nil, fmt.Errorf("duplicate attribute %q", key)
|
||||
}
|
||||
unquoted, err := unquoteEmbedValue(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid %s value: %w", key, err)
|
||||
}
|
||||
attrs[key] = unquoted
|
||||
lastEnd = match[1]
|
||||
}
|
||||
if tail := strings.TrimSpace(raw[lastEnd:]); tail != "" {
|
||||
return nil, fmt.Errorf("invalid attribute syntax near %q", tail)
|
||||
}
|
||||
|
||||
for key := range attrs {
|
||||
if key != "path" && key != "var" && key != "mode" {
|
||||
return nil, fmt.Errorf("unknown attribute %q", key)
|
||||
}
|
||||
}
|
||||
|
||||
return attrs, nil
|
||||
}
|
||||
|
||||
func unquoteEmbedValue(value string) (string, error) {
|
||||
if len(value) < 2 {
|
||||
return value, nil
|
||||
}
|
||||
if value[0] == '"' {
|
||||
return strconv.Unquote(value)
|
||||
}
|
||||
if value[0] == '\'' && value[len(value)-1] == '\'' {
|
||||
return value[1 : len(value)-1], nil
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func sqlStringLiteral(value string) (string, error) {
|
||||
if strings.ContainsRune(value, '\x00') {
|
||||
return "", fmt.Errorf("embedded text contains NUL byte")
|
||||
}
|
||||
return "'" + strings.ReplaceAll(value, "'", "''") + "'", nil
|
||||
}
|
||||
|
||||
func replaceEmbedPlaceholder(sql, placeholder, literal string) string {
|
||||
return embedPlaceholderPattern(placeholder).ReplaceAllString(sql, "${1}"+literal+"${2}")
|
||||
}
|
||||
|
||||
func embedPlaceholderPattern(placeholder string) *regexp.Regexp {
|
||||
return regexp.MustCompile(`(^|[^a-zA-Z0-9_:])` + regexp.QuoteMeta(placeholder) + `([^a-zA-Z0-9_]|$)`)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package assetloader
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProcessEmbedDirectives_TextLiteralEscapesQuotes(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sqlPath := filepath.Join(dir, "1_001_seed.sql")
|
||||
if err := os.WriteFile(filepath.Join(dir, "body.txt"), []byte("Line 1\nIt's fine"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := ProcessEmbedDirectives(sqlPath, `
|
||||
-- @embed: path=body.txt var=:body mode=text
|
||||
INSERT INTO notes (body) VALUES (:body);
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessEmbedDirectives failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(got, "VALUES ('Line 1\nIt''s fine');") {
|
||||
t.Fatalf("embedded SQL did not contain escaped text literal:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "VALUES (:body);") {
|
||||
t.Fatalf("placeholder was not replaced:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessEmbedDirectives_Base64Literal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sqlPath := filepath.Join(dir, "1_001_seed.sql")
|
||||
binary := []byte{0x00, 0xff, 0x10, 0x20}
|
||||
if err := os.WriteFile(filepath.Join(dir, "blob.bin"), binary, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := ProcessEmbedDirectives(sqlPath, `
|
||||
-- @embed: path=blob.bin var=:payload mode=base64
|
||||
INSERT INTO files (payload) VALUES (decode(:payload, 'base64')::bytea);
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessEmbedDirectives failed: %v", err)
|
||||
}
|
||||
want := "decode('" + base64.StdEncoding.EncodeToString(binary) + "', 'base64')::bytea"
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("embedded SQL did not contain base64 literal %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessEmbedDirectives_RelativeToSQLFile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sqlDir := filepath.Join(root, "nested", "seed")
|
||||
if err := os.MkdirAll(filepath.Join(sqlDir, "assets"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlPath := filepath.Join(sqlDir, "1_001_seed.sql")
|
||||
if err := os.WriteFile(filepath.Join(sqlDir, "assets", "body.txt"), []byte("relative body"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := ProcessEmbedDirectives(sqlPath, `
|
||||
-- @embed: path=assets/body.txt var=:body mode=text
|
||||
SELECT :body;
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessEmbedDirectives failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(got, "SELECT 'relative body';") {
|
||||
t.Fatalf("path was not resolved relative to SQL file:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessEmbedDirectives_InvalidDirectiveAndFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sqlPath := filepath.Join(dir, "1_001_seed.sql")
|
||||
if err := os.WriteFile(filepath.Join(dir, "body.txt"), []byte("ok"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "binary.txt"), []byte{0xff, 0xfe}, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sql string
|
||||
}{
|
||||
{
|
||||
name: "missing mode",
|
||||
sql: "-- @embed: path=body.txt var=:body\nSELECT :body;",
|
||||
},
|
||||
{
|
||||
name: "invalid var",
|
||||
sql: "-- @embed: path=body.txt var=body mode=text\nSELECT :body;",
|
||||
},
|
||||
{
|
||||
name: "missing file",
|
||||
sql: "-- @embed: path=missing.txt var=:body mode=text\nSELECT :body;",
|
||||
},
|
||||
{
|
||||
name: "invalid utf8 text",
|
||||
sql: "-- @embed: path=binary.txt var=:body mode=text\nSELECT :body;",
|
||||
},
|
||||
{
|
||||
name: "placeholder not found",
|
||||
sql: "-- @embed: path=body.txt var=:body mode=text\nSELECT 1;",
|
||||
},
|
||||
{
|
||||
name: "unknown attribute",
|
||||
sql: "-- @embed: path=body.txt var=:body mode=text extra=yes\nSELECT :body;",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if _, err := ProcessEmbedDirectives(sqlPath, tt.sql); err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessEmbedDirectives_DoesNotReplacePlaceholderPrefix(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sqlPath := filepath.Join(dir, "1_001_seed.sql")
|
||||
if err := os.WriteFile(filepath.Join(dir, "body.txt"), []byte("ok"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := ProcessEmbedDirectives(sqlPath, `
|
||||
-- @embed: path=body.txt var=:body mode=text
|
||||
SELECT :body, :body_extra;
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessEmbedDirectives failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(got, "SELECT 'ok', :body_extra;") {
|
||||
t.Fatalf("placeholder boundary was not respected:\n%s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package assetloader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// namedPlaceholder matches :identifier patterns (but not ::cast syntax).
|
||||
var namedPlaceholder = regexp.MustCompile(`:([a-zA-Z_][a-zA-Z0-9_]*)`)
|
||||
|
||||
// pgCastMarker temporarily replaces :: to protect PostgreSQL cast syntax.
|
||||
const pgCastMarker = "\x00PGCAST\x00"
|
||||
|
||||
// BuildQuery converts a SQL call that uses :name named placeholders into a
|
||||
// pgx-compatible positional-parameter query ($1, $2, …) and returns the
|
||||
// corresponding argument slice.
|
||||
//
|
||||
// Built-in placeholders:
|
||||
// - :bytes → fileBytes ([]byte)
|
||||
// - :filename → filename (string, base name only)
|
||||
// - :any_key → staticParams["any_key"] (string)
|
||||
//
|
||||
// A placeholder that appears more than once maps to the same $N. An unknown
|
||||
// placeholder (not built-in and not in staticParams) returns an error.
|
||||
// PostgreSQL cast syntax (::type) is left untouched.
|
||||
func BuildQuery(call string, fileBytes []byte, filename string, staticParams map[string]string) (query string, args []any, err error) {
|
||||
// Protect :: casts before running the placeholder regex.
|
||||
protected := strings.ReplaceAll(call, "::", pgCastMarker)
|
||||
|
||||
paramIndex := map[string]int{} // name → 1-based position
|
||||
var firstErr error
|
||||
|
||||
query = namedPlaceholder.ReplaceAllStringFunc(protected, func(match string) string {
|
||||
if firstErr != nil {
|
||||
return match
|
||||
}
|
||||
name := match[1:] // strip leading ':'
|
||||
|
||||
// Return existing positional param for repeated placeholders.
|
||||
if idx, seen := paramIndex[name]; seen {
|
||||
return fmt.Sprintf("$%d", idx)
|
||||
}
|
||||
|
||||
// Resolve the placeholder value.
|
||||
var val any
|
||||
switch name {
|
||||
case "bytes":
|
||||
val = fileBytes
|
||||
case "filename":
|
||||
val = filename
|
||||
default:
|
||||
if staticParams != nil {
|
||||
if v, ok := staticParams[name]; ok {
|
||||
val = v
|
||||
}
|
||||
}
|
||||
if val == nil {
|
||||
firstErr = fmt.Errorf("unknown placeholder %q in SQL call (not a built-in and not listed in params)", match)
|
||||
return match
|
||||
}
|
||||
}
|
||||
|
||||
idx := len(args) + 1
|
||||
paramIndex[name] = idx
|
||||
args = append(args, val)
|
||||
return fmt.Sprintf("$%d", idx)
|
||||
})
|
||||
|
||||
if firstErr != nil {
|
||||
return "", nil, firstErr
|
||||
}
|
||||
|
||||
// Restore :: casts.
|
||||
query = strings.ReplaceAll(query, pgCastMarker, "::")
|
||||
|
||||
return query, args, nil
|
||||
}
|
||||
|
||||
// ExecuteItem reads the asset file referenced by item.Entry.File (which is the
|
||||
// absolute path set by ScanDir) and executes the configured SQL call via conn.
|
||||
// The file's raw bytes are bound as a []byte parameter — no encoding or escaping.
|
||||
func ExecuteItem(ctx context.Context, conn *pgx.Conn, item Item) error {
|
||||
data, err := os.ReadFile(item.Entry.File)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading asset file %s: %w", item.Entry.File, err)
|
||||
}
|
||||
|
||||
filename := filepath.Base(item.Entry.File)
|
||||
|
||||
sql, args, err := BuildQuery(item.Entry.Call, data, filename, item.Entry.Params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("building query for %s: %w", filename, err)
|
||||
}
|
||||
|
||||
if _, err := conn.Exec(ctx, sql, args...); err != nil {
|
||||
return fmt.Errorf("executing asset %s: %w", filename, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
// Package assetloader implements a native Go asset/file loader that binds
|
||||
// local binary and text files as pgx query parameters during database seeding.
|
||||
// Files are bound as actual []byte query parameters — never converted to SQL
|
||||
// text literals — so binary data stays byte-exact and no escaping is needed.
|
||||
//
|
||||
// Manifests are small YAML files (assets.yaml) that describe, per file, the
|
||||
// SQL call to invoke and the named placeholders for :bytes, :filename, and any
|
||||
// static column values. Manifests live inside directories that follow the same
|
||||
// {priority}_{sequence}_{name} naming convention used by the sqldir reader,
|
||||
// so asset-loading steps can be interleaved with SQL scripts in a migrate-apply
|
||||
// run.
|
||||
package assetloader
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ManifestEntry describes a single file to load from an assets.yaml manifest.
|
||||
type ManifestEntry struct {
|
||||
// File is the path to the asset file, relative to the manifest directory.
|
||||
File string `yaml:"file"`
|
||||
// Call is the SQL statement to execute. Use :bytes for file content,
|
||||
// :filename for the base name, and :param_name for static params.
|
||||
Call string `yaml:"call"`
|
||||
// Params holds optional static named parameters referenced in Call.
|
||||
Params map[string]string `yaml:"params,omitempty"`
|
||||
}
|
||||
|
||||
// Item combines a manifest entry with its ordering metadata and the resolved
|
||||
// directory where the manifest and asset file reside.
|
||||
type Item struct {
|
||||
// Priority and Sequence come from the parent directory's naming pattern.
|
||||
Priority int
|
||||
Sequence uint
|
||||
// DirName is the last path component of the manifest's directory.
|
||||
DirName string
|
||||
// Dir is the absolute path to the directory containing assets.yaml and files.
|
||||
Dir string
|
||||
// Entry is the parsed manifest entry.
|
||||
Entry ManifestEntry
|
||||
}
|
||||
|
||||
// dirPattern matches {priority}_{sequence}_{name} or {priority}-{sequence}-{name}
|
||||
// directory names, e.g. "1_010_seed_templates" or "2-001-branding".
|
||||
var dirPattern = regexp.MustCompile(`^(\d+)[_-](\d+)[_-](.+)$`)
|
||||
|
||||
// LoadManifest reads and parses the assets.yaml file in dir, returning
|
||||
// the ordered list of manifest entries. Returns an error if assets.yaml is
|
||||
// absent or contains invalid YAML.
|
||||
func LoadManifest(dir string) ([]ManifestEntry, error) {
|
||||
manifestPath := filepath.Join(dir, "assets.yaml")
|
||||
data, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading %s: %w", manifestPath, err)
|
||||
}
|
||||
|
||||
var entries []ManifestEntry
|
||||
if err := yaml.Unmarshal(data, &entries); err != nil {
|
||||
return nil, fmt.Errorf("parsing %s: %w", manifestPath, err)
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// ScanDir recursively walks baseDir, finds all assets.yaml manifests, resolves
|
||||
// each file entry (skipping symlinks and path traversal), and returns the
|
||||
// resulting Items sorted by (Priority, Sequence, DirName).
|
||||
//
|
||||
// Each manifest must reside in a directory whose name follows the
|
||||
// {priority}_{sequence}_{name} pattern. Manifests in directories that do not
|
||||
// follow this convention are assigned Priority=0, Sequence=0 and sorted last.
|
||||
func ScanDir(baseDir string) ([]Item, error) {
|
||||
absBase, err := filepath.Abs(baseDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolving base dir: %w", err)
|
||||
}
|
||||
|
||||
var items []Item
|
||||
|
||||
err = filepath.WalkDir(absBase, func(path string, d os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if d.Name() != "assets.yaml" {
|
||||
return nil
|
||||
}
|
||||
|
||||
manifestDir := filepath.Dir(path)
|
||||
priority, sequence, dirName := parseDirName(filepath.Base(manifestDir))
|
||||
|
||||
entries, err := LoadManifest(manifestDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading manifest in %s: %w", manifestDir, err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.File == "" || entry.Call == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Resolve and validate the asset file path.
|
||||
resolved, skip, err := resolveAssetPath(absBase, manifestDir, entry.File)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if skip {
|
||||
continue
|
||||
}
|
||||
|
||||
items = append(items, Item{
|
||||
Priority: priority,
|
||||
Sequence: sequence,
|
||||
DirName: dirName,
|
||||
Dir: manifestDir,
|
||||
Entry: ManifestEntry{
|
||||
File: resolved, // absolute path, safe to read
|
||||
Call: entry.Call,
|
||||
Params: entry.Params,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
if items[i].Priority != items[j].Priority {
|
||||
return items[i].Priority < items[j].Priority
|
||||
}
|
||||
if items[i].Sequence != items[j].Sequence {
|
||||
return items[i].Sequence < items[j].Sequence
|
||||
}
|
||||
return items[i].DirName < items[j].DirName
|
||||
})
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// parseDirName extracts (priority, sequence, name) from a directory name that
|
||||
// follows the {priority}[_-]{sequence}[_-]{name} convention. Returns (0, 0, dir)
|
||||
// when the name does not match.
|
||||
func parseDirName(dir string) (priority int, sequence uint, name string) {
|
||||
m := dirPattern.FindStringSubmatch(dir)
|
||||
if m == nil {
|
||||
return 0, 0, dir
|
||||
}
|
||||
p, _ := strconv.Atoi(m[1])
|
||||
s, _ := strconv.ParseUint(m[2], 10, 64)
|
||||
return p, uint(s), m[3]
|
||||
}
|
||||
|
||||
// resolveAssetPath resolves a manifest-relative file path and checks that:
|
||||
// - it does not escape the base directory (path traversal prevention)
|
||||
// - none of its path components are symlinks
|
||||
//
|
||||
// Returns the absolute path, a skip flag (true when the entry should be silently
|
||||
// dropped), and any hard error.
|
||||
func resolveAssetPath(absBase, manifestDir, file string) (absPath string, skip bool, err error) {
|
||||
// Clean and join before any symlink resolution so we can detect traversal.
|
||||
joined := filepath.Join(manifestDir, filepath.Clean(file))
|
||||
|
||||
// Ensure the cleaned path is still inside absBase.
|
||||
rel, err := filepath.Rel(absBase, joined)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
// Path escapes the base directory; skip silently.
|
||||
return "", true, nil
|
||||
}
|
||||
|
||||
// Walk each component to detect symlinks.
|
||||
parts := strings.Split(rel, string(filepath.Separator))
|
||||
current := absBase
|
||||
for _, part := range parts {
|
||||
current = filepath.Join(current, part)
|
||||
info, statErr := os.Lstat(current)
|
||||
if statErr != nil {
|
||||
// File doesn't exist; skip.
|
||||
return "", true, nil
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
// Symlink in path; skip silently.
|
||||
return "", true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return joined, false, nil
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
package assetloader_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/assetloader"
|
||||
)
|
||||
|
||||
func TestLoadManifest_ValidList(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeFile(t, dir, "assets.yaml", `
|
||||
- file: hello.txt
|
||||
call: INSERT INTO files (name, data) VALUES (:filename, :bytes)
|
||||
- file: logo.png
|
||||
call: UPDATE branding SET logo = :bytes WHERE id = 1
|
||||
`)
|
||||
writeFile(t, dir, "hello.txt", "hello world")
|
||||
writeFile(t, dir, "logo.png", "\x89PNG\r\n\x1a\n")
|
||||
|
||||
m, err := assetloader.LoadManifest(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadManifest failed: %v", err)
|
||||
}
|
||||
if len(m) != 2 {
|
||||
t.Fatalf("expected 2 entries, got %d", len(m))
|
||||
}
|
||||
if m[0].File != "hello.txt" {
|
||||
t.Errorf("entry 0 file: got %q, want %q", m[0].File, "hello.txt")
|
||||
}
|
||||
if m[1].File != "logo.png" {
|
||||
t.Errorf("entry 1 file: got %q, want %q", m[1].File, "logo.png")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadManifest_WithStaticParams(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeFile(t, dir, "assets.yaml", `
|
||||
- file: template.md
|
||||
call: INSERT INTO templates (owner_id, name, data) VALUES (:owner_id, :filename, :bytes)
|
||||
params:
|
||||
owner_id: "42"
|
||||
`)
|
||||
writeFile(t, dir, "template.md", "# Template")
|
||||
|
||||
m, err := assetloader.LoadManifest(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadManifest failed: %v", err)
|
||||
}
|
||||
if len(m) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(m))
|
||||
}
|
||||
if m[0].Params["owner_id"] != "42" {
|
||||
t.Errorf("static param owner_id: got %q, want %q", m[0].Params["owner_id"], "42")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadManifest_MissingFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// No assets.yaml present
|
||||
_, err := assetloader.LoadManifest(dir)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing assets.yaml, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadManifest_InvalidYAML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeFile(t, dir, "assets.yaml", `{not: [valid yaml`)
|
||||
_, err := assetloader.LoadManifest(dir)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid YAML, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanDir_FindsManifests(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
// Directory named with priority-sequence pattern
|
||||
dir1 := filepath.Join(root, "1_010_seed_templates")
|
||||
if err := os.MkdirAll(dir1, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, dir1, "assets.yaml", `
|
||||
- file: a.txt
|
||||
call: INSERT INTO t (data) VALUES (:bytes)
|
||||
`)
|
||||
writeFile(t, dir1, "a.txt", "aaa")
|
||||
|
||||
dir2 := filepath.Join(root, "2_001_branding")
|
||||
if err := os.MkdirAll(dir2, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, dir2, "assets.yaml", `
|
||||
- file: logo.png
|
||||
call: UPDATE branding SET logo = :bytes
|
||||
`)
|
||||
writeFile(t, dir2, "logo.png", "PNG")
|
||||
|
||||
items, err := assetloader.ScanDir(root)
|
||||
if err != nil {
|
||||
t.Fatalf("ScanDir failed: %v", err)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("expected 2 items, got %d", len(items))
|
||||
}
|
||||
// Should be ordered by priority then sequence
|
||||
if items[0].Priority != 1 || items[0].Sequence != 10 {
|
||||
t.Errorf("item[0]: got priority=%d seq=%d, want 1,10", items[0].Priority, items[0].Sequence)
|
||||
}
|
||||
if items[1].Priority != 2 || items[1].Sequence != 1 {
|
||||
t.Errorf("item[1]: got priority=%d seq=%d, want 2,1", items[1].Priority, items[1].Sequence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanDir_OrdersByPriorityThenSequence(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
for _, d := range []string{"2_002_b", "1_001_a", "2_001_c", "1_002_d"} {
|
||||
dir := filepath.Join(root, d)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, dir, "assets.yaml", `
|
||||
- file: x.txt
|
||||
call: SELECT :bytes
|
||||
`)
|
||||
writeFile(t, dir, "x.txt", "x")
|
||||
}
|
||||
|
||||
items, err := assetloader.ScanDir(root)
|
||||
if err != nil {
|
||||
t.Fatalf("ScanDir failed: %v", err)
|
||||
}
|
||||
if len(items) != 4 {
|
||||
t.Fatalf("expected 4 items, got %d", len(items))
|
||||
}
|
||||
|
||||
type ps struct {
|
||||
p int
|
||||
s uint
|
||||
}
|
||||
want := []ps{{1, 1}, {1, 2}, {2, 1}, {2, 2}}
|
||||
for i, w := range want {
|
||||
got := ps{items[i].Priority, items[i].Sequence}
|
||||
if got != w {
|
||||
t.Errorf("items[%d]: got {%d,%d}, want {%d,%d}", i, got.p, got.s, w.p, w.s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanDir_SkipsSymlinks(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
dir1 := filepath.Join(root, "1_001_real")
|
||||
if err := os.MkdirAll(dir1, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, dir1, "assets.yaml", `
|
||||
- file: a.txt
|
||||
call: SELECT :bytes
|
||||
`)
|
||||
writeFile(t, dir1, "a.txt", "real")
|
||||
|
||||
// Symlink to an asset file - should be skipped during file read
|
||||
realFile := filepath.Join(root, "real.txt")
|
||||
writeFile(t, root, "real.txt", "symlink target")
|
||||
symlink := filepath.Join(dir1, "link.txt")
|
||||
if err := os.Symlink(realFile, symlink); err != nil {
|
||||
t.Skip("symlinks not supported:", err)
|
||||
}
|
||||
|
||||
// Add a manifest entry that references the symlink
|
||||
writeFile(t, dir1, "assets.yaml", `
|
||||
- file: a.txt
|
||||
call: SELECT :bytes
|
||||
- file: link.txt
|
||||
call: SELECT :bytes
|
||||
`)
|
||||
|
||||
items, err := assetloader.ScanDir(root)
|
||||
if err != nil {
|
||||
t.Fatalf("ScanDir failed: %v", err)
|
||||
}
|
||||
// The symlink entry should be skipped; only a.txt should remain
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected 1 item after symlink skip, got %d", len(items))
|
||||
}
|
||||
if filepath.Base(items[0].Entry.File) != "a.txt" {
|
||||
t.Errorf("expected non-symlink entry, got %q", items[0].Entry.File)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanDir_RejectsPathTraversal(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
dir1 := filepath.Join(root, "1_001_evil")
|
||||
if err := os.MkdirAll(dir1, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, dir1, "assets.yaml", `
|
||||
- file: ../../etc/passwd
|
||||
call: SELECT :bytes
|
||||
`)
|
||||
|
||||
items, err := assetloader.ScanDir(root)
|
||||
if err != nil {
|
||||
t.Fatalf("ScanDir failed: %v", err)
|
||||
}
|
||||
// Path traversal entry should be skipped
|
||||
if len(items) != 0 {
|
||||
t.Fatalf("expected 0 items after path traversal rejection, got %d", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildQuery_BasicPlaceholders(t *testing.T) {
|
||||
sql, args, err := assetloader.BuildQuery(
|
||||
"INSERT INTO t (name, data) VALUES (:filename, :bytes)",
|
||||
[]byte("hello"),
|
||||
"hello.txt",
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildQuery failed: %v", err)
|
||||
}
|
||||
if sql != "INSERT INTO t (name, data) VALUES ($1, $2)" {
|
||||
t.Errorf("unexpected SQL: %s", sql)
|
||||
}
|
||||
if len(args) != 2 {
|
||||
t.Fatalf("expected 2 args, got %d", len(args))
|
||||
}
|
||||
if string(args[0].(string)) != "hello.txt" {
|
||||
t.Errorf("args[0]: got %q, want %q", args[0], "hello.txt")
|
||||
}
|
||||
if string(args[1].([]byte)) != "hello" {
|
||||
t.Errorf("args[1]: got %v, want %v", args[1], []byte("hello"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildQuery_StaticParams(t *testing.T) {
|
||||
sql, args, err := assetloader.BuildQuery(
|
||||
"INSERT INTO t (owner, name, data) VALUES (:owner_id, :filename, :bytes)",
|
||||
[]byte("data"),
|
||||
"file.bin",
|
||||
map[string]string{"owner_id": "99"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildQuery failed: %v", err)
|
||||
}
|
||||
if sql != "INSERT INTO t (owner, name, data) VALUES ($1, $2, $3)" {
|
||||
t.Errorf("unexpected SQL: %s", sql)
|
||||
}
|
||||
if len(args) != 3 {
|
||||
t.Fatalf("expected 3 args, got %d: %v", len(args), args)
|
||||
}
|
||||
if args[0].(string) != "99" {
|
||||
t.Errorf("args[0]: got %q, want %q", args[0], "99")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildQuery_RepeatedPlaceholder(t *testing.T) {
|
||||
sql, args, err := assetloader.BuildQuery(
|
||||
"SELECT length(:bytes), encode(:bytes, 'base64')",
|
||||
[]byte("abc"),
|
||||
"f.bin",
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildQuery failed: %v", err)
|
||||
}
|
||||
// :bytes appears twice but maps to same $1
|
||||
if sql != "SELECT length($1), encode($1, 'base64')" {
|
||||
t.Errorf("unexpected SQL: %s", sql)
|
||||
}
|
||||
if len(args) != 1 {
|
||||
t.Fatalf("expected 1 arg, got %d", len(args))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildQuery_PostgresCastNotMatched(t *testing.T) {
|
||||
// ::text should NOT be treated as a placeholder
|
||||
sql, args, err := assetloader.BuildQuery(
|
||||
"SELECT :bytes::text, :filename",
|
||||
[]byte("data"),
|
||||
"f.txt",
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildQuery failed: %v", err)
|
||||
}
|
||||
if sql != "SELECT $1::text, $2" {
|
||||
t.Errorf("unexpected SQL: %s", sql)
|
||||
}
|
||||
if len(args) != 2 {
|
||||
t.Fatalf("expected 2 args, got %d", len(args))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildQuery_UnknownPlaceholder(t *testing.T) {
|
||||
_, _, err := assetloader.BuildQuery(
|
||||
"SELECT :unknown_param",
|
||||
[]byte("data"),
|
||||
"f.txt",
|
||||
nil,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown placeholder, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildQuery_BinaryFileByteExact(t *testing.T) {
|
||||
// Binary data with null bytes, high bytes - must pass through unchanged
|
||||
binary := []byte{0x00, 0xFF, 0x80, 0x01, 0xFE}
|
||||
_, args, err := assetloader.BuildQuery(
|
||||
"INSERT INTO blobs (data) VALUES (:bytes)",
|
||||
binary,
|
||||
"blob.bin",
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildQuery failed: %v", err)
|
||||
}
|
||||
if len(args) != 1 {
|
||||
t.Fatalf("expected 1 arg")
|
||||
}
|
||||
got := args[0].([]byte)
|
||||
if len(got) != len(binary) {
|
||||
t.Fatalf("byte count: got %d, want %d", len(got), len(binary))
|
||||
}
|
||||
for i, b := range binary {
|
||||
if got[i] != b {
|
||||
t.Errorf("byte[%d]: got 0x%02x, want 0x%02x", i, got[i], b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeFile is a test helper that writes content to a file.
|
||||
func writeFile(t *testing.T, dir, name, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("writeFile %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Package buildinfo exposes the RelSpec version and build date so that both the
|
||||
// CLI and the schema writers can stamp generated output with the same values.
|
||||
package buildinfo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Version and BuildDate are set via -ldflags at build time (see Makefile). When
|
||||
// built without ldflags they are backfilled from the Go module build info.
|
||||
var (
|
||||
Version = "dev"
|
||||
BuildDate = "unknown"
|
||||
)
|
||||
|
||||
func init() {
|
||||
if Version != "dev" {
|
||||
return
|
||||
}
|
||||
info, ok := debug.ReadBuildInfo()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var rev, vcsTime string
|
||||
for _, s := range info.Settings {
|
||||
switch s.Key {
|
||||
case "vcs.revision":
|
||||
if len(s.Value) >= 7 {
|
||||
rev = s.Value[:7]
|
||||
}
|
||||
case "vcs.time":
|
||||
vcsTime = s.Value
|
||||
}
|
||||
}
|
||||
if rev != "" {
|
||||
Version = rev
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, vcsTime); err == nil {
|
||||
BuildDate = t.UTC().Format("2006-01-02 15:04:05 UTC")
|
||||
}
|
||||
}
|
||||
|
||||
// GeneratedComment returns the one-line provenance string embedded in generated
|
||||
// files, e.g. "RelSpec dev (built: unknown)".
|
||||
func GeneratedComment() string {
|
||||
return fmt.Sprintf("RelSpec %s (built: %s)", Version, BuildDate)
|
||||
}
|
||||
|
||||
const AsciiLogo = `
|
||||
██████╗ ███████╗██╗ ███████╗██████╗ ███████╗ ██████╗
|
||||
██╔══██╗██╔════╝██║ ██╔════╝██╔══██╗██╔════╝██╔════╝
|
||||
██████╔╝█████╗ ██║ ███████╗██████╔╝█████╗ ██║
|
||||
██╔══██╗██╔══╝ ██║ ╚════██║██╔═══╝ ██╔══╝ ██║
|
||||
██║ ██║███████╗███████╗███████║██║ ███████╗╚██████╗
|
||||
╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝╚═╝ ╚══════╝ ╚═════╝
|
||||
[ IN ] ──▶ [ RELSPEC ] ──▶ [ OUT ]
|
||||
╔══════════════════════════════════════╗
|
||||
║ ║
|
||||
║ © WARKY DEVS ║
|
||||
║ Author: Hein (hein@warky.dev) ║
|
||||
║ ║
|
||||
╚══════════════════════════════════════╝
|
||||
`
|
||||
@@ -0,0 +1,28 @@
|
||||
// Package commontypes provides shared type definitions used across multiple packages.
|
||||
//
|
||||
// # Overview
|
||||
//
|
||||
// The commontypes package contains common data structures, constants, and type
|
||||
// definitions that are shared between different parts of RelSpec but don't belong
|
||||
// to the core models package.
|
||||
//
|
||||
// # Purpose
|
||||
//
|
||||
// This package helps avoid circular dependencies by providing a common location
|
||||
// for types that are used by multiple packages without creating import cycles.
|
||||
//
|
||||
// # Contents
|
||||
//
|
||||
// Common types may include:
|
||||
// - Shared enums and constants
|
||||
// - Utility type aliases
|
||||
// - Common error types
|
||||
// - Shared configuration structures
|
||||
//
|
||||
// # Usage
|
||||
//
|
||||
// import "git.warky.dev/wdevs/relspecgo/pkg/commontypes"
|
||||
//
|
||||
// // Use common types
|
||||
// var formatType commontypes.FormatType
|
||||
package commontypes
|
||||
+300
-41
@@ -1,11 +1,26 @@
|
||||
package diff
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
// sortedKeys returns a map's keys sorted alphabetically, so callers get a
|
||||
// deterministic iteration order instead of Go's randomized map order.
|
||||
func sortedKeys[T any](m map[string]T) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// CompareDatabases compares two database models and returns the differences
|
||||
func CompareDatabases(source, target *models.Database) *DiffResult {
|
||||
result := &DiffResult{
|
||||
@@ -34,7 +49,8 @@ func compareSchemas(source, target []*models.Schema) *SchemaDiff {
|
||||
}
|
||||
|
||||
// Find missing and modified schemas
|
||||
for name, srcSchema := range sourceMap {
|
||||
for _, name := range sortedKeys(sourceMap) {
|
||||
srcSchema := sourceMap[name]
|
||||
if tgtSchema, exists := targetMap[name]; !exists {
|
||||
diff.Missing = append(diff.Missing, srcSchema)
|
||||
} else {
|
||||
@@ -45,7 +61,8 @@ func compareSchemas(source, target []*models.Schema) *SchemaDiff {
|
||||
}
|
||||
|
||||
// Find extra schemas
|
||||
for name, tgtSchema := range targetMap {
|
||||
for _, name := range sortedKeys(targetMap) {
|
||||
tgtSchema := targetMap[name]
|
||||
if _, exists := sourceMap[name]; !exists {
|
||||
diff.Extra = append(diff.Extra, tgtSchema)
|
||||
}
|
||||
@@ -54,6 +71,13 @@ func compareSchemas(source, target []*models.Schema) *SchemaDiff {
|
||||
return diff
|
||||
}
|
||||
|
||||
func (c *SchemaChange) addChange(field string, source, target any) {
|
||||
if c.Changes == nil {
|
||||
c.Changes = make(map[string]any)
|
||||
}
|
||||
c.Changes[field] = map[string]any{"source": source, "target": target}
|
||||
}
|
||||
|
||||
func compareSchemaDetails(source, target *models.Schema) *SchemaChange {
|
||||
change := &SchemaChange{
|
||||
Name: source.Name,
|
||||
@@ -61,6 +85,16 @@ func compareSchemaDetails(source, target *models.Schema) *SchemaChange {
|
||||
|
||||
hasChanges := false
|
||||
|
||||
// Compare schema attributes
|
||||
if source.Description != target.Description {
|
||||
change.addChange("description", source.Description, target.Description)
|
||||
hasChanges = true
|
||||
}
|
||||
if source.Owner != target.Owner {
|
||||
change.addChange("owner", source.Owner, target.Owner)
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
// Compare tables
|
||||
tableDiff := compareTables(source.Tables, target.Tables)
|
||||
if !isEmpty(tableDiff) {
|
||||
@@ -82,6 +116,13 @@ func compareSchemaDetails(source, target *models.Schema) *SchemaChange {
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
// Compare scripts
|
||||
scriptDiff := compareScripts(source.Scripts, target.Scripts)
|
||||
if !isEmpty(scriptDiff) {
|
||||
change.Scripts = scriptDiff
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
if !hasChanges {
|
||||
return nil
|
||||
}
|
||||
@@ -106,7 +147,8 @@ func compareTables(source, target []*models.Table) *TableDiff {
|
||||
}
|
||||
|
||||
// Find missing and modified tables
|
||||
for name, srcTable := range sourceMap {
|
||||
for _, name := range sortedKeys(sourceMap) {
|
||||
srcTable := sourceMap[name]
|
||||
if tgtTable, exists := targetMap[name]; !exists {
|
||||
diff.Missing = append(diff.Missing, srcTable)
|
||||
} else {
|
||||
@@ -117,7 +159,8 @@ func compareTables(source, target []*models.Table) *TableDiff {
|
||||
}
|
||||
|
||||
// Find extra tables
|
||||
for name, tgtTable := range targetMap {
|
||||
for _, name := range sortedKeys(targetMap) {
|
||||
tgtTable := targetMap[name]
|
||||
if _, exists := sourceMap[name]; !exists {
|
||||
diff.Extra = append(diff.Extra, tgtTable)
|
||||
}
|
||||
@@ -176,7 +219,8 @@ func compareColumns(source, target map[string]*models.Column) *ColumnDiff {
|
||||
}
|
||||
|
||||
// Find missing and modified columns
|
||||
for name, srcCol := range source {
|
||||
for _, name := range sortedKeys(source) {
|
||||
srcCol := source[name]
|
||||
if tgtCol, exists := target[name]; !exists {
|
||||
diff.Missing = append(diff.Missing, srcCol)
|
||||
} else {
|
||||
@@ -192,7 +236,8 @@ func compareColumns(source, target map[string]*models.Column) *ColumnDiff {
|
||||
}
|
||||
|
||||
// Find extra columns
|
||||
for name, tgtCol := range target {
|
||||
for _, name := range sortedKeys(target) {
|
||||
tgtCol := target[name]
|
||||
if _, exists := source[name]; !exists {
|
||||
diff.Extra = append(diff.Extra, tgtCol)
|
||||
}
|
||||
@@ -203,11 +248,13 @@ func compareColumns(source, target map[string]*models.Column) *ColumnDiff {
|
||||
|
||||
func compareColumnDetails(source, target *models.Column) map[string]any {
|
||||
changes := make(map[string]any)
|
||||
sourceType, sourceLength, sourceDefault := comparableColumn(source)
|
||||
targetType, targetLength, targetDefault := comparableColumn(target)
|
||||
|
||||
if source.Type != target.Type {
|
||||
if sourceType != targetType {
|
||||
changes["type"] = map[string]string{"source": source.Type, "target": target.Type}
|
||||
}
|
||||
if source.Length != target.Length {
|
||||
if sourceLength != targetLength {
|
||||
changes["length"] = map[string]int{"source": source.Length, "target": target.Length}
|
||||
}
|
||||
if source.Precision != target.Precision {
|
||||
@@ -219,8 +266,8 @@ func compareColumnDetails(source, target *models.Column) map[string]any {
|
||||
if source.NotNull != target.NotNull {
|
||||
changes["not_null"] = map[string]bool{"source": source.NotNull, "target": target.NotNull}
|
||||
}
|
||||
if !reflect.DeepEqual(source.Default, target.Default) {
|
||||
changes["default"] = map[string]any{"source": source.Default, "target": target.Default}
|
||||
if !reflect.DeepEqual(sourceDefault, targetDefault) {
|
||||
changes["default"] = map[string]any{"source": sourceDefault, "target": targetDefault}
|
||||
}
|
||||
if source.AutoIncrement != target.AutoIncrement {
|
||||
changes["auto_increment"] = map[string]bool{"source": source.AutoIncrement, "target": target.AutoIncrement}
|
||||
@@ -232,6 +279,28 @@ func compareColumnDetails(source, target *models.Column) map[string]any {
|
||||
return changes
|
||||
}
|
||||
|
||||
// comparableColumn accepts DBML's compact type/default spelling as well as
|
||||
// PostgreSQL's normalized fields (for example varchar(255) vs varchar + 255).
|
||||
func comparableColumn(column *models.Column) (normalizedType string, length int, defaultVal any) {
|
||||
typeName := strings.TrimSpace(column.Type)
|
||||
defaultValue := column.Default
|
||||
lower := strings.ToLower(typeName)
|
||||
if i := strings.Index(lower, " default "); i >= 0 {
|
||||
if defaultValue == nil {
|
||||
defaultValue = strings.TrimSpace(typeName[i+len(" default "):])
|
||||
}
|
||||
typeName = strings.TrimSpace(typeName[:i])
|
||||
}
|
||||
length = column.Length
|
||||
if open := strings.LastIndex(typeName, "("); open >= 0 && strings.HasSuffix(typeName, ")") {
|
||||
if parsed, err := strconv.Atoi(strings.TrimSpace(typeName[open+1 : len(typeName)-1])); err == nil && length == 0 {
|
||||
length = parsed
|
||||
}
|
||||
typeName = strings.TrimSpace(typeName[:open])
|
||||
}
|
||||
return strings.ToLower(typeName), length, defaultValue
|
||||
}
|
||||
|
||||
func compareIndexes(source, target map[string]*models.Index) *IndexDiff {
|
||||
diff := &IndexDiff{
|
||||
Missing: make([]*models.Index, 0),
|
||||
@@ -239,32 +308,85 @@ func compareIndexes(source, target map[string]*models.Index) *IndexDiff {
|
||||
Modified: make([]*IndexChange, 0),
|
||||
}
|
||||
|
||||
// Find missing and modified indexes
|
||||
for name, srcIdx := range source {
|
||||
if tgtIdx, exists := target[name]; !exists {
|
||||
// Match by name first, then by definition. PostgreSQL and DBML can assign
|
||||
// different names to the same index (for example, posts_user_id_title_idx
|
||||
// and uidx_posts_user_id_title), so a name-only comparison reports false
|
||||
// drift after a merge/diff round trip.
|
||||
unmatchedSource := make(map[string]*models.Index, len(source))
|
||||
unmatchedTarget := make(map[string]*models.Index, len(target))
|
||||
for name, index := range source {
|
||||
unmatchedSource[name] = index
|
||||
}
|
||||
for name, index := range target {
|
||||
unmatchedTarget[name] = index
|
||||
}
|
||||
|
||||
for _, name := range sortedKeys(source) {
|
||||
srcIdx := source[name]
|
||||
tgtIdx, exists := target[name]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
delete(unmatchedSource, name)
|
||||
delete(unmatchedTarget, name)
|
||||
if changes := compareIndexDetails(srcIdx, tgtIdx); len(changes) > 0 {
|
||||
diff.Modified = append(diff.Modified, &IndexChange{
|
||||
Name: name,
|
||||
Source: srcIdx,
|
||||
Target: tgtIdx,
|
||||
Changes: changes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Pair remaining indexes by their structural identity, independent of the
|
||||
// generated/name field. The sorted iteration makes ambiguous matches
|
||||
// deterministic; duplicate definitions are still represented as separate
|
||||
// indexes by consuming one target at a time.
|
||||
remainingTarget := make(map[string][]*models.Index)
|
||||
for _, name := range sortedKeys(unmatchedTarget) {
|
||||
index := unmatchedTarget[name]
|
||||
key := indexDefinitionKey(index)
|
||||
remainingTarget[key] = append(remainingTarget[key], index)
|
||||
}
|
||||
for _, name := range sortedKeys(unmatchedSource) {
|
||||
srcIdx := unmatchedSource[name]
|
||||
key := indexDefinitionKey(srcIdx)
|
||||
candidates := remainingTarget[key]
|
||||
if len(candidates) == 0 {
|
||||
diff.Missing = append(diff.Missing, srcIdx)
|
||||
} else {
|
||||
if changes := compareIndexDetails(srcIdx, tgtIdx); len(changes) > 0 {
|
||||
diff.Modified = append(diff.Modified, &IndexChange{
|
||||
Name: name,
|
||||
Source: srcIdx,
|
||||
Target: tgtIdx,
|
||||
Changes: changes,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
tgtIdx := candidates[0]
|
||||
remainingTarget[key] = candidates[1:]
|
||||
if changes := compareIndexDetails(srcIdx, tgtIdx); len(changes) > 0 {
|
||||
diff.Modified = append(diff.Modified, &IndexChange{
|
||||
Name: srcIdx.Name,
|
||||
Source: srcIdx,
|
||||
Target: tgtIdx,
|
||||
Changes: changes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Find extra indexes
|
||||
for name, tgtIdx := range target {
|
||||
if _, exists := source[name]; !exists {
|
||||
diff.Extra = append(diff.Extra, tgtIdx)
|
||||
}
|
||||
for _, key := range sortedKeys(remainingTarget) {
|
||||
diff.Extra = append(diff.Extra, remainingTarget[key]...)
|
||||
}
|
||||
|
||||
return diff
|
||||
}
|
||||
|
||||
func indexDefinitionKey(index *models.Index) string {
|
||||
return fmt.Sprintf("%t:%s:%s", index.Unique, strings.Join(index.Columns, ","), strings.Join(index.Include, ","))
|
||||
}
|
||||
|
||||
func comparableIndexType(indexType string) string {
|
||||
indexType = strings.ToLower(strings.TrimSpace(indexType))
|
||||
if indexType == "" {
|
||||
return "btree"
|
||||
}
|
||||
return indexType
|
||||
}
|
||||
|
||||
func compareIndexDetails(source, target *models.Index) map[string]any {
|
||||
changes := make(map[string]any)
|
||||
|
||||
@@ -274,7 +396,7 @@ func compareIndexDetails(source, target *models.Index) map[string]any {
|
||||
if source.Unique != target.Unique {
|
||||
changes["unique"] = map[string]bool{"source": source.Unique, "target": target.Unique}
|
||||
}
|
||||
if source.Type != target.Type {
|
||||
if comparableIndexType(source.Type) != comparableIndexType(target.Type) {
|
||||
changes["type"] = map[string]string{"source": source.Type, "target": target.Type}
|
||||
}
|
||||
if source.Where != target.Where {
|
||||
@@ -284,7 +406,26 @@ func compareIndexDetails(source, target *models.Index) map[string]any {
|
||||
return changes
|
||||
}
|
||||
|
||||
// Compare constraints.
|
||||
// Primary-key constraints are excluded: a PK is already represented by the
|
||||
// column's IsPrimaryKey flag, which compareColumns already compares. The
|
||||
// PostgreSQL reader additionally materialises each PK as a primary_key
|
||||
// constraint and a unique btree index; the DBML reader keeps PKs as column
|
||||
// flags only. Comparing the constraint maps directly would therefore report
|
||||
// every PK as an "extra" constraint and the generated index as an "extra"
|
||||
// index on a freshly-applied schema. Filtering them here keeps the round
|
||||
// trip stable without losing real PK information.
|
||||
func compareConstraints(source, target map[string]*models.Constraint) *ConstraintDiff {
|
||||
filteredSource := filterPrimaryKeyConstraints(source)
|
||||
filteredTarget := filterPrimaryKeyConstraints(target)
|
||||
sourceByKey := make(map[string]*models.Constraint, len(filteredSource))
|
||||
targetByKey := make(map[string]*models.Constraint, len(filteredTarget))
|
||||
for _, constraint := range filteredSource {
|
||||
sourceByKey[constraintCompareKey(constraint)] = constraint
|
||||
}
|
||||
for _, constraint := range filteredTarget {
|
||||
targetByKey[constraintCompareKey(constraint)] = constraint
|
||||
}
|
||||
diff := &ConstraintDiff{
|
||||
Missing: make([]*models.Constraint, 0),
|
||||
Extra: make([]*models.Constraint, 0),
|
||||
@@ -292,8 +433,9 @@ func compareConstraints(source, target map[string]*models.Constraint) *Constrain
|
||||
}
|
||||
|
||||
// Find missing and modified constraints
|
||||
for name, srcCon := range source {
|
||||
if tgtCon, exists := target[name]; !exists {
|
||||
for _, name := range sortedKeys(sourceByKey) {
|
||||
srcCon := sourceByKey[name]
|
||||
if tgtCon, exists := targetByKey[name]; !exists {
|
||||
diff.Missing = append(diff.Missing, srcCon)
|
||||
} else {
|
||||
if changes := compareConstraintDetails(srcCon, tgtCon); len(changes) > 0 {
|
||||
@@ -308,8 +450,9 @@ func compareConstraints(source, target map[string]*models.Constraint) *Constrain
|
||||
}
|
||||
|
||||
// Find extra constraints
|
||||
for name, tgtCon := range target {
|
||||
if _, exists := source[name]; !exists {
|
||||
for _, name := range sortedKeys(targetByKey) {
|
||||
tgtCon := targetByKey[name]
|
||||
if _, exists := sourceByKey[name]; !exists {
|
||||
diff.Extra = append(diff.Extra, tgtCon)
|
||||
}
|
||||
}
|
||||
@@ -317,6 +460,29 @@ func compareConstraints(source, target map[string]*models.Constraint) *Constrain
|
||||
return diff
|
||||
}
|
||||
|
||||
// filterPrimaryKeyConstraints drops primary_key constraints from a single
|
||||
// map. Primary keys are compared by the column IsPrimaryKey flag in
|
||||
// compareColumns, so comparing the primary_key constraints here only
|
||||
// produces duplicate "extra" entries (every PK is extra on the DBML side).
|
||||
// Other constraint types are preserved untouched.
|
||||
func filterPrimaryKeyConstraints(m map[string]*models.Constraint) map[string]*models.Constraint {
|
||||
out := make(map[string]*models.Constraint, len(m))
|
||||
for name, c := range m {
|
||||
if c.Type == models.PrimaryKeyConstraint {
|
||||
continue
|
||||
}
|
||||
out[name] = c
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func constraintCompareKey(constraint *models.Constraint) string {
|
||||
if constraint.Type != models.ForeignKeyConstraint {
|
||||
return constraint.SQLName()
|
||||
}
|
||||
return fmt.Sprintf("fk:%s:%s:%s:%s:%s:%s", strings.ToLower(constraint.Schema), strings.ToLower(constraint.Table), strings.Join(constraint.Columns, ","), strings.ToLower(constraint.ReferencedSchema), strings.ToLower(constraint.ReferencedTable), strings.Join(constraint.ReferencedColumns, ","))
|
||||
}
|
||||
|
||||
func compareConstraintDetails(source, target *models.Constraint) map[string]any {
|
||||
changes := make(map[string]any)
|
||||
|
||||
@@ -332,16 +498,23 @@ func compareConstraintDetails(source, target *models.Constraint) map[string]any
|
||||
if !reflect.DeepEqual(source.ReferencedColumns, target.ReferencedColumns) {
|
||||
changes["referenced_columns"] = map[string][]string{"source": source.ReferencedColumns, "target": target.ReferencedColumns}
|
||||
}
|
||||
if source.OnDelete != target.OnDelete {
|
||||
if normalizeConstraintAction(source.OnDelete) != normalizeConstraintAction(target.OnDelete) {
|
||||
changes["on_delete"] = map[string]string{"source": source.OnDelete, "target": target.OnDelete}
|
||||
}
|
||||
if source.OnUpdate != target.OnUpdate {
|
||||
if normalizeConstraintAction(source.OnUpdate) != normalizeConstraintAction(target.OnUpdate) {
|
||||
changes["on_update"] = map[string]string{"source": source.OnUpdate, "target": target.OnUpdate}
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
func normalizeConstraintAction(action string) string {
|
||||
if strings.EqualFold(strings.TrimSpace(action), "NO ACTION") {
|
||||
return ""
|
||||
}
|
||||
return strings.ToUpper(strings.TrimSpace(action))
|
||||
}
|
||||
|
||||
func compareRelationships(source, target map[string]*models.Relationship) *RelationshipDiff {
|
||||
diff := &RelationshipDiff{
|
||||
Missing: make([]*models.Relationship, 0),
|
||||
@@ -350,7 +523,8 @@ func compareRelationships(source, target map[string]*models.Relationship) *Relat
|
||||
}
|
||||
|
||||
// Find missing and modified relationships
|
||||
for name, srcRel := range source {
|
||||
for _, name := range sortedKeys(source) {
|
||||
srcRel := source[name]
|
||||
if tgtRel, exists := target[name]; !exists {
|
||||
diff.Missing = append(diff.Missing, srcRel)
|
||||
} else {
|
||||
@@ -366,7 +540,8 @@ func compareRelationships(source, target map[string]*models.Relationship) *Relat
|
||||
}
|
||||
|
||||
// Find extra relationships
|
||||
for name, tgtRel := range target {
|
||||
for _, name := range sortedKeys(target) {
|
||||
tgtRel := target[name]
|
||||
if _, exists := source[name]; !exists {
|
||||
diff.Extra = append(diff.Extra, tgtRel)
|
||||
}
|
||||
@@ -415,7 +590,8 @@ func compareViews(source, target []*models.View) *ViewDiff {
|
||||
}
|
||||
|
||||
// Find missing and modified views
|
||||
for name, srcView := range sourceMap {
|
||||
for _, name := range sortedKeys(sourceMap) {
|
||||
srcView := sourceMap[name]
|
||||
if tgtView, exists := targetMap[name]; !exists {
|
||||
diff.Missing = append(diff.Missing, srcView)
|
||||
} else {
|
||||
@@ -431,7 +607,8 @@ func compareViews(source, target []*models.View) *ViewDiff {
|
||||
}
|
||||
|
||||
// Find extra views
|
||||
for name, tgtView := range targetMap {
|
||||
for _, name := range sortedKeys(targetMap) {
|
||||
tgtView := targetMap[name]
|
||||
if _, exists := sourceMap[name]; !exists {
|
||||
diff.Extra = append(diff.Extra, tgtView)
|
||||
}
|
||||
@@ -468,7 +645,8 @@ func compareSequences(source, target []*models.Sequence) *SequenceDiff {
|
||||
}
|
||||
|
||||
// Find missing and modified sequences
|
||||
for name, srcSeq := range sourceMap {
|
||||
for _, name := range sortedKeys(sourceMap) {
|
||||
srcSeq := sourceMap[name]
|
||||
if tgtSeq, exists := targetMap[name]; !exists {
|
||||
diff.Missing = append(diff.Missing, srcSeq)
|
||||
} else {
|
||||
@@ -484,7 +662,8 @@ func compareSequences(source, target []*models.Sequence) *SequenceDiff {
|
||||
}
|
||||
|
||||
// Find extra sequences
|
||||
for name, tgtSeq := range targetMap {
|
||||
for _, name := range sortedKeys(targetMap) {
|
||||
tgtSeq := targetMap[name]
|
||||
if _, exists := sourceMap[name]; !exists {
|
||||
diff.Extra = append(diff.Extra, tgtSeq)
|
||||
}
|
||||
@@ -515,6 +694,79 @@ func compareSequenceDetails(source, target *models.Sequence) map[string]any {
|
||||
return changes
|
||||
}
|
||||
|
||||
func compareScripts(source, target []*models.Script) *ScriptDiff {
|
||||
diff := &ScriptDiff{
|
||||
Missing: make([]*models.Script, 0),
|
||||
Extra: make([]*models.Script, 0),
|
||||
Modified: make([]*ScriptChange, 0),
|
||||
}
|
||||
|
||||
sourceMap := make(map[string]*models.Script)
|
||||
targetMap := make(map[string]*models.Script)
|
||||
|
||||
for _, s := range source {
|
||||
sourceMap[scriptCompareKey(s)] = s
|
||||
}
|
||||
for _, s := range target {
|
||||
targetMap[scriptCompareKey(s)] = s
|
||||
}
|
||||
|
||||
for _, name := range sortedKeys(sourceMap) {
|
||||
srcScript := sourceMap[name]
|
||||
if tgtScript, exists := targetMap[name]; !exists {
|
||||
diff.Missing = append(diff.Missing, srcScript)
|
||||
} else if changes := compareScriptDetails(srcScript, tgtScript); len(changes) > 0 {
|
||||
diff.Modified = append(diff.Modified, &ScriptChange{
|
||||
Name: srcScript.Name,
|
||||
Source: srcScript,
|
||||
Target: tgtScript,
|
||||
Changes: changes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for _, name := range sortedKeys(targetMap) {
|
||||
tgtScript := targetMap[name]
|
||||
if _, exists := sourceMap[name]; !exists {
|
||||
diff.Extra = append(diff.Extra, tgtScript)
|
||||
}
|
||||
}
|
||||
|
||||
return diff
|
||||
}
|
||||
|
||||
func scriptCompareKey(script *models.Script) string {
|
||||
return fmt.Sprintf("%d:%d:%s", script.Priority, script.Sequence, script.SQLName())
|
||||
}
|
||||
|
||||
func compareScriptDetails(source, target *models.Script) map[string]any {
|
||||
changes := make(map[string]any)
|
||||
|
||||
if source.SQL != target.SQL {
|
||||
changes["sql"] = map[string]string{"source": source.SQL, "target": target.SQL}
|
||||
}
|
||||
if source.Rollback != target.Rollback {
|
||||
changes["rollback"] = map[string]string{"source": source.Rollback, "target": target.Rollback}
|
||||
}
|
||||
if !reflect.DeepEqual(source.RunAfter, target.RunAfter) {
|
||||
changes["run_after"] = map[string][]string{"source": source.RunAfter, "target": target.RunAfter}
|
||||
}
|
||||
if source.Schema != target.Schema {
|
||||
changes["schema"] = map[string]string{"source": source.Schema, "target": target.Schema}
|
||||
}
|
||||
if source.Version != target.Version {
|
||||
changes["version"] = map[string]string{"source": source.Version, "target": target.Version}
|
||||
}
|
||||
if source.Priority != target.Priority {
|
||||
changes["priority"] = map[string]int{"source": source.Priority, "target": target.Priority}
|
||||
}
|
||||
if source.Sequence != target.Sequence {
|
||||
changes["sequence"] = map[string]uint{"source": source.Sequence, "target": target.Sequence}
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
// Helper function to check if a diff is empty
|
||||
func isEmpty(v any) bool {
|
||||
switch d := v.(type) {
|
||||
@@ -532,6 +784,8 @@ func isEmpty(v any) bool {
|
||||
return len(d.Missing) == 0 && len(d.Extra) == 0 && len(d.Modified) == 0
|
||||
case *SequenceDiff:
|
||||
return len(d.Missing) == 0 && len(d.Extra) == 0 && len(d.Modified) == 0
|
||||
case *ScriptDiff:
|
||||
return len(d.Missing) == 0 && len(d.Extra) == 0 && len(d.Modified) == 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -588,6 +842,11 @@ func ComputeSummary(result *DiffResult) *Summary {
|
||||
summary.Sequences.Extra += len(schemaChange.Sequences.Extra)
|
||||
summary.Sequences.Modified += len(schemaChange.Sequences.Modified)
|
||||
}
|
||||
if schemaChange.Scripts != nil {
|
||||
summary.Scripts.Missing += len(schemaChange.Scripts.Missing)
|
||||
summary.Scripts.Extra += len(schemaChange.Scripts.Extra)
|
||||
summary.Scripts.Modified += len(schemaChange.Scripts.Modified)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
package diff
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
func TestCompareSchemaDetails(t *testing.T) {
|
||||
mk := func() *models.Schema {
|
||||
s := models.InitSchema("public")
|
||||
s.Tables = []*models.Table{models.InitTable("t", "public")}
|
||||
return s
|
||||
}
|
||||
|
||||
if got := compareSchemaDetails(mk(), mk()); got != nil {
|
||||
t.Errorf("identical schemas must yield nil, got %+v", got)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*models.Schema)
|
||||
check func(*SchemaChange) bool
|
||||
}{
|
||||
{
|
||||
"table added", func(s *models.Schema) { s.Tables = append(s.Tables, models.InitTable("u", "public")) },
|
||||
func(c *SchemaChange) bool { return c.Tables != nil && len(c.Tables.Extra) == 1 },
|
||||
},
|
||||
{
|
||||
"view added", func(s *models.Schema) { s.Views = []*models.View{models.InitView("v", "public")} },
|
||||
func(c *SchemaChange) bool { return c.Views != nil && len(c.Views.Extra) == 1 },
|
||||
},
|
||||
{
|
||||
"sequence added", func(s *models.Schema) { s.Sequences = []*models.Sequence{models.InitSequence("sq", "public")} },
|
||||
func(c *SchemaChange) bool { return c.Sequences != nil && len(c.Sequences.Extra) == 1 },
|
||||
},
|
||||
{
|
||||
"script added", func(s *models.Schema) { s.Scripts = []*models.Script{models.InitScript("sc")} },
|
||||
func(c *SchemaChange) bool { return c.Scripts != nil && len(c.Scripts.Extra) == 1 },
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
target := mk()
|
||||
tt.mutate(target)
|
||||
got := compareSchemaDetails(mk(), target)
|
||||
if got == nil || got.Name != "public" || !tt.check(got) {
|
||||
t.Errorf("unexpected change: %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareConstraintDetails(t *testing.T) {
|
||||
base := func() *models.Constraint {
|
||||
c := models.InitConstraint("fk", models.ForeignKeyConstraint)
|
||||
c.Columns = []string{"a"}
|
||||
c.ReferencedTable = "users"
|
||||
c.ReferencedColumns = []string{"id"}
|
||||
c.OnDelete = "CASCADE"
|
||||
c.OnUpdate = "NO ACTION"
|
||||
return c
|
||||
}
|
||||
if got := compareConstraintDetails(base(), base()); len(got) != 0 {
|
||||
t.Errorf("identical: %v", got)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*models.Constraint)
|
||||
wantKey string
|
||||
}{
|
||||
{"type", func(c *models.Constraint) { c.Type = models.UniqueConstraint }, "type"},
|
||||
{"columns", func(c *models.Constraint) { c.Columns = []string{"b"} }, "columns"},
|
||||
{"referenced table", func(c *models.Constraint) { c.ReferencedTable = "other" }, "referenced_table"},
|
||||
{"referenced columns", func(c *models.Constraint) { c.ReferencedColumns = []string{"x"} }, "referenced_columns"},
|
||||
{"on delete", func(c *models.Constraint) { c.OnDelete = "SET NULL" }, "on_delete"},
|
||||
{"on update", func(c *models.Constraint) { c.OnUpdate = "CASCADE" }, "on_update"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
target := base()
|
||||
tt.mutate(target)
|
||||
got := compareConstraintDetails(base(), target)
|
||||
if _, ok := got[tt.wantKey]; !ok || len(got) != 1 {
|
||||
t.Errorf("got %v, want only %q", got, tt.wantKey)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Action spelling variants that mean the same thing are not changes.
|
||||
a, b := base(), base()
|
||||
a.OnDelete, b.OnDelete = "cascade", " CASCADE "
|
||||
a.OnUpdate, b.OnUpdate = "", "no action"
|
||||
if got := compareConstraintDetails(a, b); len(got) != 0 {
|
||||
t.Errorf("equivalent actions reported as changes: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeConstraintAction(t *testing.T) {
|
||||
tests := []struct{ in, want string }{
|
||||
{"", ""},
|
||||
{"NO ACTION", ""},
|
||||
{"no action", ""},
|
||||
{" No Action ", ""},
|
||||
{"cascade", "CASCADE"},
|
||||
{" set null ", "SET NULL"},
|
||||
{"RESTRICT", "RESTRICT"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := normalizeConstraintAction(tt.in); got != tt.want {
|
||||
t.Errorf("normalizeConstraintAction(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConstraintCompareKey(t *testing.T) {
|
||||
uq := &models.Constraint{Name: "UQ_Name", Type: models.UniqueConstraint}
|
||||
if got := constraintCompareKey(uq); got != "uq_name" {
|
||||
t.Errorf("non-FK key: %q", got)
|
||||
}
|
||||
fk := func(name string) *models.Constraint {
|
||||
return &models.Constraint{
|
||||
Name: name, Type: models.ForeignKeyConstraint, Schema: "Public", Table: "Orders",
|
||||
Columns: []string{"user_id"}, ReferencedSchema: "Public", ReferencedTable: "Users", ReferencedColumns: []string{"id"},
|
||||
}
|
||||
}
|
||||
if constraintCompareKey(fk("a")) != constraintCompareKey(fk("b")) {
|
||||
t.Error("FK key must ignore the constraint name")
|
||||
}
|
||||
other := fk("a")
|
||||
other.ReferencedColumns = []string{"uid"}
|
||||
if constraintCompareKey(fk("a")) == constraintCompareKey(other) {
|
||||
t.Error("FK key must include referenced columns")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterPrimaryKeyConstraints(t *testing.T) {
|
||||
in := map[string]*models.Constraint{
|
||||
"pk": {Name: "pk", Type: models.PrimaryKeyConstraint},
|
||||
"uq": {Name: "uq", Type: models.UniqueConstraint},
|
||||
"fk": {Name: "fk", Type: models.ForeignKeyConstraint},
|
||||
}
|
||||
got := filterPrimaryKeyConstraints(in)
|
||||
if len(got) != 2 || got["pk"] != nil || got["uq"] == nil || got["fk"] == nil {
|
||||
t.Errorf("got %v", got)
|
||||
}
|
||||
if len(in) != 3 {
|
||||
t.Error("input must not be modified")
|
||||
}
|
||||
if got := filterPrimaryKeyConstraints(nil); got == nil || len(got) != 0 {
|
||||
t.Errorf("nil: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareRelationshipDetails(t *testing.T) {
|
||||
base := func() *models.Relationship {
|
||||
r := models.InitRelationship("r", models.RelationType("one_to_many"))
|
||||
r.FromTable, r.ToTable = "orders", "users"
|
||||
r.FromColumns, r.ToColumns = []string{"user_id"}, []string{"id"}
|
||||
return r
|
||||
}
|
||||
if got := compareRelationshipDetails(base(), base()); len(got) != 0 {
|
||||
t.Errorf("identical: %v", got)
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*models.Relationship)
|
||||
wantKey string
|
||||
}{
|
||||
{"type", func(r *models.Relationship) { r.Type = "one_to_one" }, "type"},
|
||||
{"from table", func(r *models.Relationship) { r.FromTable = "x" }, "from_table"},
|
||||
{"to table", func(r *models.Relationship) { r.ToTable = "x" }, "to_table"},
|
||||
{"from columns", func(r *models.Relationship) { r.FromColumns = []string{"x"} }, "from_columns"},
|
||||
{"to columns", func(r *models.Relationship) { r.ToColumns = []string{"x"} }, "to_columns"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
target := base()
|
||||
tt.mutate(target)
|
||||
got := compareRelationshipDetails(base(), target)
|
||||
if _, ok := got[tt.wantKey]; !ok || len(got) != 1 {
|
||||
t.Errorf("got %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareRelationshipsModified(t *testing.T) {
|
||||
src := map[string]*models.Relationship{
|
||||
"same": {Name: "same", Type: "one_to_many"},
|
||||
"changed": {Name: "changed", Type: "one_to_many"},
|
||||
"missing": {Name: "missing"},
|
||||
}
|
||||
tgt := map[string]*models.Relationship{
|
||||
"same": {Name: "same", Type: "one_to_many"},
|
||||
"changed": {Name: "changed", Type: "many_to_many"},
|
||||
"extra": {Name: "extra"},
|
||||
}
|
||||
d := compareRelationships(src, tgt)
|
||||
if len(d.Missing) != 1 || d.Missing[0].Name != "missing" || len(d.Extra) != 1 || d.Extra[0].Name != "extra" ||
|
||||
len(d.Modified) != 1 || d.Modified[0].Name != "changed" {
|
||||
t.Errorf("got %+v", d)
|
||||
}
|
||||
if _, ok := d.Modified[0].Changes["type"]; !ok {
|
||||
t.Errorf("changes: %v", d.Modified[0].Changes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareViews(t *testing.T) {
|
||||
v := func(name, def string) *models.View { return &models.View{Name: name, Definition: def} }
|
||||
src := []*models.View{v("Keep", "select 1"), v("Changed", "select 1"), v("Gone", "select 1")}
|
||||
tgt := []*models.View{v("keep", "select 1"), v("changed", "select 2"), v("New", "select 1")}
|
||||
|
||||
d := compareViews(src, tgt)
|
||||
if len(d.Missing) != 1 || d.Missing[0].Name != "Gone" {
|
||||
t.Errorf("missing: %+v", d.Missing)
|
||||
}
|
||||
if len(d.Extra) != 1 || d.Extra[0].Name != "New" {
|
||||
t.Errorf("extra: %+v", d.Extra)
|
||||
}
|
||||
if len(d.Modified) != 1 || d.Modified[0].Name != "changed" || d.Modified[0].Source.Definition != "select 1" || d.Modified[0].Target.Definition != "select 2" {
|
||||
t.Errorf("modified: %+v", d.Modified)
|
||||
}
|
||||
want := map[string]any{"definition": map[string]string{"source": "select 1", "target": "select 2"}}
|
||||
if !reflect.DeepEqual(d.Modified[0].Changes, want) {
|
||||
t.Errorf("changes: %v", d.Modified[0].Changes)
|
||||
}
|
||||
if !isEmpty(compareViews(nil, nil)) {
|
||||
t.Error("nil views must be empty")
|
||||
}
|
||||
if got := compareViewDetails(v("a", "x"), v("a", "x")); len(got) != 0 {
|
||||
t.Errorf("same definition: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareSequences(t *testing.T) {
|
||||
seq := func(name string, start, inc, min, max int64, cycle bool) *models.Sequence {
|
||||
return &models.Sequence{Name: name, StartValue: start, IncrementBy: inc, MinValue: min, MaxValue: max, Cycle: cycle}
|
||||
}
|
||||
src := []*models.Sequence{seq("Same", 1, 1, 1, 100, false), seq("Diff", 1, 1, 1, 100, false), seq("Gone", 1, 1, 1, 1, false)}
|
||||
tgt := []*models.Sequence{seq("same", 1, 1, 1, 100, false), seq("diff", 5, 2, 3, 200, true), seq("New", 1, 1, 1, 1, false)}
|
||||
|
||||
d := compareSequences(src, tgt)
|
||||
if len(d.Missing) != 1 || d.Missing[0].Name != "Gone" || len(d.Extra) != 1 || d.Extra[0].Name != "New" || len(d.Modified) != 1 {
|
||||
t.Fatalf("got %+v", d)
|
||||
}
|
||||
ch := d.Modified[0].Changes
|
||||
for _, key := range []string{"start_value", "increment_by", "min_value", "max_value", "cycle"} {
|
||||
if _, ok := ch[key]; !ok {
|
||||
t.Errorf("missing change key %q in %v", key, ch)
|
||||
}
|
||||
}
|
||||
if got := ch["increment_by"].(map[string]int64); got["source"] != 1 || got["target"] != 2 {
|
||||
t.Errorf("increment_by: %v", got)
|
||||
}
|
||||
if got := ch["cycle"].(map[string]bool); got["source"] || !got["target"] {
|
||||
t.Errorf("cycle: %v", got)
|
||||
}
|
||||
if got := compareSequenceDetails(seq("a", 1, 1, 1, 1, false), seq("a", 1, 1, 1, 1, false)); len(got) != 0 {
|
||||
t.Errorf("identical: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareScriptDetailsAllFields(t *testing.T) {
|
||||
a := &models.Script{Name: "s", SQL: "a", Rollback: "ra", RunAfter: []string{"x"}, Schema: "p", Version: "1", Priority: 1, Sequence: 1}
|
||||
b := &models.Script{Name: "s", SQL: "b", Rollback: "rb", RunAfter: []string{"y"}, Schema: "q", Version: "2", Priority: 2, Sequence: 2}
|
||||
got := compareScriptDetails(a, b)
|
||||
for _, key := range []string{"sql", "rollback", "run_after", "schema", "version", "priority", "sequence"} {
|
||||
if _, ok := got[key]; !ok {
|
||||
t.Errorf("missing %q in %v", key, got)
|
||||
}
|
||||
}
|
||||
if got := compareScriptDetails(a, a); len(got) != 0 {
|
||||
t.Errorf("identical: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEmptyAllTypes(t *testing.T) {
|
||||
if !isEmpty(&ViewDiff{}) || !isEmpty(&SequenceDiff{}) {
|
||||
t.Error("empty view/sequence diffs must be empty")
|
||||
}
|
||||
if isEmpty(&ViewDiff{Extra: []*models.View{{Name: "v"}}}) || isEmpty(&SequenceDiff{Modified: []*SequenceChange{{Name: "s"}}}) {
|
||||
t.Error("non-empty diffs reported as empty")
|
||||
}
|
||||
if isEmpty(&ConstraintDiff{Modified: []*ConstraintChange{{Name: "c"}}}) || isEmpty(&RelationshipDiff{Missing: []*models.Relationship{{Name: "r"}}}) {
|
||||
t.Error("non-empty diffs reported as empty")
|
||||
}
|
||||
if isEmpty(&IndexDiff{Modified: []*IndexChange{{Name: "i"}}}) || isEmpty(&TableDiff{Modified: []*TableChange{{Name: "t"}}}) {
|
||||
t.Error("non-empty diffs reported as empty")
|
||||
}
|
||||
if isEmpty("something else") || isEmpty(nil) {
|
||||
t.Error("unknown types must not be treated as empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeSummaryFullTree(t *testing.T) {
|
||||
res := &DiffResult{Schemas: &SchemaDiff{
|
||||
Missing: []*models.Schema{{Name: "m"}},
|
||||
Extra: []*models.Schema{{Name: "e"}},
|
||||
Modified: []*SchemaChange{{
|
||||
Name: "public",
|
||||
Tables: &TableDiff{
|
||||
Missing: []*models.Table{{Name: "a"}},
|
||||
Extra: []*models.Table{{Name: "b"}, {Name: "c"}},
|
||||
Modified: []*TableChange{{
|
||||
Name: "t",
|
||||
Columns: &ColumnDiff{Missing: []*models.Column{{}}, Extra: []*models.Column{{}, {}}, Modified: []*ColumnChange{{}}},
|
||||
Indexes: &IndexDiff{Missing: []*models.Index{{}}, Extra: []*models.Index{{}}, Modified: []*IndexChange{{}, {}}},
|
||||
Constraints: &ConstraintDiff{Missing: []*models.Constraint{{}}, Modified: []*ConstraintChange{{}}},
|
||||
Relationships: &RelationshipDiff{Extra: []*models.Relationship{{}}},
|
||||
}},
|
||||
},
|
||||
Views: &ViewDiff{Missing: []*models.View{{}}, Extra: []*models.View{{}}, Modified: []*ViewChange{{}}},
|
||||
Sequences: &SequenceDiff{Missing: []*models.Sequence{{}}, Extra: []*models.Sequence{{}, {}}},
|
||||
Scripts: &ScriptDiff{Modified: []*ScriptChange{{}}},
|
||||
}},
|
||||
}}
|
||||
s := ComputeSummary(res)
|
||||
checks := []struct {
|
||||
name string
|
||||
got [3]int
|
||||
want [3]int
|
||||
}{
|
||||
{"schemas", [3]int{s.Schemas.Missing, s.Schemas.Extra, s.Schemas.Modified}, [3]int{1, 1, 1}},
|
||||
{"tables", [3]int{s.Tables.Missing, s.Tables.Extra, s.Tables.Modified}, [3]int{1, 2, 1}},
|
||||
{"columns", [3]int{s.Columns.Missing, s.Columns.Extra, s.Columns.Modified}, [3]int{1, 2, 1}},
|
||||
{"indexes", [3]int{s.Indexes.Missing, s.Indexes.Extra, s.Indexes.Modified}, [3]int{1, 1, 2}},
|
||||
{"constraints", [3]int{s.Constraints.Missing, s.Constraints.Extra, s.Constraints.Modified}, [3]int{1, 0, 1}},
|
||||
{"relationships", [3]int{s.Relationships.Missing, s.Relationships.Extra, s.Relationships.Modified}, [3]int{0, 1, 0}},
|
||||
{"views", [3]int{s.Views.Missing, s.Views.Extra, s.Views.Modified}, [3]int{1, 1, 1}},
|
||||
{"sequences", [3]int{s.Sequences.Missing, s.Sequences.Extra, s.Sequences.Modified}, [3]int{1, 2, 0}},
|
||||
{"scripts", [3]int{s.Scripts.Missing, s.Scripts.Extra, s.Scripts.Modified}, [3]int{0, 0, 1}},
|
||||
}
|
||||
for _, c := range checks {
|
||||
if c.got != c.want {
|
||||
t.Errorf("%s: got %v, want %v", c.name, c.got, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
if got := ComputeSummary(&DiffResult{}); got == nil || got.Schemas != (SchemaSummary{}) {
|
||||
t.Errorf("nil Schemas: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package diff
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
@@ -140,6 +141,46 @@ func TestCompareColumns(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompareColumns_Deterministic verifies that Missing/Extra entries are
|
||||
// always reported in the same (alphabetical) order across repeated calls,
|
||||
// instead of following Go's randomized map iteration order over the
|
||||
// source/target column maps.
|
||||
func TestCompareColumns_Deterministic(t *testing.T) {
|
||||
source := map[string]*models.Column{
|
||||
"zeta": {Name: "zeta", Type: "text"},
|
||||
"alpha": {Name: "alpha", Type: "text"},
|
||||
"mu": {Name: "mu", Type: "text"},
|
||||
}
|
||||
target := map[string]*models.Column{
|
||||
"omega": {Name: "omega", Type: "text"},
|
||||
"delta": {Name: "delta", Type: "text"},
|
||||
"charlie": {Name: "charlie", Type: "text"},
|
||||
}
|
||||
|
||||
wantMissing := []string{"alpha", "mu", "zeta"}
|
||||
wantExtra := []string{"charlie", "delta", "omega"}
|
||||
|
||||
for i := 0; i < 25; i++ {
|
||||
got := compareColumns(source, target)
|
||||
|
||||
gotMissing := make([]string, len(got.Missing))
|
||||
for j, c := range got.Missing {
|
||||
gotMissing[j] = c.Name
|
||||
}
|
||||
gotExtra := make([]string, len(got.Extra))
|
||||
for j, c := range got.Extra {
|
||||
gotExtra[j] = c.Name
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(gotMissing, wantMissing) {
|
||||
t.Fatalf("compareColumns() Missing = %v, want %v (run %d)", gotMissing, wantMissing, i)
|
||||
}
|
||||
if !reflect.DeepEqual(gotExtra, wantExtra) {
|
||||
t.Fatalf("compareColumns() Extra = %v, want %v (run %d)", gotExtra, wantExtra, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareColumnDetails(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -260,6 +301,22 @@ func TestCompareIndexes(t *testing.T) {
|
||||
return len(d.Modified) == 1 && d.Modified[0].Name == "idx_name"
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "equivalent indexes with different generated names",
|
||||
source: map[string]*models.Index{
|
||||
"uidx_posts_user_id_title": {
|
||||
Name: "uidx_posts_user_id_title", Columns: []string{"user_id", "title"}, Unique: true,
|
||||
},
|
||||
},
|
||||
target: map[string]*models.Index{
|
||||
"posts_user_id_title_idx": {
|
||||
Name: "posts_user_id_title_idx", Columns: []string{"user_id", "title"}, Unique: true, Type: "btree",
|
||||
},
|
||||
},
|
||||
want: func(d *IndexDiff) bool {
|
||||
return len(d.Missing) == 0 && len(d.Extra) == 0 && len(d.Modified) == 0
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -484,6 +541,78 @@ func TestCompareSchemas(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareScripts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source []*models.Script
|
||||
target []*models.Script
|
||||
want func(*ScriptDiff) bool
|
||||
}{
|
||||
{
|
||||
name: "identical scripts",
|
||||
source: []*models.Script{{Name: "create_users", SQL: "CREATE TABLE users (id int);", Priority: 1, Sequence: 1}},
|
||||
target: []*models.Script{{Name: "create_users", SQL: "CREATE TABLE users (id int);", Priority: 1, Sequence: 1}},
|
||||
want: func(d *ScriptDiff) bool {
|
||||
return len(d.Missing) == 0 && len(d.Extra) == 0 && len(d.Modified) == 0
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing script",
|
||||
source: []*models.Script{{Name: "create_users", SQL: "CREATE TABLE users (id int);"}},
|
||||
target: []*models.Script{},
|
||||
want: func(d *ScriptDiff) bool {
|
||||
return len(d.Missing) == 1 && d.Missing[0].Name == "create_users"
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "extra script",
|
||||
source: []*models.Script{},
|
||||
target: []*models.Script{{Name: "create_users", SQL: "CREATE TABLE users (id int);"}},
|
||||
want: func(d *ScriptDiff) bool {
|
||||
return len(d.Extra) == 1 && d.Extra[0].Name == "create_users"
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "modified script sql",
|
||||
source: []*models.Script{{Name: "create_users", SQL: "CREATE TABLE users (id int);"}},
|
||||
target: []*models.Script{{Name: "create_users", SQL: "CREATE TABLE users (id bigint);"}},
|
||||
want: func(d *ScriptDiff) bool {
|
||||
return len(d.Modified) == 1 && d.Modified[0].Name == "create_users" && d.Modified[0].Changes["sql"] != nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "different script order is different identity",
|
||||
source: []*models.Script{{Name: "create_users", SQL: "SELECT 1;", Priority: 1, Sequence: 1}},
|
||||
target: []*models.Script{{Name: "create_users", SQL: "SELECT 1;", Priority: 2, Sequence: 3}},
|
||||
want: func(d *ScriptDiff) bool {
|
||||
return len(d.Missing) == 1 && len(d.Extra) == 1 && len(d.Modified) == 0
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "same descriptive names remain distinct",
|
||||
source: []*models.Script{
|
||||
{Name: "alter_users", SQL: "SELECT 1;", Priority: 1, Sequence: 1},
|
||||
{Name: "alter_users", SQL: "SELECT 2;", Priority: 1, Sequence: 2},
|
||||
},
|
||||
target: []*models.Script{
|
||||
{Name: "alter_users", SQL: "SELECT 1;", Priority: 1, Sequence: 1},
|
||||
},
|
||||
want: func(d *ScriptDiff) bool {
|
||||
return len(d.Missing) == 1 && d.Missing[0].Sequence == 2
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := compareScripts(tt.source, tt.target)
|
||||
if !tt.want(got) {
|
||||
t.Errorf("compareScripts() result doesn't match expectations")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEmpty(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -499,6 +628,8 @@ func TestIsEmpty(t *testing.T) {
|
||||
{"TableDiff with extra", &TableDiff{Missing: []*models.Table{}, Extra: []*models.Table{{Name: "users"}}, Modified: []*TableChange{}}, false},
|
||||
{"empty ConstraintDiff", &ConstraintDiff{Missing: []*models.Constraint{}, Extra: []*models.Constraint{}, Modified: []*ConstraintChange{}}, true},
|
||||
{"empty RelationshipDiff", &RelationshipDiff{Missing: []*models.Relationship{}, Extra: []*models.Relationship{}, Modified: []*RelationshipChange{}}, true},
|
||||
{"empty ScriptDiff", &ScriptDiff{Missing: []*models.Script{}, Extra: []*models.Script{}, Modified: []*ScriptChange{}}, true},
|
||||
{"ScriptDiff with modified", &ScriptDiff{Missing: []*models.Script{}, Extra: []*models.Script{}, Modified: []*ScriptChange{{Name: "create_users"}}}, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -545,6 +676,26 @@ func TestComputeSummary(t *testing.T) {
|
||||
return s.Schemas.Missing == 1 && s.Schemas.Extra == 2 && s.Schemas.Modified == 1
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scripts with differences",
|
||||
result: &DiffResult{
|
||||
Schemas: &SchemaDiff{
|
||||
Modified: []*SchemaChange{
|
||||
{
|
||||
Name: "public",
|
||||
Scripts: &ScriptDiff{
|
||||
Missing: []*models.Script{{Name: "missing_script"}},
|
||||
Extra: []*models.Script{{Name: "extra_script"}, {Name: "seed_data"}},
|
||||
Modified: []*ScriptChange{{Name: "changed_script"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: func(s *Summary) bool {
|
||||
return s.Scripts.Missing == 1 && s.Scripts.Extra == 2 && s.Scripts.Modified == 1
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// Package diff provides utilities for comparing database schemas and identifying differences.
|
||||
//
|
||||
// # Overview
|
||||
//
|
||||
// The diff package compares two database models at various granularity levels (database,
|
||||
// schema, table, column) and produces detailed reports of differences including:
|
||||
// - Missing items (present in source but not in target)
|
||||
// - Extra items (present in target but not in source)
|
||||
// - Modified items (present in both but with different properties)
|
||||
//
|
||||
// # Usage
|
||||
//
|
||||
// Compare two databases and format the output:
|
||||
//
|
||||
// result := diff.CompareDatabases(sourceDB, targetDB)
|
||||
// err := diff.FormatDiff(result, diff.OutputFormatText, os.Stdout)
|
||||
//
|
||||
// # Output Formats
|
||||
//
|
||||
// The package supports multiple output formats:
|
||||
// - OutputFormatText: Human-readable text format
|
||||
// - OutputFormatJSON: Structured JSON output
|
||||
// - OutputFormatYAML: Structured YAML output
|
||||
//
|
||||
// # Comparison Scope
|
||||
//
|
||||
// The comparison covers:
|
||||
// - Schemas: Name, description, and contents
|
||||
// - Tables: Name, description, and all sub-elements
|
||||
// - Columns: Type, nullability, defaults, constraints
|
||||
// - Indexes: Columns, uniqueness, type
|
||||
// - Constraints: Type, columns, references
|
||||
// - Relationships: Type, from/to tables and columns
|
||||
// - Views: Definition and columns
|
||||
// - Sequences: Start value, increment, min/max values
|
||||
//
|
||||
// # Use Cases
|
||||
//
|
||||
// - Schema migration planning
|
||||
// - Database synchronization verification
|
||||
// - Change tracking and auditing
|
||||
// - CI/CD pipeline validation
|
||||
package diff
|
||||
+66
-1
@@ -158,6 +158,21 @@ func formatSummary(result *DiffResult, w io.Writer) error {
|
||||
fmt.Fprintf(w, "\n")
|
||||
}
|
||||
|
||||
// Scripts
|
||||
if summary.Scripts.Missing > 0 || summary.Scripts.Extra > 0 || summary.Scripts.Modified > 0 {
|
||||
fmt.Fprintf(w, "Scripts:\n")
|
||||
if summary.Scripts.Missing > 0 {
|
||||
fmt.Fprintf(w, " Missing: %d\n", summary.Scripts.Missing)
|
||||
}
|
||||
if summary.Scripts.Extra > 0 {
|
||||
fmt.Fprintf(w, " Extra: %d\n", summary.Scripts.Extra)
|
||||
}
|
||||
if summary.Scripts.Modified > 0 {
|
||||
fmt.Fprintf(w, " Modified: %d\n", summary.Scripts.Modified)
|
||||
}
|
||||
fmt.Fprintf(w, "\n")
|
||||
}
|
||||
|
||||
// Check if there are no differences
|
||||
if summary.Schemas.Missing == 0 && summary.Schemas.Extra == 0 && summary.Schemas.Modified == 0 &&
|
||||
summary.Tables.Missing == 0 && summary.Tables.Extra == 0 && summary.Tables.Modified == 0 &&
|
||||
@@ -166,7 +181,8 @@ func formatSummary(result *DiffResult, w io.Writer) error {
|
||||
summary.Constraints.Missing == 0 && summary.Constraints.Extra == 0 && summary.Constraints.Modified == 0 &&
|
||||
summary.Relationships.Missing == 0 && summary.Relationships.Extra == 0 && summary.Relationships.Modified == 0 &&
|
||||
summary.Views.Missing == 0 && summary.Views.Extra == 0 && summary.Views.Modified == 0 &&
|
||||
summary.Sequences.Missing == 0 && summary.Sequences.Extra == 0 && summary.Sequences.Modified == 0 {
|
||||
summary.Sequences.Missing == 0 && summary.Sequences.Extra == 0 && summary.Sequences.Modified == 0 &&
|
||||
summary.Scripts.Missing == 0 && summary.Scripts.Extra == 0 && summary.Scripts.Modified == 0 {
|
||||
fmt.Fprintf(w, "No differences found.\n")
|
||||
}
|
||||
|
||||
@@ -448,6 +464,26 @@ const htmlTemplate = `<!DOCTYPE html>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if or .Summary.Scripts.Missing .Summary.Scripts.Extra .Summary.Scripts.Modified}}
|
||||
<div class="summary-item">
|
||||
<h3>Scripts</h3>
|
||||
<div class="count-group">
|
||||
<div class="count">
|
||||
<span class="count-label">Missing</span>
|
||||
<span class="count-value missing">{{.Summary.Scripts.Missing}}</span>
|
||||
</div>
|
||||
<div class="count">
|
||||
<span class="count-label">Extra</span>
|
||||
<span class="count-value extra">{{.Summary.Scripts.Extra}}</span>
|
||||
</div>
|
||||
<div class="count">
|
||||
<span class="count-label">Modified</span>
|
||||
<span class="count-value modified">{{.Summary.Scripts.Modified}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -588,6 +624,35 @@ const htmlTemplate = `<!DOCTYPE html>
|
||||
</ul>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{if .Scripts}}
|
||||
{{if .Scripts.Missing}}
|
||||
<h4>Missing Scripts</h4>
|
||||
<ul class="item-list">
|
||||
{{range .Scripts.Missing}}
|
||||
<li class="missing">{{.Name}}</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
|
||||
{{if .Scripts.Extra}}
|
||||
<h4>Extra Scripts</h4>
|
||||
<ul class="item-list">
|
||||
{{range .Scripts.Extra}}
|
||||
<li class="extra">{{.Name}}</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
|
||||
{{if .Scripts.Modified}}
|
||||
<h4>Modified Scripts</h4>
|
||||
<ul class="item-list">
|
||||
{{range .Scripts.Modified}}
|
||||
<li class="modified">{{.Name}}</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
@@ -104,13 +104,32 @@ func TestFormatSummary(t *testing.T) {
|
||||
},
|
||||
wantStr: []string{"Tables:", "Missing: 1", "Extra: 1", "Modified: 1"},
|
||||
},
|
||||
{
|
||||
name: "with script differences",
|
||||
result: &DiffResult{
|
||||
Source: "source",
|
||||
Target: "target",
|
||||
Schemas: &SchemaDiff{
|
||||
Modified: []*SchemaChange{
|
||||
{
|
||||
Name: "public",
|
||||
Scripts: &ScriptDiff{
|
||||
Missing: []*models.Script{{Name: "create_users"}},
|
||||
Extra: []*models.Script{{Name: "seed_users"}},
|
||||
Modified: []*ScriptChange{{Name: "add_indexes"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantStr: []string{"Scripts:", "Missing: 1", "Extra: 1", "Modified: 1"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := formatSummary(tt.result, &buf)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("formatSummary() error = %v", err)
|
||||
return
|
||||
@@ -139,7 +158,6 @@ func TestFormatJSON(t *testing.T) {
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := formatJSON(result, &buf)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("formatJSON() error = %v", err)
|
||||
return
|
||||
@@ -237,13 +255,37 @@ func TestFormatHTML(t *testing.T) {
|
||||
"text",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with script modifications",
|
||||
result: &DiffResult{
|
||||
Source: "source",
|
||||
Target: "target",
|
||||
Schemas: &SchemaDiff{
|
||||
Modified: []*SchemaChange{
|
||||
{
|
||||
Name: "public",
|
||||
Scripts: &ScriptDiff{
|
||||
Missing: []*models.Script{{Name: "create_users"}},
|
||||
Extra: []*models.Script{{Name: "seed_users"}},
|
||||
Modified: []*ScriptChange{{Name: "add_indexes"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantStr: []string{
|
||||
"Scripts",
|
||||
"create_users",
|
||||
"seed_users",
|
||||
"add_indexes",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := formatHTML(tt.result, &buf)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("formatHTML() error = %v", err)
|
||||
return
|
||||
@@ -289,7 +331,6 @@ func TestFormatSummaryWithColumns(t *testing.T) {
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := formatSummary(result, &buf)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("formatSummary() error = %v", err)
|
||||
return
|
||||
@@ -338,7 +379,6 @@ func TestFormatSummaryWithIndexes(t *testing.T) {
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := formatSummary(result, &buf)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("formatSummary() error = %v", err)
|
||||
return
|
||||
@@ -380,7 +420,6 @@ func TestFormatSummaryWithConstraints(t *testing.T) {
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := formatSummary(result, &buf)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("formatSummary() error = %v", err)
|
||||
return
|
||||
@@ -403,7 +442,6 @@ func TestFormatJSONIndentation(t *testing.T) {
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := formatJSON(result, &buf)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("formatJSON() error = %v", err)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package diff
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
func TestCompareSchemaDetails_DescriptionAndOwner(t *testing.T) {
|
||||
mk := func(desc, owner string) *models.Schema {
|
||||
s := models.InitSchema("public")
|
||||
s.Description, s.Owner = desc, owner
|
||||
return s
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
src, tgt *models.Schema
|
||||
wantFields []string
|
||||
}{
|
||||
{"identical", mk("d", "o"), mk("d", "o"), nil},
|
||||
{"description", mk("a", "o"), mk("b", "o"), []string{"description"}},
|
||||
{"owner", mk("d", "x"), mk("d", "y"), []string{"owner"}},
|
||||
{"both", mk("a", "x"), mk("b", "y"), []string{"description", "owner"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := compareSchemaDetails(tt.src, tt.tgt)
|
||||
if len(tt.wantFields) == 0 {
|
||||
if got != nil {
|
||||
t.Fatalf("expected no change, got %+v", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got == nil || len(got.Changes) != len(tt.wantFields) {
|
||||
t.Fatalf("changes: %+v", got)
|
||||
}
|
||||
for _, f := range tt.wantFields {
|
||||
c, ok := got.Changes[f].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("missing %s: %+v", f, got.Changes)
|
||||
}
|
||||
if c["source"] == c["target"] {
|
||||
t.Errorf("%s source and target equal: %v", f, c)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareDatabases_SchemaAttrsCounted(t *testing.T) {
|
||||
src, tgt := models.InitDatabase("a"), models.InitDatabase("b")
|
||||
s1, s2 := models.InitSchema("public"), models.InitSchema("public")
|
||||
s1.Owner, s2.Owner = "alice", "bob"
|
||||
src.Schemas, tgt.Schemas = append(src.Schemas, s1), append(tgt.Schemas, s2)
|
||||
|
||||
res := CompareDatabases(src, tgt)
|
||||
if res.Schemas == nil || len(res.Schemas.Modified) != 1 {
|
||||
t.Fatalf("schema owner change not reported: %+v", res.Schemas)
|
||||
}
|
||||
if ComputeSummary(res).Schemas.Modified != 1 {
|
||||
t.Error("summary must count the modified schema")
|
||||
}
|
||||
}
|
||||
+28
-4
@@ -18,10 +18,12 @@ type SchemaDiff struct {
|
||||
|
||||
// SchemaChange represents changes within a schema
|
||||
type SchemaChange struct {
|
||||
Name string `json:"name"`
|
||||
Tables *TableDiff `json:"tables,omitempty"`
|
||||
Views *ViewDiff `json:"views,omitempty"`
|
||||
Sequences *SequenceDiff `json:"sequences,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Changes map[string]any `json:"changes,omitempty"` // Schema attributes that differ (description, owner), keyed by field name
|
||||
Tables *TableDiff `json:"tables,omitempty"`
|
||||
Views *ViewDiff `json:"views,omitempty"`
|
||||
Sequences *SequenceDiff `json:"sequences,omitempty"`
|
||||
Scripts *ScriptDiff `json:"scripts,omitempty"`
|
||||
}
|
||||
|
||||
// TableDiff represents differences in tables
|
||||
@@ -131,6 +133,21 @@ type SequenceChange struct {
|
||||
Changes map[string]any `json:"changes"`
|
||||
}
|
||||
|
||||
// ScriptDiff represents differences in migration scripts.
|
||||
type ScriptDiff struct {
|
||||
Missing []*models.Script `json:"missing"` // Scripts in source but not in target
|
||||
Extra []*models.Script `json:"extra"` // Scripts in target but not in source
|
||||
Modified []*ScriptChange `json:"modified"` // Scripts that exist in both but differ
|
||||
}
|
||||
|
||||
// ScriptChange represents a modified migration script.
|
||||
type ScriptChange struct {
|
||||
Name string `json:"name"`
|
||||
Source *models.Script `json:"source"`
|
||||
Target *models.Script `json:"target"`
|
||||
Changes map[string]any `json:"changes"`
|
||||
}
|
||||
|
||||
// Summary provides counts for quick overview
|
||||
type Summary struct {
|
||||
Schemas SchemaSummary `json:"schemas"`
|
||||
@@ -141,6 +158,7 @@ type Summary struct {
|
||||
Relationships RelationshipSummary `json:"relationships"`
|
||||
Views ViewSummary `json:"views"`
|
||||
Sequences SequenceSummary `json:"sequences"`
|
||||
Scripts ScriptSummary `json:"scripts"`
|
||||
}
|
||||
|
||||
type SchemaSummary struct {
|
||||
@@ -190,3 +208,9 @@ type SequenceSummary struct {
|
||||
Extra int `json:"extra"`
|
||||
Modified int `json:"modified"`
|
||||
}
|
||||
|
||||
type ScriptSummary struct {
|
||||
Missing int `json:"missing"`
|
||||
Extra int `json:"extra"`
|
||||
Modified int `json:"modified"`
|
||||
}
|
||||
|
||||
@@ -123,6 +123,7 @@ rules:
|
||||
| `missing_primary_key` | `have_primary_key` | Ensure tables have primary keys |
|
||||
| `orphaned_foreign_key` | `orphaned_foreign_key` | Detect FKs referencing non-existent tables |
|
||||
| `circular_dependency` | `circular_dependency` | Detect circular FK dependencies |
|
||||
| `duplicate_index_name` | `duplicate_index_name` | Index / PK / unique names must be unique per schema (default: `enforce`) |
|
||||
|
||||
## Rule Configuration
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// Package inspector provides database introspection capabilities for live databases.
|
||||
//
|
||||
// # Overview
|
||||
//
|
||||
// The inspector package contains utilities for connecting to live databases and
|
||||
// extracting their schema information through system catalog queries and metadata
|
||||
// inspection.
|
||||
//
|
||||
// # Features
|
||||
//
|
||||
// - Database connection management
|
||||
// - Schema metadata extraction
|
||||
// - Table structure analysis
|
||||
// - Constraint and index discovery
|
||||
// - Foreign key relationship mapping
|
||||
//
|
||||
// # Supported Databases
|
||||
//
|
||||
// - PostgreSQL (via pgx driver)
|
||||
// - SQLite (via modernc.org/sqlite driver)
|
||||
//
|
||||
// # Usage
|
||||
//
|
||||
// This package is used internally by database readers (pgsql, sqlite) to perform
|
||||
// live schema introspection:
|
||||
//
|
||||
// inspector := inspector.NewPostgreSQLInspector(connString)
|
||||
// schemas, err := inspector.GetSchemas()
|
||||
// tables, err := inspector.GetTables(schemaName)
|
||||
//
|
||||
// # Architecture
|
||||
//
|
||||
// Each database type has its own inspector implementation that understands the
|
||||
// specific system catalogs and metadata structures of that database system.
|
||||
//
|
||||
// # Security
|
||||
//
|
||||
// Inspectors use read-only operations and never modify database structure.
|
||||
// Connection credentials should be handled securely.
|
||||
package inspector
|
||||
@@ -2,6 +2,7 @@ package inspector
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
@@ -54,8 +55,15 @@ func NewInspector(db *models.Database, config *Config) *Inspector {
|
||||
func (i *Inspector) Inspect() (*InspectorReport, error) {
|
||||
results := []ValidationResult{}
|
||||
|
||||
// Run all enabled validators
|
||||
for ruleName, rule := range i.config.Rules {
|
||||
// Run all enabled validators in deterministic (alphabetical) rule-name order
|
||||
ruleNames := make([]string, 0, len(i.config.Rules))
|
||||
for ruleName := range i.config.Rules {
|
||||
ruleNames = append(ruleNames, ruleName)
|
||||
}
|
||||
sort.Strings(ruleNames)
|
||||
|
||||
for _, ruleName := range ruleNames {
|
||||
rule := i.config.Rules[ruleName]
|
||||
if !rule.IsEnabled() {
|
||||
continue
|
||||
}
|
||||
@@ -153,6 +161,7 @@ func getValidator(functionName string) (validatorFunc, bool) {
|
||||
"have_primary_key": validateMissingPrimaryKey,
|
||||
"orphaned_foreign_key": validateOrphanedForeignKey,
|
||||
"circular_dependency": validateCircularDependency,
|
||||
"duplicate_index_name": validateDuplicateIndexName,
|
||||
}
|
||||
|
||||
fn, exists := validators[functionName]
|
||||
@@ -160,7 +169,7 @@ func getValidator(functionName string) (validatorFunc, bool) {
|
||||
}
|
||||
|
||||
// createResult is a helper to create a validation result
|
||||
func createResult(ruleName string, passed bool, message string, location string, context map[string]interface{}) ValidationResult {
|
||||
func createResult(ruleName string, passed bool, message, location string, context map[string]interface{}) ValidationResult {
|
||||
return ValidationResult{
|
||||
RuleName: ruleName,
|
||||
Message: message,
|
||||
|
||||
@@ -29,7 +29,6 @@ func TestInspect(t *testing.T) {
|
||||
|
||||
inspector := NewInspector(db, config)
|
||||
report, err := inspector.Inspect()
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Inspect() returned error: %v", err)
|
||||
}
|
||||
@@ -51,6 +50,45 @@ func TestInspect(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestInspect_Deterministic verifies that repeated Inspect() calls against
|
||||
// the same database and config produce violations in the same order, instead
|
||||
// of following Go's randomized map iteration order over config.Rules and the
|
||||
// per-table Columns/Constraints/Indexes maps.
|
||||
func TestInspect_Deterministic(t *testing.T) {
|
||||
db := createTestDatabase()
|
||||
config := GetDefaultConfig()
|
||||
|
||||
inspector := NewInspector(db, config)
|
||||
|
||||
first, err := inspector.Inspect()
|
||||
if err != nil {
|
||||
t.Fatalf("Inspect() returned error: %v", err)
|
||||
}
|
||||
|
||||
wantOrder := make([]string, len(first.Violations))
|
||||
for i, v := range first.Violations {
|
||||
wantOrder[i] = v.RuleName + "|" + v.Location
|
||||
}
|
||||
|
||||
for i := 0; i < 25; i++ {
|
||||
report, err := inspector.Inspect()
|
||||
if err != nil {
|
||||
t.Fatalf("Inspect() returned error on run %d: %v", i, err)
|
||||
}
|
||||
|
||||
if len(report.Violations) != len(wantOrder) {
|
||||
t.Fatalf("run %d: got %d violations, want %d", i, len(report.Violations), len(wantOrder))
|
||||
}
|
||||
|
||||
for j, v := range report.Violations {
|
||||
got := v.RuleName + "|" + v.Location
|
||||
if got != wantOrder[j] {
|
||||
t.Fatalf("run %d: violation[%d] = %q, want %q", i, j, got, wantOrder[j])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectWithDisabledRules(t *testing.T) {
|
||||
db := createTestDatabase()
|
||||
config := GetDefaultConfig()
|
||||
@@ -64,7 +102,6 @@ func TestInspectWithDisabledRules(t *testing.T) {
|
||||
|
||||
inspector := NewInspector(db, config)
|
||||
report, err := inspector.Inspect()
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Inspect() with disabled rules returned error: %v", err)
|
||||
}
|
||||
@@ -96,7 +133,6 @@ func TestInspectWithEnforcedRules(t *testing.T) {
|
||||
|
||||
inspector := NewInspector(db, config)
|
||||
report, err := inspector.Inspect()
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Inspect() returned error: %v", err)
|
||||
}
|
||||
|
||||
+14
-7
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -60,19 +61,19 @@ func (f *MarkdownFormatter) Format(report *InspectorReport) (string, error) {
|
||||
// Summary
|
||||
sb.WriteString(f.formatHeader("Summary"))
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(fmt.Sprintf("- Rules Checked: %d\n", report.Summary.RulesChecked))
|
||||
fmt.Fprintf(&sb, "- Rules Checked: %d\n", report.Summary.RulesChecked)
|
||||
|
||||
// Color-code error and warning counts
|
||||
if report.Summary.ErrorCount > 0 {
|
||||
sb.WriteString(f.colorize(fmt.Sprintf("- Errors: %d\n", report.Summary.ErrorCount), colorRed))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("- Errors: %d\n", report.Summary.ErrorCount))
|
||||
fmt.Fprintf(&sb, "- Errors: %d\n", report.Summary.ErrorCount)
|
||||
}
|
||||
|
||||
if report.Summary.WarningCount > 0 {
|
||||
sb.WriteString(f.colorize(fmt.Sprintf("- Warnings: %d\n", report.Summary.WarningCount), colorYellow))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("- Warnings: %d\n", report.Summary.WarningCount))
|
||||
fmt.Fprintf(&sb, "- Warnings: %d\n", report.Summary.WarningCount)
|
||||
}
|
||||
|
||||
if report.Summary.PassedCount > 0 {
|
||||
@@ -140,7 +141,7 @@ func (f *MarkdownFormatter) formatHeader(text string) string {
|
||||
return f.formatBold("# " + text)
|
||||
}
|
||||
|
||||
func (f *MarkdownFormatter) formatSubheader(text string, color string) string {
|
||||
func (f *MarkdownFormatter) formatSubheader(text, color string) string {
|
||||
header := "### " + text
|
||||
if f.UseColors {
|
||||
return color + colorBold + header + colorReset
|
||||
@@ -155,7 +156,7 @@ func (f *MarkdownFormatter) formatBold(text string) string {
|
||||
return "**" + text + "**"
|
||||
}
|
||||
|
||||
func (f *MarkdownFormatter) colorize(text string, color string) string {
|
||||
func (f *MarkdownFormatter) colorize(text, color string) string {
|
||||
if f.UseColors {
|
||||
return color + text + colorReset
|
||||
}
|
||||
@@ -199,12 +200,18 @@ func (f *MarkdownFormatter) formatContext(context map[string]interface{}) string
|
||||
"column": true,
|
||||
}
|
||||
|
||||
for key, value := range context {
|
||||
keys := make([]string, 0, len(context))
|
||||
for key := range context {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
for _, key := range keys {
|
||||
if skipKeys[key] {
|
||||
continue
|
||||
}
|
||||
|
||||
parts = append(parts, fmt.Sprintf("%s=%v", key, value))
|
||||
parts = append(parts, fmt.Sprintf("%s=%v", key, context[key]))
|
||||
}
|
||||
|
||||
return strings.Join(parts, ", ")
|
||||
|
||||
@@ -154,6 +154,11 @@ func GetDefaultConfig() *Config {
|
||||
Function: "circular_dependency",
|
||||
Message: "Circular foreign key dependency detected",
|
||||
},
|
||||
"duplicate_index_name": {
|
||||
Enabled: "enforce",
|
||||
Function: "duplicate_index_name",
|
||||
Message: "Index name is reused within the schema; PostgreSQL skips the duplicate CREATE INDEX IF NOT EXISTS",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user