Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 389fff2b44 | |||
| f331ba2b61 | |||
| f4b8fc5382 | |||
| dc9172cc7c | |||
| ee88c07989 | |||
| ff1180524a |
@@ -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.
|
||||
327
.gitea/workflows/release.yml
Normal file
327
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,327 @@
|
||||
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: Test
|
||||
run: go test ./...
|
||||
|
||||
- name: Lint
|
||||
run: go vet ./...
|
||||
|
||||
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 }}"
|
||||
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 "-X git.warky.dev/wdevs/relspecgo/cmd/relspec.version=${VERSION}" \
|
||||
-o "$NAME" ./cmd/relspec
|
||||
echo "Built $NAME"
|
||||
done
|
||||
|
||||
- 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}"
|
||||
|
||||
for GOARCH in amd64 arm64; do
|
||||
GOOS=linux GOARCH=$GOARCH go build \
|
||||
-trimpath \
|
||||
-ldflags "-X git.warky.dev/wdevs/relspecgo/cmd/relspec.version=${PKGVER}" \
|
||||
-o relspec ./cmd/relspec
|
||||
|
||||
PKGDIR="relspec_${PKGVER}_${GOARCH}"
|
||||
mkdir -p "${PKGDIR}/DEBIAN"
|
||||
mkdir -p "${PKGDIR}/usr/bin"
|
||||
|
||||
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
|
||||
dnf install -y 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 }}
|
||||
117
.github/workflows/release.yml
vendored
117
.github/workflows/release.yml
vendored
@@ -1,117 +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 "BUILD_DATE=$(date -u '+%Y-%m-%d %H:%M:%S UTC')" >> $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 }}' -X 'main.buildDate=${{ steps.get_version.outputs.BUILD_DATE }}'" ./cmd/relspec
|
||||
|
||||
# Linux ARM64
|
||||
GOOS=linux GOARCH=arm64 go build -o dist/relspec-linux-arm64 -ldflags "-X 'main.version=${{ steps.get_version.outputs.VERSION }}' -X 'main.buildDate=${{ steps.get_version.outputs.BUILD_DATE }}'" ./cmd/relspec
|
||||
|
||||
# macOS AMD64
|
||||
GOOS=darwin GOARCH=amd64 go build -o dist/relspec-darwin-amd64 -ldflags "-X 'main.version=${{ steps.get_version.outputs.VERSION }}' -X 'main.buildDate=${{ steps.get_version.outputs.BUILD_DATE }}'" ./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 }}' -X 'main.buildDate=${{ steps.get_version.outputs.BUILD_DATE }}'" ./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 }}' -X 'main.buildDate=${{ steps.get_version.outputs.BUILD_DATE }}'" ./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)"
|
||||
39
Makefile
39
Makefile
@@ -204,30 +204,21 @@ 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: ## Auto-increment patch version, update package files, commit, tag, and push
|
||||
@CURRENT=$$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0"); \
|
||||
MAJOR=$$(echo $$CURRENT | sed 's/v\([0-9]*\)\.\([0-9]*\)\.\([0-9]*\).*/\1/'); \
|
||||
MINOR=$$(echo $$CURRENT | sed 's/v\([0-9]*\)\.\([0-9]*\)\.\([0-9]*\).*/\2/'); \
|
||||
PATCH=$$(echo $$CURRENT | sed 's/v\([0-9]*\)\.\([0-9]*\)\.\([0-9]*\).*/\3/'); \
|
||||
NEXT="v$$MAJOR.$$MINOR.$$((PATCH + 1))"; \
|
||||
PKGVER="$$MAJOR.$$MINOR.$$((PATCH + 1))"; \
|
||||
echo "Current: $$CURRENT → Next: $$NEXT"; \
|
||||
sed -i "s/^pkgver=.*/pkgver=$$PKGVER/" linux/arch/PKGBUILD; \
|
||||
sed -i "s/^Version:.*/Version: $$PKGVER/" linux/centos/relspec.spec; \
|
||||
git add 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"
|
||||
|
||||
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}'
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"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"
|
||||
@@ -45,6 +46,7 @@ var (
|
||||
convertSourceType string
|
||||
convertSourcePath string
|
||||
convertSourceConn string
|
||||
convertFromList []string
|
||||
convertTargetType string
|
||||
convertTargetPath string
|
||||
convertPackageName string
|
||||
@@ -166,6 +168,7 @@ func init() {
|
||||
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 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)")
|
||||
@@ -191,17 +194,29 @@ func runConvert(cmd *cobra.Command, args []string) 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)
|
||||
}
|
||||
@@ -237,6 +252,30 @@ func runConvert(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
|
||||
183
cmd/relspec/convert_from_list_test.go
Normal file
183
cmd/relspec/convert_from_list_test.go
Normal file
@@ -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, 0755); 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, 0755); 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, 0755); 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")
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ var (
|
||||
mergeSourceType string
|
||||
mergeSourcePath string
|
||||
mergeSourceConn string
|
||||
mergeFromList []string
|
||||
mergeOutputType string
|
||||
mergeOutputPath string
|
||||
mergeOutputConn string
|
||||
@@ -109,8 +110,9 @@ 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")
|
||||
@@ -144,6 +146,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 == "" {
|
||||
@@ -157,8 +164,8 @@ func runMerge(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
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 == "" {
|
||||
@@ -189,19 +196,36 @@ 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
|
||||
// Step 2: Read source database(s)
|
||||
fmt.Fprintf(os.Stderr, "\n[2/3] 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)
|
||||
|
||||
162
cmd/relspec/merge_from_list_test.go
Normal file
162
cmd/relspec/merge_from_list_test.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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, 0755); 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")
|
||||
}
|
||||
}
|
||||
@@ -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,9 +97,20 @@ 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)
|
||||
}
|
||||
|
||||
134
cmd/relspec/templ_from_list_test.go
Normal file
134
cmd/relspec/templ_from_list_test.go
Normal file
@@ -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, 0644); 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, 0755); 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")
|
||||
}
|
||||
}
|
||||
219
cmd/relspec/testhelpers_test.go
Normal file
219
cmd/relspec/testhelpers_test.go
Normal file
@@ -0,0 +1,219 @@
|
||||
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, 0644); 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
|
||||
}
|
||||
35
linux/arch/PKGBUILD
Normal file
35
linux/arch/PKGBUILD
Normal file
@@ -0,0 +1,35 @@
|
||||
# Maintainer: Hein (Warky Devs) <hein@warky.dev>
|
||||
pkgname=relspec
|
||||
pkgver=1.0.43
|
||||
pkgrel=1
|
||||
pkgdesc="Database schema conversion and analysis tool"
|
||||
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/cmd/relspec.version=$pkgver" \
|
||||
-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"
|
||||
}
|
||||
43
linux/centos/relspec.spec
Normal file
43
linux/centos/relspec.spec
Normal file
@@ -0,0 +1,43 @@
|
||||
Name: relspec
|
||||
Version: 1.0.43
|
||||
Release: 1%{?dist}
|
||||
Summary: Database schema conversion and analysis tool
|
||||
|
||||
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/cmd/relspec.version=%{version}" \
|
||||
-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
|
||||
11
linux/debian/control
Normal file
11
linux/debian/control
Normal file
@@ -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.
|
||||
@@ -60,19 +60,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 {
|
||||
|
||||
@@ -676,8 +676,19 @@ func (r *Reader) extractTableFromGormTag(tag string) (tablename string, schemaNa
|
||||
|
||||
// deriveTableName derives a table name from struct name
|
||||
func (r *Reader) deriveTableName(structName string) string {
|
||||
// Remove "Model" prefix if present, use the name as-is without transformation
|
||||
return strings.TrimPrefix(structName, "Model")
|
||||
// Remove "Model" prefix if present
|
||||
name := strings.TrimPrefix(structName, "Model")
|
||||
|
||||
// Convert PascalCase to snake_case
|
||||
var result strings.Builder
|
||||
for i, r := range name {
|
||||
if i > 0 && r >= 'A' && r <= 'Z' {
|
||||
result.WriteRune('_')
|
||||
}
|
||||
result.WriteRune(r)
|
||||
}
|
||||
|
||||
return strings.ToLower(result.String())
|
||||
}
|
||||
|
||||
// parseColumn parses a struct field into a Column model
|
||||
|
||||
@@ -216,6 +216,21 @@ func resolveFieldNameCollision(fieldName string) string {
|
||||
return fieldName
|
||||
}
|
||||
|
||||
// sortConstraints sorts constraints by sequence, then by name
|
||||
func sortConstraints(constraints map[string]*models.Constraint) []*models.Constraint {
|
||||
result := make([]*models.Constraint, 0, len(constraints))
|
||||
for _, c := range constraints {
|
||||
result = append(result, c)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
if result[i].Sequence > 0 && result[j].Sequence > 0 {
|
||||
return result[i].Sequence < result[j].Sequence
|
||||
}
|
||||
return result[i].Name < result[j].Name
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// sortColumns sorts columns by sequence, then by name
|
||||
func sortColumns(columns map[string]*models.Column) []*models.Column {
|
||||
result := make([]*models.Column, 0, len(columns))
|
||||
|
||||
@@ -242,7 +242,7 @@ func (w *Writer) addRelationshipFields(modelData *ModelData, table *models.Table
|
||||
usedFieldNames := make(map[string]int)
|
||||
|
||||
// For each foreign key in this table, add a belongs-to/has-one relationship
|
||||
for _, constraint := range table.Constraints {
|
||||
for _, constraint := range sortConstraints(table.Constraints) {
|
||||
if constraint.Type != models.ForeignKeyConstraint {
|
||||
continue
|
||||
}
|
||||
@@ -275,7 +275,7 @@ func (w *Writer) addRelationshipFields(modelData *ModelData, table *models.Table
|
||||
continue // Skip self
|
||||
}
|
||||
|
||||
for _, constraint := range otherTable.Constraints {
|
||||
for _, constraint := range sortConstraints(otherTable.Constraints) {
|
||||
if constraint.Type != models.ForeignKeyConstraint {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -62,10 +62,10 @@ func (w *Writer) databaseToDBML(d *models.Database) string {
|
||||
var sb strings.Builder
|
||||
|
||||
if d.Description != "" {
|
||||
sb.WriteString(fmt.Sprintf("// %s\n", d.Description))
|
||||
fmt.Fprintf(&sb, "// %s\n", d.Description)
|
||||
}
|
||||
if d.Comment != "" {
|
||||
sb.WriteString(fmt.Sprintf("// %s\n", d.Comment))
|
||||
fmt.Fprintf(&sb, "// %s\n", d.Comment)
|
||||
}
|
||||
if d.Description != "" || d.Comment != "" {
|
||||
sb.WriteString("\n")
|
||||
@@ -94,7 +94,7 @@ func (w *Writer) schemaToDBML(schema *models.Schema) string {
|
||||
var sb strings.Builder
|
||||
|
||||
if schema.Description != "" {
|
||||
sb.WriteString(fmt.Sprintf("// Schema: %s - %s\n", schema.Name, schema.Description))
|
||||
fmt.Fprintf(&sb, "// Schema: %s - %s\n", schema.Name, schema.Description)
|
||||
}
|
||||
|
||||
for _, table := range schema.Tables {
|
||||
@@ -110,10 +110,10 @@ func (w *Writer) tableToDBML(t *models.Table) string {
|
||||
var sb strings.Builder
|
||||
|
||||
tableName := fmt.Sprintf("%s.%s", t.Schema, t.Name)
|
||||
sb.WriteString(fmt.Sprintf("Table %s {\n", tableName))
|
||||
fmt.Fprintf(&sb, "Table %s {\n", tableName)
|
||||
|
||||
for _, column := range t.Columns {
|
||||
sb.WriteString(fmt.Sprintf(" %s %s", column.Name, column.Type))
|
||||
fmt.Fprintf(&sb, " %s %s", column.Name, column.Type)
|
||||
|
||||
var attrs []string
|
||||
if column.IsPrimaryKey {
|
||||
@@ -138,11 +138,11 @@ func (w *Writer) tableToDBML(t *models.Table) string {
|
||||
}
|
||||
|
||||
if len(attrs) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" [%s]", strings.Join(attrs, ", ")))
|
||||
fmt.Fprintf(&sb, " [%s]", strings.Join(attrs, ", "))
|
||||
}
|
||||
|
||||
if column.Comment != "" {
|
||||
sb.WriteString(fmt.Sprintf(" // %s", column.Comment))
|
||||
fmt.Fprintf(&sb, " // %s", column.Comment)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
@@ -161,9 +161,9 @@ func (w *Writer) tableToDBML(t *models.Table) string {
|
||||
indexAttrs = append(indexAttrs, fmt.Sprintf("type: %s", index.Type))
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf(" (%s)", strings.Join(index.Columns, ", ")))
|
||||
fmt.Fprintf(&sb, " (%s)", strings.Join(index.Columns, ", "))
|
||||
if len(indexAttrs) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" [%s]", strings.Join(indexAttrs, ", ")))
|
||||
fmt.Fprintf(&sb, " [%s]", strings.Join(indexAttrs, ", "))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
@@ -172,7 +172,7 @@ func (w *Writer) tableToDBML(t *models.Table) string {
|
||||
|
||||
note := strings.TrimSpace(t.Description + " " + t.Comment)
|
||||
if note != "" {
|
||||
sb.WriteString(fmt.Sprintf("\n Note: '%s'\n", note))
|
||||
fmt.Fprintf(&sb, "\n Note: '%s'\n", note)
|
||||
}
|
||||
|
||||
sb.WriteString("}\n")
|
||||
|
||||
@@ -213,6 +213,21 @@ func resolveFieldNameCollision(fieldName string) string {
|
||||
return fieldName
|
||||
}
|
||||
|
||||
// sortConstraints sorts constraints by sequence, then by name
|
||||
func sortConstraints(constraints map[string]*models.Constraint) []*models.Constraint {
|
||||
result := make([]*models.Constraint, 0, len(constraints))
|
||||
for _, c := range constraints {
|
||||
result = append(result, c)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
if result[i].Sequence > 0 && result[j].Sequence > 0 {
|
||||
return result[i].Sequence < result[j].Sequence
|
||||
}
|
||||
return result[i].Name < result[j].Name
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// sortColumns sorts columns by sequence, then by name
|
||||
func sortColumns(columns map[string]*models.Column) []*models.Column {
|
||||
result := make([]*models.Column, 0, len(columns))
|
||||
|
||||
@@ -236,7 +236,7 @@ func (w *Writer) addRelationshipFields(modelData *ModelData, table *models.Table
|
||||
usedFieldNames := make(map[string]int)
|
||||
|
||||
// For each foreign key in this table, add a belongs-to relationship
|
||||
for _, constraint := range table.Constraints {
|
||||
for _, constraint := range sortConstraints(table.Constraints) {
|
||||
if constraint.Type != models.ForeignKeyConstraint {
|
||||
continue
|
||||
}
|
||||
@@ -269,7 +269,7 @@ func (w *Writer) addRelationshipFields(modelData *ModelData, table *models.Table
|
||||
continue // Skip self
|
||||
}
|
||||
|
||||
for _, constraint := range otherTable.Constraints {
|
||||
for _, constraint := range sortConstraints(otherTable.Constraints) {
|
||||
if constraint.Type != models.ForeignKeyConstraint {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ func (w *Writer) databaseToGraphQL(db *models.Database) string {
|
||||
if w.shouldIncludeComments() {
|
||||
sb.WriteString("# Generated GraphQL Schema\n")
|
||||
if db.Name != "" {
|
||||
sb.WriteString(fmt.Sprintf("# Database: %s\n", db.Name))
|
||||
fmt.Fprintf(&sb, "# Database: %s\n", db.Name)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
@@ -62,7 +62,7 @@ func (w *Writer) databaseToGraphQL(db *models.Database) string {
|
||||
scalars := w.collectCustomScalars(db)
|
||||
if len(scalars) > 0 {
|
||||
for _, scalar := range scalars {
|
||||
sb.WriteString(fmt.Sprintf("scalar %s\n", scalar))
|
||||
fmt.Fprintf(&sb, "scalar %s\n", scalar)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
@@ -176,9 +176,9 @@ func (w *Writer) isJoinTable(table *models.Table) bool {
|
||||
func (w *Writer) enumToGraphQL(enum *models.Enum) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("enum %s {\n", enum.Name))
|
||||
fmt.Fprintf(&sb, "enum %s {\n", enum.Name)
|
||||
for _, value := range enum.Values {
|
||||
sb.WriteString(fmt.Sprintf(" %s\n", value))
|
||||
fmt.Fprintf(&sb, " %s\n", value)
|
||||
}
|
||||
sb.WriteString("}\n")
|
||||
|
||||
@@ -197,10 +197,10 @@ func (w *Writer) tableToGraphQL(table *models.Table, db *models.Database, schema
|
||||
if desc == "" {
|
||||
desc = table.Comment
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("# %s\n", desc))
|
||||
fmt.Fprintf(&sb, "# %s\n", desc)
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("type %s {\n", typeName))
|
||||
fmt.Fprintf(&sb, "type %s {\n", typeName)
|
||||
|
||||
// Collect and categorize fields
|
||||
var idFields, scalarFields, relationFields []string
|
||||
|
||||
@@ -125,9 +125,9 @@ func (w *Writer) generateGenerator() string {
|
||||
func (w *Writer) enumToPrisma(enum *models.Enum) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("enum %s {\n", enum.Name))
|
||||
fmt.Fprintf(&sb, "enum %s {\n", enum.Name)
|
||||
for _, value := range enum.Values {
|
||||
sb.WriteString(fmt.Sprintf(" %s\n", value))
|
||||
fmt.Fprintf(&sb, " %s\n", value)
|
||||
}
|
||||
sb.WriteString("}\n")
|
||||
|
||||
@@ -179,7 +179,7 @@ func (w *Writer) identifyJoinTables(schema *models.Schema) map[string]bool {
|
||||
func (w *Writer) tableToPrisma(table *models.Table, schema *models.Schema, joinTables map[string]bool) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("model %s {\n", table.Name))
|
||||
fmt.Fprintf(&sb, "model %s {\n", table.Name)
|
||||
|
||||
// Collect columns to write
|
||||
columns := make([]*models.Column, 0, len(table.Columns))
|
||||
@@ -219,11 +219,11 @@ func (w *Writer) columnToField(col *models.Column, table *models.Table, schema *
|
||||
var sb strings.Builder
|
||||
|
||||
// Field name
|
||||
sb.WriteString(fmt.Sprintf(" %s", col.Name))
|
||||
fmt.Fprintf(&sb, " %s", col.Name)
|
||||
|
||||
// Field type
|
||||
prismaType := w.sqlTypeToPrisma(col.Type, schema)
|
||||
sb.WriteString(fmt.Sprintf(" %s", prismaType))
|
||||
fmt.Fprintf(&sb, " %s", prismaType)
|
||||
|
||||
// Optional modifier
|
||||
if !col.NotNull && !col.IsPrimaryKey {
|
||||
@@ -413,7 +413,7 @@ func (w *Writer) generateRelationFields(table *models.Table, schema *models.Sche
|
||||
relationName = relationName[:len(relationName)-1]
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf(" %s %s", strings.ToLower(relationName), relationType))
|
||||
fmt.Fprintf(&sb, " %s %s", strings.ToLower(relationName), relationType)
|
||||
|
||||
if isOptional {
|
||||
sb.WriteString("?")
|
||||
@@ -479,8 +479,8 @@ func (w *Writer) generateInverseRelations(table *models.Table, schema *models.Sc
|
||||
if fk.ReferencedTable != table.Name {
|
||||
// This is the other side
|
||||
otherSide := fk.ReferencedTable
|
||||
sb.WriteString(fmt.Sprintf(" %ss %s[]\n",
|
||||
strings.ToLower(otherSide), otherSide))
|
||||
fmt.Fprintf(&sb, " %ss %s[]\n",
|
||||
strings.ToLower(otherSide), otherSide)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -497,8 +497,8 @@ func (w *Writer) generateInverseRelations(table *models.Table, schema *models.Sc
|
||||
pluralName += "s"
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf(" %s %s[]\n",
|
||||
strings.ToLower(pluralName), otherTable.Name))
|
||||
fmt.Fprintf(&sb, " %s %s[]\n",
|
||||
strings.ToLower(pluralName), otherTable.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -530,20 +530,20 @@ func (w *Writer) generateBlockAttributes(table *models.Table) string {
|
||||
|
||||
if len(pkCols) > 1 {
|
||||
sort.Strings(pkCols)
|
||||
sb.WriteString(fmt.Sprintf(" @@id([%s])\n", strings.Join(pkCols, ", ")))
|
||||
fmt.Fprintf(&sb, " @@id([%s])\n", strings.Join(pkCols, ", "))
|
||||
}
|
||||
|
||||
// @@unique for multi-column unique constraints
|
||||
for _, constraint := range table.Constraints {
|
||||
if constraint.Type == models.UniqueConstraint && len(constraint.Columns) > 1 {
|
||||
sb.WriteString(fmt.Sprintf(" @@unique([%s])\n", strings.Join(constraint.Columns, ", ")))
|
||||
fmt.Fprintf(&sb, " @@unique([%s])\n", strings.Join(constraint.Columns, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
// @@index for indexes
|
||||
for _, index := range table.Indexes {
|
||||
if !index.Unique { // Unique indexes are handled by @@unique
|
||||
sb.WriteString(fmt.Sprintf(" @@index([%s])\n", strings.Join(index.Columns, ", ")))
|
||||
fmt.Fprintf(&sb, " @@index([%s])\n", strings.Join(index.Columns, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -207,7 +207,7 @@ func (w *Writer) tableToEntity(table *models.Table, schema *models.Schema, joinT
|
||||
|
||||
// Generate @Entity decorator with options
|
||||
entityOptions := w.buildEntityOptions(table)
|
||||
sb.WriteString(fmt.Sprintf("@Entity({\n%s\n})\n", entityOptions))
|
||||
fmt.Fprintf(&sb, "@Entity({\n%s\n})\n", entityOptions)
|
||||
|
||||
// Get class name (from metadata if different from table name)
|
||||
className := table.Name
|
||||
@@ -219,7 +219,7 @@ func (w *Writer) tableToEntity(table *models.Table, schema *models.Schema, joinT
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("export class %s {\n", className))
|
||||
fmt.Fprintf(&sb, "export class %s {\n", className)
|
||||
|
||||
// Collect and sort columns
|
||||
columns := make([]*models.Column, 0, len(table.Columns))
|
||||
@@ -272,7 +272,7 @@ func (w *Writer) viewToEntity(view *models.View) string {
|
||||
sb.WriteString("})\n")
|
||||
|
||||
// Generate class
|
||||
sb.WriteString(fmt.Sprintf("export class %s {\n", view.Name))
|
||||
fmt.Fprintf(&sb, "export class %s {\n", view.Name)
|
||||
|
||||
// Generate field definitions (without decorators for view fields)
|
||||
columns := make([]*models.Column, 0, len(view.Columns))
|
||||
@@ -285,7 +285,7 @@ func (w *Writer) viewToEntity(view *models.View) string {
|
||||
|
||||
for _, col := range columns {
|
||||
tsType := w.sqlTypeToTypeScript(col.Type)
|
||||
sb.WriteString(fmt.Sprintf(" %s: %s;\n", col.Name, tsType))
|
||||
fmt.Fprintf(&sb, " %s: %s;\n", col.Name, tsType)
|
||||
}
|
||||
|
||||
sb.WriteString("}\n")
|
||||
@@ -314,7 +314,7 @@ func (w *Writer) columnToField(col *models.Column, table *models.Table) string {
|
||||
// Regular @Column decorator
|
||||
options := w.buildColumnOptions(col, table)
|
||||
if options != "" {
|
||||
sb.WriteString(fmt.Sprintf(" @Column({ %s })\n", options))
|
||||
fmt.Fprintf(&sb, " @Column({ %s })\n", options)
|
||||
} else {
|
||||
sb.WriteString(" @Column()\n")
|
||||
}
|
||||
@@ -327,7 +327,7 @@ func (w *Writer) columnToField(col *models.Column, table *models.Table) string {
|
||||
nullable = " | null"
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf(" %s: %s%s;", col.Name, tsType, nullable))
|
||||
fmt.Fprintf(&sb, " %s: %s%s;", col.Name, tsType, nullable)
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
@@ -464,17 +464,17 @@ func (w *Writer) generateRelationFields(table *models.Table, schema *models.Sche
|
||||
inverseField := w.findInverseFieldName(table.Name, relatedTable, schema)
|
||||
|
||||
if inverseField != "" {
|
||||
sb.WriteString(fmt.Sprintf(" @ManyToOne(() => %s, %s => %s.%s)\n",
|
||||
relatedTable, strings.ToLower(relatedTable), strings.ToLower(relatedTable), inverseField))
|
||||
fmt.Fprintf(&sb, " @ManyToOne(() => %s, %s => %s.%s)\n",
|
||||
relatedTable, strings.ToLower(relatedTable), strings.ToLower(relatedTable), inverseField)
|
||||
} else {
|
||||
if isNullable {
|
||||
sb.WriteString(fmt.Sprintf(" @ManyToOne(() => %s, { nullable: true })\n", relatedTable))
|
||||
fmt.Fprintf(&sb, " @ManyToOne(() => %s, { nullable: true })\n", relatedTable)
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf(" @ManyToOne(() => %s)\n", relatedTable))
|
||||
fmt.Fprintf(&sb, " @ManyToOne(() => %s)\n", relatedTable)
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf(" %s: %s%s;\n", fieldName, relatedTable, nullable))
|
||||
fmt.Fprintf(&sb, " %s: %s%s;\n", fieldName, relatedTable, nullable)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user