Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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,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 }}
|
||||
@@ -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)"
|
||||
@@ -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
|
||||
|
||||
@@ -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: 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 godoc
|
||||
|
||||
# Binary name
|
||||
BINARY_NAME=relspec
|
||||
@@ -14,6 +14,11 @@ GOGET=$(GOCMD) get
|
||||
GOMOD=$(GOCMD) mod
|
||||
GOCLEAN=$(GOCMD) clean
|
||||
|
||||
# Version information
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
BUILD_DATE := $(shell date -u +"%Y-%m-%d %H:%M:%S UTC")
|
||||
LDFLAGS := -X 'main.version=$(VERSION)' -X 'main.buildDate=$(BUILD_DATE)'
|
||||
|
||||
# Auto-detect container runtime (Docker or Podman)
|
||||
CONTAINER_RUNTIME := $(shell \
|
||||
if command -v podman > /dev/null 2>&1; then \
|
||||
@@ -37,9 +42,9 @@ COMPOSE_CMD := $(shell \
|
||||
all: lint test build ## Run linting, tests, and build
|
||||
|
||||
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 +96,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 +106,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)
|
||||
@@ -176,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}'
|
||||
|
||||
@@ -6,258 +6,178 @@
|
||||
[](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
|
||||
## Supported Formats
|
||||
|
||||
### Interactive Schema Editor
|
||||
| Direction | Formats |
|
||||
|-----------|---------|
|
||||
| **Readers** | `bun` `dbml` `dctx` `drawdb` `drizzle` `gorm` `graphql` `json` `mssql` `pgsql` `prisma` `sqldir` `sqlite` `typeorm` `yaml` |
|
||||
| **Writers** | `bun` `dbml` `dctx` `drawdb` `drizzle` `gorm` `graphql` `json` `mssql` `pgsql` `prisma` `sqlexec` `sqlite` `template` `typeorm` `yaml` |
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
### Schema Merging
|
||||
### `merge` — Additive schema merge (never modifies existing items)
|
||||
|
||||
```bash
|
||||
# Merge two JSON schemas (additive merge - adds missing items only)
|
||||
# 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.
|
||||
|
||||
### `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>
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
## 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/sqltypes/ Nullable SQL types for generated/hand-written models (see below)
|
||||
```
|
||||
|
||||
## Todo
|
||||
## Nullable Types (`pkg/sqltypes`)
|
||||
|
||||
[Todo List of Features](./TODO.md)
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
- Go 1.21 or higher
|
||||
- Access to test databases (optional)
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
go build -o relspec ./cmd/relspec
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Apache License 2.0 - See [LICENSE](LICENSE) for details.
|
||||
|
||||
Copyright 2025 Warky Devs
|
||||
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`).
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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,43 +1,44 @@
|
||||
# RelSpec - TODO List
|
||||
|
||||
|
||||
## Input Readers / Writers
|
||||
|
||||
- [✔️] **Database Inspector**
|
||||
- [✔️] PostgreSQL driver
|
||||
- [✔️] PostgreSQL driver (reader + writer)
|
||||
- [ ] MySQL driver
|
||||
- [ ] SQLite driver
|
||||
- [✔️] SQLite driver (reader + writer with automatic schema flattening)
|
||||
- [ ] MSSQL driver
|
||||
- [✔️] 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.)
|
||||
- [✔️] 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)
|
||||
- [] .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)
|
||||
- [✔️] GraphQL schema generation
|
||||
|
||||
|
||||
## 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
|
||||
@@ -46,12 +47,13 @@
|
||||
- [ ] Watch mode for auto-regeneration
|
||||
|
||||
## 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 |
+127
-43
@@ -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"
|
||||
@@ -18,8 +19,10 @@ 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/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 +34,26 @@ 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"
|
||||
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
|
||||
convertContinueOnError bool
|
||||
)
|
||||
|
||||
var convertCmd = &cobra.Command{
|
||||
@@ -70,6 +78,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 +94,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 +154,31 @@ 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`,
|
||||
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().BoolVar(&convertContinueOnError, "continue-on-error", false, "Prepend \\set ON_ERROR_STOP off to generated SQL so psql continues past errors (pgsql output only)")
|
||||
|
||||
err := convertCmd.MarkFlagRequired("from")
|
||||
if err != nil {
|
||||
@@ -169,17 +198,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)
|
||||
}
|
||||
@@ -204,7 +245,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, convertContinueOnError); err != nil {
|
||||
return fmt.Errorf("failed to write target: %w", err)
|
||||
}
|
||||
|
||||
@@ -215,6 +256,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
|
||||
|
||||
@@ -223,73 +288,90 @@ 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 "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,14 +385,10 @@ 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 string, continueOnError bool) error {
|
||||
var writer writers.Writer
|
||||
|
||||
writerOpts := &writers.WriterOptions{
|
||||
OutputPath: outputPath,
|
||||
PackageName: packageName,
|
||||
FlattenSchema: flattenSchema,
|
||||
}
|
||||
writerOpts := newWriterOptions(outputPath, packageName, flattenSchema, nullableTypes, continueOnError)
|
||||
|
||||
switch strings.ToLower(dbType) {
|
||||
case "dbml":
|
||||
@@ -346,6 +424,12 @@ 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 "sqlite", "sqlite3":
|
||||
writer = wsqlite.NewWriter(writerOpts)
|
||||
|
||||
case "prisma":
|
||||
writer = wprisma.NewWriter(writerOpts)
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ 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/sqlite"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/yaml"
|
||||
)
|
||||
|
||||
@@ -254,6 +255,17 @@ func readDatabase(dbType, filePath, connString, label string) (*models.Database,
|
||||
}
|
||||
reader = pgsql.NewReader(&readers.ReaderOptions{ConnectionString: 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(&readers.ReaderOptions{FilePath: dbPath})
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("%s: unsupported database format: %s", label, dbType)
|
||||
}
|
||||
|
||||
+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)
|
||||
}
|
||||
|
||||
+24
-12
@@ -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"
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
+80
-37
@@ -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
|
||||
@@ -107,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")
|
||||
@@ -142,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 == "" {
|
||||
@@ -155,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 == "" {
|
||||
@@ -187,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)
|
||||
@@ -232,6 +258,11 @@ 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))
|
||||
}
|
||||
|
||||
// Step 4: Write output
|
||||
fmt.Fprintf(os.Stderr, "\n[4/4] Writing output...\n")
|
||||
fmt.Fprintf(os.Stderr, " Format: %s\n", mergeOutputType)
|
||||
@@ -258,62 +289,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,59 +375,61 @@ 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":
|
||||
writer = wsqlite.NewWriter(newWriterOptions(filePath, "", flattenSchema, "", false))
|
||||
case "pgsql":
|
||||
writerOpts := &writers.WriterOptions{OutputPath: filePath, FlattenSchema: flattenSchema}
|
||||
writerOpts := newWriterOptions(filePath, "", flattenSchema, "", false)
|
||||
if connString != "" {
|
||||
writerOpts.Metadata = map[string]interface{}{
|
||||
"connection_string": connString,
|
||||
|
||||
@@ -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, 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")
|
||||
}
|
||||
}
|
||||
|
||||
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,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"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,
|
||||
}
|
||||
}
|
||||
|
||||
func newWriterOptions(outputPath, packageName string, flattenSchema bool, nullableTypes string, continueOnError bool) *writers.WriterOptions {
|
||||
return &writers.WriterOptions{
|
||||
OutputPath: outputPath,
|
||||
PackageName: packageName,
|
||||
FlattenSchema: flattenSchema,
|
||||
NullableTypes: nullableTypes,
|
||||
Prisma7: prisma7,
|
||||
ContinueOnError: continueOnError,
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,50 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
// Version information, set via ldflags during build
|
||||
version = "dev"
|
||||
buildDate = "unknown"
|
||||
prisma7 bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
// If version wasn't set via ldflags, try to get it from build info
|
||||
if version == "dev" {
|
||||
if info, ok := debug.ReadBuildInfo(); ok {
|
||||
// Try to get version from VCS
|
||||
var vcsRevision, vcsTime string
|
||||
for _, setting := range info.Settings {
|
||||
switch setting.Key {
|
||||
case "vcs.revision":
|
||||
if len(setting.Value) >= 7 {
|
||||
vcsRevision = setting.Value[:7]
|
||||
}
|
||||
case "vcs.time":
|
||||
vcsTime = setting.Value
|
||||
}
|
||||
}
|
||||
|
||||
if vcsRevision != "" {
|
||||
version = vcsRevision
|
||||
}
|
||||
|
||||
if vcsTime != "" {
|
||||
if t, err := time.Parse(time.RFC3339, vcsTime); err == nil {
|
||||
buildDate = t.UTC().Format("2006-01-02 15:04:05 UTC")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "relspec",
|
||||
Short: "RelSpec - Database schema conversion and analysis tool",
|
||||
@@ -13,6 +54,9 @@ 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.).`,
|
||||
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Printf("RelSpec %s (built: %s)\n\n", version, buildDate)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
@@ -24,4 +68,6 @@ func init() {
|
||||
rootCmd.AddCommand(editCmd)
|
||||
rootCmd.AddCommand(mergeCmd)
|
||||
rootCmd.AddCommand(splitCmd)
|
||||
rootCmd.AddCommand(versionCmd)
|
||||
rootCmd.PersistentFlags().BoolVar(&prisma7, "prisma7", false, "Use Prisma 7 generator conventions when reading/writing Prisma schemas")
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ var (
|
||||
splitDatabaseName string
|
||||
splitExcludeSchema string
|
||||
splitExcludeTables string
|
||||
splitNullableTypes string
|
||||
)
|
||||
|
||||
var splitCmd = &cobra.Command{
|
||||
@@ -110,6 +111,7 @@ 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'")
|
||||
|
||||
err := splitCmd.MarkFlagRequired("from")
|
||||
if err != nil {
|
||||
@@ -185,6 +187,8 @@ func runSplit(cmd *cobra.Command, args []string) error {
|
||||
splitPackageName,
|
||||
"", // no schema filter for split
|
||||
false, // no flatten-schema for split
|
||||
splitNullableTypes,
|
||||
false, // no continue-on-error for split
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write output: %w", err)
|
||||
|
||||
+15
-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,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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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, 0644); 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, 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
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var versionCmd = &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print version information",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Printf("RelSpec %s\n", version)
|
||||
fmt.Printf("Built: %s\n", buildDate)
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -1,38 +1,50 @@
|
||||
module git.warky.dev/wdevs/relspecgo
|
||||
|
||||
go 1.24.0
|
||||
go 1.25.7
|
||||
|
||||
require (
|
||||
github.com/gdamore/tcell/v2 v2.8.1
|
||||
github.com/gdamore/tcell/v2 v2.13.9
|
||||
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
|
||||
golang.org/x/text v0.37.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.50.1
|
||||
)
|
||||
|
||||
require (
|
||||
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/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.51.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/term v0.43.0 // indirect
|
||||
golang.org/x/tools v0.45.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,46 @@
|
||||
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/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 +49,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 +90,8 @@ 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/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 +100,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.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
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.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
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.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||
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.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.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.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.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.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
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.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
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.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
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 +150,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.62
|
||||
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/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"
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
Name: relspec
|
||||
Version: 1.0.62
|
||||
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/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
|
||||
@@ -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,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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package mariadb
|
||||
|
||||
import "strings"
|
||||
|
||||
// MariaDBToCanonicalTypes maps MariaDB/MySQL type names to canonical types.
|
||||
var MariaDBToCanonicalTypes = map[string]string{
|
||||
// Integer types
|
||||
"tinyint": "int8",
|
||||
"smallint": "int16",
|
||||
"mediumint": "int",
|
||||
"int": "int",
|
||||
"integer": "int",
|
||||
"int2": "int16",
|
||||
"int4": "int",
|
||||
"int8": "int64",
|
||||
"bigint": "int64",
|
||||
// Boolean (TINYINT(1) alias)
|
||||
"boolean": "bool",
|
||||
"bool": "bool",
|
||||
"bit": "bool",
|
||||
// Float types
|
||||
"float": "float32",
|
||||
"double": "float64",
|
||||
"real": "float64",
|
||||
"double precision": "float64",
|
||||
// Decimal types
|
||||
"decimal": "decimal",
|
||||
"numeric": "decimal",
|
||||
"dec": "decimal",
|
||||
"fixed": "decimal",
|
||||
// String types
|
||||
"char": "string",
|
||||
"character": "string",
|
||||
"varchar": "string",
|
||||
"nchar": "string",
|
||||
"nvarchar": "string",
|
||||
"tinytext": "text",
|
||||
"text": "text",
|
||||
"mediumtext": "text",
|
||||
"longtext": "text",
|
||||
// Binary/blob types
|
||||
"binary": "bytea",
|
||||
"varbinary": "bytea",
|
||||
"tinyblob": "bytea",
|
||||
"blob": "bytea",
|
||||
"mediumblob": "bytea",
|
||||
"longblob": "bytea",
|
||||
// Date/time types
|
||||
"date": "date",
|
||||
"time": "time",
|
||||
"datetime": "timestamp",
|
||||
"timestamp": "timestamp",
|
||||
"year": "int",
|
||||
// Other types
|
||||
"json": "json",
|
||||
"enum": "string",
|
||||
"set": "string",
|
||||
"uuid": "uuid",
|
||||
}
|
||||
|
||||
// CanonicalToMariaDBTypes maps canonical types to MariaDB/MySQL types.
|
||||
var CanonicalToMariaDBTypes = map[string]string{
|
||||
"bool": "TINYINT(1)",
|
||||
"int8": "TINYINT",
|
||||
"int16": "SMALLINT",
|
||||
"int": "INT",
|
||||
"int32": "INT",
|
||||
"int64": "BIGINT",
|
||||
"uint": "INT UNSIGNED",
|
||||
"uint8": "TINYINT UNSIGNED",
|
||||
"uint16": "SMALLINT UNSIGNED",
|
||||
"uint32": "INT UNSIGNED",
|
||||
"uint64": "BIGINT UNSIGNED",
|
||||
"float32": "FLOAT",
|
||||
"float64": "DOUBLE",
|
||||
"decimal": "DECIMAL",
|
||||
"string": "VARCHAR(255)",
|
||||
"text": "TEXT",
|
||||
"date": "DATE",
|
||||
"time": "TIME",
|
||||
"timestamp": "DATETIME",
|
||||
"timestamptz": "DATETIME",
|
||||
"uuid": "CHAR(36)",
|
||||
"json": "JSON",
|
||||
"jsonb": "JSON",
|
||||
"bytea": "BLOB",
|
||||
}
|
||||
|
||||
// MariaDBTypeSynonyms maps MariaDB/MySQL type aliases to their canonical MariaDB name.
|
||||
var MariaDBTypeSynonyms = map[string]string{
|
||||
"integer": "int",
|
||||
"int2": "smallint",
|
||||
"int4": "int",
|
||||
"int8": "bigint",
|
||||
"double precision": "double",
|
||||
"character": "char",
|
||||
"dec": "decimal",
|
||||
"fixed": "decimal",
|
||||
"numeric": "decimal",
|
||||
"boolean": "tinyint",
|
||||
"bool": "tinyint",
|
||||
}
|
||||
|
||||
// NormalizeMariaDBType maps a MariaDB/MySQL base type (no dimension parameters)
|
||||
// to its canonical MariaDB form. Unknown types are returned as-is (lowercased).
|
||||
func NormalizeMariaDBType(baseType string) string {
|
||||
lower := strings.ToLower(strings.TrimSpace(baseType))
|
||||
if canonical, ok := MariaDBTypeSynonyms[lower]; ok {
|
||||
return canonical
|
||||
}
|
||||
return lower
|
||||
}
|
||||
|
||||
// ConvertMariaDBToCanonical converts a MariaDB/MySQL type name to the canonical type.
|
||||
// Strips dimension parameters and normalizes aliases. Defaults to "string".
|
||||
func ConvertMariaDBToCanonical(mariadbType string) string {
|
||||
base := strings.ToLower(strings.TrimSpace(mariadbType))
|
||||
if idx := strings.Index(base, "("); idx >= 0 {
|
||||
base = strings.TrimSpace(base[:idx])
|
||||
}
|
||||
|
||||
if canonical, ok := MariaDBToCanonicalTypes[base]; ok {
|
||||
return canonical
|
||||
}
|
||||
|
||||
// Prefix match for composite types (e.g., "unsigned bigint")
|
||||
for key, canonical := range MariaDBToCanonicalTypes {
|
||||
if strings.HasPrefix(base, key) {
|
||||
return canonical
|
||||
}
|
||||
}
|
||||
|
||||
return "string"
|
||||
}
|
||||
|
||||
// ConvertCanonicalToMariaDB converts a canonical type to a MariaDB/MySQL type.
|
||||
// Defaults to VARCHAR(255) for unrecognised types.
|
||||
func ConvertCanonicalToMariaDB(canonicalType string) string {
|
||||
lower := strings.ToLower(strings.TrimSpace(canonicalType))
|
||||
if idx := strings.Index(lower, "("); idx >= 0 {
|
||||
lower = strings.TrimSpace(lower[:idx])
|
||||
}
|
||||
|
||||
if mariadbType, ok := CanonicalToMariaDBTypes[lower]; ok {
|
||||
return mariadbType
|
||||
}
|
||||
|
||||
// Prefix fallback
|
||||
for canonical, mariadb := range CanonicalToMariaDBTypes {
|
||||
if strings.HasPrefix(lower, canonical) {
|
||||
return mariadb
|
||||
}
|
||||
}
|
||||
|
||||
return "VARCHAR(255)"
|
||||
}
|
||||
+126
-1
@@ -5,9 +5,11 @@ package merge
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/pgsql"
|
||||
)
|
||||
|
||||
// MergeResult represents the result of a merge operation
|
||||
@@ -22,6 +24,16 @@ type MergeResult struct {
|
||||
EnumsAdded int
|
||||
ViewsAdded int
|
||||
SequencesAdded int
|
||||
TypeConflicts []ColumnTypeConflict
|
||||
}
|
||||
|
||||
// ColumnTypeConflict describes a column that exists in both schemas but with incompatible types.
|
||||
type ColumnTypeConflict struct {
|
||||
Schema string
|
||||
Table string
|
||||
Column string
|
||||
TargetType string
|
||||
SourceType string
|
||||
}
|
||||
|
||||
// MergeOptions contains options for merge operations
|
||||
@@ -146,11 +158,19 @@ func (r *MergeResult) mergeColumns(table *models.Table, srcTable *models.Table)
|
||||
|
||||
// Merge columns
|
||||
for colName, srcCol := range srcTable.Columns {
|
||||
if _, exists := existingColumns[colName]; !exists {
|
||||
if tgtCol, exists := existingColumns[colName]; !exists {
|
||||
// Column doesn't exist, add it
|
||||
newCol := cloneColumn(srcCol)
|
||||
table.Columns[colName] = newCol
|
||||
r.ColumnsAdded++
|
||||
} else if columnTypeConflict(tgtCol, srcCol) {
|
||||
r.TypeConflicts = append(r.TypeConflicts, ColumnTypeConflict{
|
||||
Schema: firstNonEmpty(table.Schema, srcTable.Schema, srcCol.Schema),
|
||||
Table: firstNonEmpty(table.Name, srcTable.Name, srcCol.Table),
|
||||
Column: firstNonEmpty(tgtCol.Name, srcCol.Name, colName),
|
||||
TargetType: describeColumnType(tgtCol),
|
||||
SourceType: describeColumnType(srcCol),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -426,6 +446,78 @@ func cloneColumn(col *models.Column) *models.Column {
|
||||
return newCol
|
||||
}
|
||||
|
||||
func columnTypeConflict(target, source *models.Column) bool {
|
||||
if target == nil || source == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
tType, tLen, tPrec, tScale := extractTypeParts(target)
|
||||
sType, sLen, sPrec, sScale := extractTypeParts(source)
|
||||
|
||||
return tType != sType || tLen != sLen || tPrec != sPrec || tScale != sScale
|
||||
}
|
||||
|
||||
// extractTypeParts returns the canonical base type and dimensions for a column,
|
||||
// handling the case where dimensions are embedded in the type string (e.g. "char(2)")
|
||||
// rather than stored in the separate Length/Precision/Scale fields.
|
||||
func extractTypeParts(col *models.Column) (baseType string, length, precision, scale int) {
|
||||
typeName := strings.ToLower(strings.TrimSpace(col.Type))
|
||||
length, precision, scale = col.Length, col.Precision, col.Scale
|
||||
|
||||
if idx := strings.Index(typeName, "("); idx >= 0 {
|
||||
inner := strings.TrimRight(strings.TrimSpace(typeName[idx+1:]), ")")
|
||||
typeName = strings.TrimSpace(typeName[:idx])
|
||||
parts := strings.Split(inner, ",")
|
||||
if len(parts) == 2 {
|
||||
if p, err := strconv.Atoi(strings.TrimSpace(parts[0])); err == nil && p > 0 && precision == 0 {
|
||||
precision = p
|
||||
}
|
||||
if s, err := strconv.Atoi(strings.TrimSpace(parts[1])); err == nil && s > 0 && scale == 0 {
|
||||
scale = s
|
||||
}
|
||||
} else if len(parts) == 1 {
|
||||
if l, err := strconv.Atoi(strings.TrimSpace(parts[0])); err == nil && l > 0 && length == 0 && precision == 0 {
|
||||
length = l
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
typeName = pgsql.NormalizePGType(typeName)
|
||||
|
||||
return typeName, length, precision, scale
|
||||
}
|
||||
|
||||
func describeColumnType(col *models.Column) string {
|
||||
if col == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
typeName := strings.TrimSpace(col.Type)
|
||||
if typeName == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch {
|
||||
case col.Precision > 0 && col.Scale > 0:
|
||||
return fmt.Sprintf("%s(%d,%d)", typeName, col.Precision, col.Scale)
|
||||
case col.Precision > 0:
|
||||
return fmt.Sprintf("%s(%d)", typeName, col.Precision)
|
||||
case col.Length > 0:
|
||||
return fmt.Sprintf("%s(%d)", typeName, col.Length)
|
||||
default:
|
||||
return typeName
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func cloneConstraint(constraint *models.Constraint) *models.Constraint {
|
||||
if constraint == nil {
|
||||
return nil
|
||||
@@ -609,6 +701,7 @@ func GetMergeSummary(result *MergeResult) string {
|
||||
fmt.Sprintf("Enums added: %d", result.EnumsAdded),
|
||||
fmt.Sprintf("Relations added: %d", result.RelationsAdded),
|
||||
fmt.Sprintf("Domains added: %d", result.DomainsAdded),
|
||||
fmt.Sprintf("Type conflicts: %d", len(result.TypeConflicts)),
|
||||
}
|
||||
|
||||
totalAdded := result.SchemasAdded + result.TablesAdded + result.ColumnsAdded +
|
||||
@@ -625,3 +718,35 @@ func GetMergeSummary(result *MergeResult) string {
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
// GetColumnTypeConflictSummary returns a short, human-readable conflict summary.
|
||||
func GetColumnTypeConflictSummary(result *MergeResult, limit int) string {
|
||||
if result == nil || len(result.TypeConflicts) == 0 {
|
||||
return ""
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = len(result.TypeConflicts)
|
||||
}
|
||||
|
||||
lines := make([]string, 0, min(limit, len(result.TypeConflicts))+1)
|
||||
lines = append(lines, "column type conflicts detected:")
|
||||
for i, conflict := range result.TypeConflicts {
|
||||
if i >= limit {
|
||||
break
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf(" - %s.%s.%s: target=%s source=%s",
|
||||
conflict.Schema, conflict.Table, conflict.Column, conflict.TargetType, conflict.SourceType))
|
||||
}
|
||||
if len(result.TypeConflicts) > limit {
|
||||
lines = append(lines, fmt.Sprintf(" ... and %d more", len(result.TypeConflicts)-limit))
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package merge
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
@@ -140,6 +141,61 @@ func TestMergeColumns_NewColumn(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeColumns_TypeConflictIsDetected(t *testing.T) {
|
||||
target := &models.Database{
|
||||
Schemas: []*models.Schema{
|
||||
{
|
||||
Name: "public",
|
||||
Tables: []*models.Table{
|
||||
{
|
||||
Name: "users",
|
||||
Schema: "public",
|
||||
Columns: map[string]*models.Column{
|
||||
"email": {Name: "email", Type: "varchar", Length: 255},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
source := &models.Database{
|
||||
Schemas: []*models.Schema{
|
||||
{
|
||||
Name: "public",
|
||||
Tables: []*models.Table{
|
||||
{
|
||||
Name: "users",
|
||||
Schema: "public",
|
||||
Columns: map[string]*models.Column{
|
||||
"email": {Name: "email", Type: "text"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := MergeDatabases(target, source, nil)
|
||||
|
||||
if len(result.TypeConflicts) != 1 {
|
||||
t.Fatalf("Expected 1 type conflict, got %d", len(result.TypeConflicts))
|
||||
}
|
||||
conflict := result.TypeConflicts[0]
|
||||
if conflict.Schema != "public" || conflict.Table != "users" || conflict.Column != "email" {
|
||||
t.Fatalf("Unexpected conflict location: %+v", conflict)
|
||||
}
|
||||
if conflict.TargetType != "varchar(255)" {
|
||||
t.Fatalf("Expected target type varchar(255), got %q", conflict.TargetType)
|
||||
}
|
||||
if conflict.SourceType != "text" {
|
||||
t.Fatalf("Expected source type text, got %q", conflict.SourceType)
|
||||
}
|
||||
|
||||
if got := target.Schemas[0].Tables[0].Columns["email"].Type; got != "varchar" {
|
||||
t.Fatalf("Expected target column type to remain unchanged, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConstraints_NewConstraint(t *testing.T) {
|
||||
target := &models.Database{
|
||||
Schemas: []*models.Schema{
|
||||
@@ -509,6 +565,9 @@ func TestGetMergeSummary(t *testing.T) {
|
||||
ConstraintsAdded: 3,
|
||||
IndexesAdded: 2,
|
||||
ViewsAdded: 1,
|
||||
TypeConflicts: []ColumnTypeConflict{
|
||||
{Schema: "public", Table: "users", Column: "email", TargetType: "varchar(255)", SourceType: "text"},
|
||||
},
|
||||
}
|
||||
|
||||
summary := GetMergeSummary(result)
|
||||
@@ -518,6 +577,9 @@ func TestGetMergeSummary(t *testing.T) {
|
||||
if len(summary) < 50 {
|
||||
t.Errorf("Summary seems too short: %s", summary)
|
||||
}
|
||||
if !strings.Contains(summary, "Type conflicts: 1") {
|
||||
t.Errorf("Expected type conflict count in summary, got: %s", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMergeSummary_Nil(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# MSSQL Package
|
||||
|
||||
Provides utilities for working with Microsoft SQL Server data types and conversions.
|
||||
|
||||
## Components
|
||||
|
||||
### Type Mapping
|
||||
|
||||
Provides bidirectional conversion between canonical types and MSSQL types:
|
||||
|
||||
- **CanonicalToMSSQL**: Convert abstract types to MSSQL-specific types
|
||||
- **MSSQLToCanonical**: Convert MSSQL types to abstract representation
|
||||
|
||||
## Type Conversion Tables
|
||||
|
||||
### Canonical → MSSQL
|
||||
|
||||
| Canonical | MSSQL | Notes |
|
||||
|-----------|-------|-------|
|
||||
| int | INT | 32-bit signed integer |
|
||||
| int64 | BIGINT | 64-bit signed integer |
|
||||
| int32 | INT | 32-bit signed integer |
|
||||
| int16 | SMALLINT | 16-bit signed integer |
|
||||
| int8 | TINYINT | 8-bit unsigned integer |
|
||||
| bool | BIT | 0 (false) or 1 (true) |
|
||||
| float32 | REAL | Single precision floating point |
|
||||
| float64 | FLOAT | Double precision floating point |
|
||||
| decimal | NUMERIC | Fixed-point decimal number |
|
||||
| string | NVARCHAR(255) | Unicode variable-length string |
|
||||
| text | NVARCHAR(MAX) | Unicode large text |
|
||||
| timestamp | DATETIME2 | Date and time without timezone |
|
||||
| timestamptz | DATETIMEOFFSET | Date and time with timezone offset |
|
||||
| uuid | UNIQUEIDENTIFIER | GUID/UUID type |
|
||||
| bytea | VARBINARY(MAX) | Variable-length binary data |
|
||||
| date | DATE | Date only |
|
||||
| time | TIME | Time only |
|
||||
| json | NVARCHAR(MAX) | Stored as text (MSSQL v2016+) |
|
||||
| jsonb | NVARCHAR(MAX) | Stored as text (MSSQL v2016+) |
|
||||
|
||||
### MSSQL → Canonical
|
||||
|
||||
| MSSQL | Canonical | Notes |
|
||||
|-------|-----------|-------|
|
||||
| INT, INTEGER | int | Standard integer |
|
||||
| BIGINT | int64 | Large integer |
|
||||
| SMALLINT | int16 | Small integer |
|
||||
| TINYINT | int8 | Tiny integer |
|
||||
| BIT | bool | Boolean/bit flag |
|
||||
| REAL | float32 | Single precision |
|
||||
| FLOAT | float64 | Double precision |
|
||||
| NUMERIC, DECIMAL | decimal | Exact decimal |
|
||||
| NVARCHAR, VARCHAR | string | Variable-length string |
|
||||
| NCHAR, CHAR | string | Fixed-length string |
|
||||
| DATETIME2 | timestamp | Default timestamp |
|
||||
| DATETIMEOFFSET | timestamptz | Timestamp with timezone |
|
||||
| DATE | date | Date only |
|
||||
| TIME | time | Time only |
|
||||
| UNIQUEIDENTIFIER | uuid | UUID/GUID |
|
||||
| VARBINARY, BINARY | bytea | Binary data |
|
||||
| XML | string | Stored as text |
|
||||
|
||||
## Usage
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/mssql"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Convert canonical to MSSQL
|
||||
mssqlType := mssql.ConvertCanonicalToMSSQL("int")
|
||||
fmt.Println(mssqlType) // Output: INT
|
||||
|
||||
// Convert MSSQL to canonical
|
||||
canonicalType := mssql.ConvertMSSQLToCanonical("BIGINT")
|
||||
fmt.Println(canonicalType) // Output: int64
|
||||
|
||||
// Handle parameterized types
|
||||
canonicalType = mssql.ConvertMSSQLToCanonical("NVARCHAR(255)")
|
||||
fmt.Println(canonicalType) // Output: string
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Run tests with:
|
||||
```bash
|
||||
go test ./pkg/mssql/...
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Type conversions are case-insensitive
|
||||
- Parameterized types (e.g., `NVARCHAR(255)`) have their base type extracted
|
||||
- Unmapped types default to `string` for safety
|
||||
- The package supports SQL Server 2016 and later versions
|
||||
@@ -0,0 +1,158 @@
|
||||
package mssql
|
||||
|
||||
import "strings"
|
||||
|
||||
// CanonicalToMSSQLTypes maps canonical types to MSSQL types.
|
||||
// Accepts both Go canonical names ("int", "string") and SQL canonical names
|
||||
// ("integer", "varchar") so the writer handles input from any reader.
|
||||
var CanonicalToMSSQLTypes = map[string]string{
|
||||
// Boolean — Go and SQL canonical
|
||||
"bool": "BIT",
|
||||
"boolean": "BIT",
|
||||
// Integer — Go canonical
|
||||
"int8": "TINYINT",
|
||||
"int16": "SMALLINT",
|
||||
"int": "INT",
|
||||
"int32": "INT",
|
||||
"int64": "BIGINT",
|
||||
"uint": "BIGINT",
|
||||
"uint8": "TINYINT",
|
||||
"uint16": "SMALLINT",
|
||||
"uint32": "BIGINT",
|
||||
"uint64": "BIGINT",
|
||||
// Integer — SQL canonical (serial types map to base integer; IDENTITY is set via AutoIncrement)
|
||||
"integer": "INT",
|
||||
"smallint": "SMALLINT",
|
||||
"bigint": "BIGINT",
|
||||
"tinyint": "TINYINT",
|
||||
"serial": "INT",
|
||||
"smallserial": "SMALLINT",
|
||||
"bigserial": "BIGINT",
|
||||
// Float — Go canonical
|
||||
"float32": "REAL",
|
||||
"float64": "FLOAT",
|
||||
// Float — SQL canonical
|
||||
"real": "REAL",
|
||||
"double precision": "FLOAT",
|
||||
"double": "FLOAT",
|
||||
// Decimal/numeric
|
||||
"decimal": "NUMERIC",
|
||||
"numeric": "NUMERIC",
|
||||
"money": "MONEY",
|
||||
// String — Go canonical
|
||||
"string": "NVARCHAR(255)",
|
||||
"text": "NVARCHAR(MAX)",
|
||||
// String — SQL canonical
|
||||
"varchar": "NVARCHAR(255)",
|
||||
"char": "NCHAR",
|
||||
"nvarchar": "NVARCHAR(255)",
|
||||
"nchar": "NCHAR",
|
||||
"citext": "NVARCHAR(MAX)",
|
||||
// Date/time
|
||||
"date": "DATE",
|
||||
"time": "TIME",
|
||||
"timetz": "DATETIMEOFFSET",
|
||||
"timestamp": "DATETIME2",
|
||||
"timestamptz": "DATETIMEOFFSET",
|
||||
"datetime": "DATETIME2",
|
||||
"interval": "NVARCHAR(50)",
|
||||
// UUID
|
||||
"uuid": "UNIQUEIDENTIFIER",
|
||||
// JSON — MSSQL has no native JSON type; stored as NVARCHAR(MAX)
|
||||
"json": "NVARCHAR(MAX)",
|
||||
"jsonb": "NVARCHAR(MAX)",
|
||||
// Binary
|
||||
"bytea": "VARBINARY(MAX)",
|
||||
"blob": "VARBINARY(MAX)",
|
||||
// Network/geo types — no MSSQL native equivalent
|
||||
"xml": "XML",
|
||||
"inet": "NVARCHAR(45)",
|
||||
"cidr": "NVARCHAR(43)",
|
||||
"macaddr": "NVARCHAR(17)",
|
||||
}
|
||||
|
||||
// MSSQLToCanonicalTypes maps MSSQL types to canonical types
|
||||
var MSSQLToCanonicalTypes = map[string]string{
|
||||
"bit": "bool",
|
||||
"tinyint": "int8",
|
||||
"smallint": "int16",
|
||||
"int": "int",
|
||||
"integer": "int",
|
||||
"bigint": "int64",
|
||||
"real": "float32",
|
||||
"float": "float64",
|
||||
"numeric": "decimal",
|
||||
"decimal": "decimal",
|
||||
"money": "decimal",
|
||||
"smallmoney": "decimal",
|
||||
"nvarchar": "string",
|
||||
"nchar": "string",
|
||||
"varchar": "string",
|
||||
"char": "string",
|
||||
"text": "string",
|
||||
"ntext": "string",
|
||||
"date": "date",
|
||||
"time": "time",
|
||||
"datetime": "timestamp",
|
||||
"datetime2": "timestamp",
|
||||
"smalldatetime": "timestamp",
|
||||
"datetimeoffset": "timestamptz",
|
||||
"uniqueidentifier": "uuid",
|
||||
"varbinary": "bytea",
|
||||
"binary": "bytea",
|
||||
"image": "bytea",
|
||||
"xml": "string",
|
||||
"json": "json",
|
||||
"sql_variant": "string",
|
||||
"hierarchyid": "string",
|
||||
"geography": "string",
|
||||
"geometry": "string",
|
||||
}
|
||||
|
||||
// MSSQLTypeSynonyms maps MSSQL type aliases to their canonical MSSQL name.
|
||||
var MSSQLTypeSynonyms = map[string]string{
|
||||
"integer": "int",
|
||||
"dec": "decimal",
|
||||
"float(n)": "float",
|
||||
}
|
||||
|
||||
// NormalizeMSSQLType maps an MSSQL base type (no dimension parameters) to its
|
||||
// canonical MSSQL form. Unknown types are returned as-is (lowercased).
|
||||
func NormalizeMSSQLType(baseType string) string {
|
||||
lower := strings.ToLower(strings.TrimSpace(baseType))
|
||||
if canonical, ok := MSSQLTypeSynonyms[lower]; ok {
|
||||
return canonical
|
||||
}
|
||||
return lower
|
||||
}
|
||||
|
||||
// ConvertCanonicalToMSSQL converts a canonical type (Go or SQL) to an MSSQL type.
|
||||
// Strips dimension parameters before lookup. Defaults to NVARCHAR(255) for unknown types.
|
||||
func ConvertCanonicalToMSSQL(canonicalType string) string {
|
||||
base := strings.ToLower(strings.TrimSpace(canonicalType))
|
||||
if idx := strings.Index(base, "("); idx >= 0 {
|
||||
base = strings.TrimSpace(base[:idx])
|
||||
}
|
||||
base = strings.TrimSuffix(base, "[]")
|
||||
|
||||
if mssqlType, exists := CanonicalToMSSQLTypes[base]; exists {
|
||||
return mssqlType
|
||||
}
|
||||
|
||||
return "NVARCHAR(255)"
|
||||
}
|
||||
|
||||
// ConvertMSSQLToCanonical converts an MSSQL type to the canonical type.
|
||||
// Strips dimension parameters before lookup. Defaults to "string" for unknown types.
|
||||
func ConvertMSSQLToCanonical(mssqlType string) string {
|
||||
base := strings.ToLower(strings.TrimSpace(mssqlType))
|
||||
if idx := strings.Index(base, "("); idx >= 0 {
|
||||
base = strings.TrimSpace(base[:idx])
|
||||
}
|
||||
|
||||
if canonicalType, exists := MSSQLToCanonicalTypes[base]; exists {
|
||||
return canonicalType
|
||||
}
|
||||
|
||||
return "string"
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package pgsql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultApplicationPrefix = "relspecgo"
|
||||
postgresIdentifierMaxLen = 63
|
||||
)
|
||||
|
||||
// BuildApplicationName returns a PostgreSQL application_name in the form:
|
||||
// relspecgo/<version>[:<component>]
|
||||
func BuildApplicationName(component string) string {
|
||||
appName := fmt.Sprintf("%s/%s", defaultApplicationPrefix, relspecVersion())
|
||||
component = strings.TrimSpace(component)
|
||||
if component != "" {
|
||||
appName = appName + ":" + component
|
||||
}
|
||||
if len(appName) > postgresIdentifierMaxLen {
|
||||
appName = appName[:postgresIdentifierMaxLen]
|
||||
}
|
||||
return appName
|
||||
}
|
||||
|
||||
// ParseConfigWithApplicationName parses a connection string and applies a default
|
||||
// application_name when one is not explicitly provided by the caller.
|
||||
func ParseConfigWithApplicationName(connString, component string) (*pgx.ConnConfig, error) {
|
||||
cfg, err := pgx.ParseConfig(connString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cfg.RuntimeParams == nil {
|
||||
cfg.RuntimeParams = map[string]string{}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(cfg.RuntimeParams["application_name"]) == "" {
|
||||
cfg.RuntimeParams["application_name"] = BuildApplicationName(component)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Connect establishes a PostgreSQL connection with a default relspec
|
||||
// application_name when the caller does not provide one in the DSN.
|
||||
func Connect(ctx context.Context, connString, component string) (*pgx.Conn, error) {
|
||||
cfg, err := ParseConfigWithApplicationName(connString, component)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return pgx.ConnectConfig(ctx, cfg)
|
||||
}
|
||||
|
||||
func relspecVersion() string {
|
||||
info, ok := debug.ReadBuildInfo()
|
||||
if !ok {
|
||||
return "dev"
|
||||
}
|
||||
|
||||
version := strings.TrimSpace(info.Main.Version)
|
||||
if version != "" && version != "(devel)" {
|
||||
return version
|
||||
}
|
||||
|
||||
for _, setting := range info.Settings {
|
||||
if setting.Key == "vcs.revision" {
|
||||
revision := strings.TrimSpace(setting.Value)
|
||||
if len(revision) >= 7 {
|
||||
return revision[:7]
|
||||
}
|
||||
if revision != "" {
|
||||
return revision
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "dev"
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package pgsql
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildApplicationName_IncludesVersion(t *testing.T) {
|
||||
got := BuildApplicationName("")
|
||||
if !strings.HasPrefix(got, "relspecgo/") {
|
||||
t.Fatalf("BuildApplicationName() = %q, expected prefix relspecgo/", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildApplicationName_IncludesComponent(t *testing.T) {
|
||||
got := BuildApplicationName("reader-pgsql")
|
||||
if !strings.Contains(got, ":reader-pgsql") {
|
||||
t.Fatalf("BuildApplicationName(component) = %q, expected component suffix", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildApplicationName_RespectsPostgresLengthLimit(t *testing.T) {
|
||||
got := BuildApplicationName(strings.Repeat("x", 200))
|
||||
if len(got) > 63 {
|
||||
t.Fatalf("BuildApplicationName() length = %d, expected <= 63", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigWithApplicationName_AddsWhenMissing(t *testing.T) {
|
||||
cfg, err := ParseConfigWithApplicationName("postgres://user:pass@localhost:5432/db", "reader-pgsql")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseConfigWithApplicationName() error = %v", err)
|
||||
}
|
||||
|
||||
appName := cfg.RuntimeParams["application_name"]
|
||||
if appName == "" {
|
||||
t.Fatal("expected application_name to be set")
|
||||
}
|
||||
if !strings.HasPrefix(appName, "relspecgo/") {
|
||||
t.Fatalf("application_name = %q, expected relspecgo/<version> prefix", appName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigWithApplicationName_PreservesExplicitValue(t *testing.T) {
|
||||
cfg, err := ParseConfigWithApplicationName("postgres://user:pass@localhost:5432/db?application_name=custom-app", "reader-pgsql")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseConfigWithApplicationName() error = %v", err)
|
||||
}
|
||||
|
||||
if got := cfg.RuntimeParams["application_name"]; got != "custom-app" {
|
||||
t.Fatalf("application_name = %q, expected %q", got, "custom-app")
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ var GoToStdTypes = map[string]string{
|
||||
"sqldate": "date",
|
||||
"sqltime": "time",
|
||||
"sqltimestamp": "timestamp",
|
||||
"time.Time": "timestamp",
|
||||
}
|
||||
|
||||
var GoToPGSQLTypes = map[string]string{
|
||||
@@ -90,6 +91,7 @@ var GoToPGSQLTypes = map[string]string{
|
||||
"sqldate": "date",
|
||||
"sqltime": "time",
|
||||
"sqltimestamp": "timestamp",
|
||||
"time.Time": "timestamp",
|
||||
"citext": "citext",
|
||||
}
|
||||
|
||||
@@ -135,6 +137,62 @@ func ConvertSQLType(anytype string) string {
|
||||
return anytype
|
||||
}
|
||||
|
||||
// PGTypeCanonical maps PostgreSQL type aliases and synonyms to their canonical base name.
|
||||
// Input should be a base type (no dimension parameters, lowercase).
|
||||
var PGTypeCanonical = map[string]string{
|
||||
// integer aliases
|
||||
"int": "integer",
|
||||
"int4": "integer",
|
||||
"int2": "smallint",
|
||||
"int8": "bigint",
|
||||
// float aliases
|
||||
"float4": "real",
|
||||
"float8": "double precision",
|
||||
// bool alias
|
||||
"bool": "boolean",
|
||||
// char aliases
|
||||
"character": "char",
|
||||
"character varying": "varchar",
|
||||
"bpchar": "char",
|
||||
// timestamp aliases
|
||||
"timestamp without time zone": "timestamp",
|
||||
"timestamp with time zone": "timestamptz",
|
||||
// time aliases
|
||||
"time without time zone": "time",
|
||||
"time with time zone": "timetz",
|
||||
// decimal alias
|
||||
"decimal": "numeric",
|
||||
}
|
||||
|
||||
// knownPGBaseTypes is the set of canonical PostgreSQL base types (no aliases).
|
||||
var knownPGBaseTypes = map[string]struct{}{
|
||||
"integer": {}, "bigint": {}, "smallint": {},
|
||||
"serial": {}, "bigserial": {}, "smallserial": {},
|
||||
"numeric": {}, "real": {}, "double precision": {}, "money": {},
|
||||
"varchar": {}, "char": {}, "text": {}, "citext": {},
|
||||
"boolean": {},
|
||||
"date": {}, "time": {}, "timetz": {}, "timestamp": {}, "timestamptz": {}, "interval": {},
|
||||
"uuid": {}, "json": {}, "jsonb": {}, "bytea": {},
|
||||
"inet": {}, "cidr": {}, "macaddr": {}, "xml": {},
|
||||
}
|
||||
|
||||
// NormalizePGType maps a PostgreSQL base type (no dimension parameters) to its
|
||||
// canonical form. Unknown types are returned as-is (lowercased).
|
||||
func NormalizePGType(baseType string) string {
|
||||
lower := strings.ToLower(strings.TrimSpace(baseType))
|
||||
if canonical, ok := PGTypeCanonical[lower]; ok {
|
||||
return canonical
|
||||
}
|
||||
return lower
|
||||
}
|
||||
|
||||
// IsKnownPGBaseType reports whether the given name (after NormalizePGType) is a
|
||||
// recognized built-in PostgreSQL type. Custom types (e.g. vector, postgis) return false.
|
||||
func IsKnownPGBaseType(baseType string) bool {
|
||||
_, ok := knownPGBaseTypes[strings.ToLower(strings.TrimSpace(baseType))]
|
||||
return ok
|
||||
}
|
||||
|
||||
func IsGoType(pTypeName string) bool {
|
||||
for k := range GoToStdTypes {
|
||||
if strings.EqualFold(pTypeName, k) {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Package pgsql provides PostgreSQL-specific utilities and helpers.
|
||||
//
|
||||
// # Overview
|
||||
//
|
||||
// The pgsql package contains PostgreSQL-specific functionality including:
|
||||
// - SQL reserved keyword validation
|
||||
// - Data type mappings and conversions
|
||||
// - PostgreSQL-specific schema introspection helpers
|
||||
//
|
||||
// # Components
|
||||
//
|
||||
// keywords.go - SQL reserved keywords validation
|
||||
//
|
||||
// Provides functions to check if identifiers conflict with SQL reserved words
|
||||
// and need quoting for safe usage in PostgreSQL queries.
|
||||
//
|
||||
// datatypes.go - PostgreSQL data type utilities
|
||||
//
|
||||
// Contains mappings between PostgreSQL data types and their equivalents in other
|
||||
// systems, as well as type conversion and normalization functions.
|
||||
//
|
||||
// # Usage
|
||||
//
|
||||
// // Check if identifier needs quoting
|
||||
// if pgsql.IsReservedKeyword("user") {
|
||||
// // Quote the identifier
|
||||
// }
|
||||
//
|
||||
// // Normalize data type
|
||||
// normalizedType := pgsql.NormalizeDataType("varchar(255)")
|
||||
//
|
||||
// # Purpose
|
||||
//
|
||||
// This package supports the PostgreSQL reader and writer implementations by providing
|
||||
// shared utilities for handling PostgreSQL-specific schema elements and constraints.
|
||||
package pgsql
|
||||
@@ -0,0 +1,348 @@
|
||||
package pgsql
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TypeSpec describes PostgreSQL type capabilities used by parsers/writers.
|
||||
type TypeSpec struct {
|
||||
SupportsLength bool
|
||||
SupportsPrecision bool
|
||||
}
|
||||
|
||||
var postgresBaseTypes = map[string]TypeSpec{
|
||||
// Numeric types
|
||||
"smallint": {},
|
||||
"integer": {},
|
||||
"bigint": {},
|
||||
"decimal": {SupportsPrecision: true},
|
||||
"numeric": {SupportsPrecision: true},
|
||||
"real": {},
|
||||
"double precision": {},
|
||||
"smallserial": {},
|
||||
"serial": {},
|
||||
"bigserial": {},
|
||||
"money": {},
|
||||
|
||||
// Character types
|
||||
"char": {SupportsLength: true},
|
||||
"character": {SupportsLength: true},
|
||||
"varchar": {SupportsLength: true},
|
||||
"character varying": {SupportsLength: true},
|
||||
"text": {},
|
||||
"name": {},
|
||||
|
||||
// Binary
|
||||
"bytea": {},
|
||||
|
||||
// Date/time
|
||||
"timestamp": {SupportsPrecision: true},
|
||||
"timestamp without time zone": {SupportsPrecision: true},
|
||||
"timestamp with time zone": {SupportsPrecision: true},
|
||||
"time": {SupportsPrecision: true},
|
||||
"time without time zone": {SupportsPrecision: true},
|
||||
"time with time zone": {SupportsPrecision: true},
|
||||
"date": {},
|
||||
"interval": {SupportsPrecision: true},
|
||||
|
||||
// Boolean
|
||||
"boolean": {},
|
||||
|
||||
// Geometric
|
||||
"point": {},
|
||||
"line": {},
|
||||
"lseg": {},
|
||||
"box": {},
|
||||
"path": {},
|
||||
"polygon": {},
|
||||
"circle": {},
|
||||
|
||||
// Network
|
||||
"cidr": {},
|
||||
"inet": {},
|
||||
"macaddr": {},
|
||||
"macaddr8": {},
|
||||
|
||||
// Bit string
|
||||
"bit": {SupportsLength: true},
|
||||
"bit varying": {SupportsLength: true},
|
||||
"varbit": {SupportsLength: true},
|
||||
|
||||
// Text search
|
||||
"tsvector": {},
|
||||
"tsquery": {},
|
||||
|
||||
// UUID/XML/JSON
|
||||
"uuid": {},
|
||||
"xml": {},
|
||||
"json": {},
|
||||
"jsonb": {},
|
||||
|
||||
// Range
|
||||
"int4range": {},
|
||||
"int8range": {},
|
||||
"numrange": {},
|
||||
"tsrange": {},
|
||||
"tstzrange": {},
|
||||
"daterange": {},
|
||||
"int4multirange": {},
|
||||
"int8multirange": {},
|
||||
"nummultirange": {},
|
||||
"tsmultirange": {},
|
||||
"tstzmultirange": {},
|
||||
"datemultirange": {},
|
||||
|
||||
// Object identifier
|
||||
"oid": {},
|
||||
"regclass": {},
|
||||
"regproc": {},
|
||||
"regtype": {},
|
||||
|
||||
// Pseudo-ish/common built-ins seen in schemas
|
||||
"record": {},
|
||||
"void": {},
|
||||
|
||||
// Common extensions
|
||||
"citext": {},
|
||||
"hstore": {},
|
||||
"ltree": {},
|
||||
"lquery": {},
|
||||
"ltxtquery": {},
|
||||
"vector": {}, // pgvector: keep explicit modifier form (vector(dim))
|
||||
"halfvec": {}, // pgvector: keep explicit modifier form (halfvec(dim))
|
||||
"sparsevec": {}, // pgvector: keep explicit modifier form (sparsevec(dim))
|
||||
}
|
||||
|
||||
var postgresTypeAliases = map[string]string{
|
||||
// Integer aliases
|
||||
"int2": "smallint",
|
||||
"int4": "integer",
|
||||
"int8": "bigint",
|
||||
"int": "integer",
|
||||
|
||||
// Serial aliases
|
||||
"serial2": "smallserial",
|
||||
"serial4": "serial",
|
||||
"serial8": "bigserial",
|
||||
|
||||
// Character aliases
|
||||
"bpchar": "char",
|
||||
|
||||
// Float aliases
|
||||
"float4": "real",
|
||||
"float8": "double precision",
|
||||
"float": "double precision",
|
||||
|
||||
// Time aliases
|
||||
"timestamptz": "timestamp with time zone",
|
||||
"timetz": "time with time zone",
|
||||
|
||||
// Bit alias
|
||||
"varbit": "bit varying",
|
||||
|
||||
// Boolean alias
|
||||
"bool": "boolean",
|
||||
}
|
||||
|
||||
var postgresEquivalentBaseTypes = map[string]string{
|
||||
"character varying": "varchar",
|
||||
"character": "char",
|
||||
"timestamp without time zone": "timestamp",
|
||||
"timestamp with time zone": "timestamptz",
|
||||
"time without time zone": "time",
|
||||
"time with time zone": "timetz",
|
||||
}
|
||||
|
||||
var postgresEquivalentBaseTypeVariants = map[string][]string{
|
||||
"varchar": {"varchar", "character varying"},
|
||||
"char": {"char", "character"},
|
||||
"timestamp": {"timestamp", "timestamp without time zone"},
|
||||
"timestamptz": {"timestamptz", "timestamp with time zone"},
|
||||
"time": {"time", "time without time zone"},
|
||||
"timetz": {"timetz", "time with time zone"},
|
||||
}
|
||||
|
||||
// GetPostgresBaseTypes returns a sorted-ish stable list of registered base type names.
|
||||
func GetPostgresBaseTypes() []string {
|
||||
result := make([]string, 0, len(postgresBaseTypes))
|
||||
for t := range postgresBaseTypes {
|
||||
result = append(result, t)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
// GetPostgresTypes returns the registered PostgreSQL types.
|
||||
// When includeArrays is true, each base type also includes an array variant ("type[]").
|
||||
func GetPostgresTypes(includeArrays bool) []string {
|
||||
base := GetPostgresBaseTypes()
|
||||
if !includeArrays {
|
||||
return base
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(base)*2)
|
||||
result = append(result, base...)
|
||||
for _, t := range base {
|
||||
result = append(result, t+"[]")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ExtractBaseType returns the type without outer array suffixes and modifiers.
|
||||
// Examples:
|
||||
// - varchar(255) -> varchar
|
||||
// - text[] -> text
|
||||
// - numeric(10,2)[] -> numeric
|
||||
func ExtractBaseType(sqlType string) string {
|
||||
t := normalizeTypeToken(sqlType)
|
||||
t = strings.TrimSpace(stripArraySuffixes(t))
|
||||
if idx := strings.Index(t, "("); idx > 0 {
|
||||
t = strings.TrimSpace(t[:idx])
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// ExtractBaseTypeLower is ExtractBaseType with lowercase normalization.
|
||||
func ExtractBaseTypeLower(sqlType string) string {
|
||||
return strings.ToLower(ExtractBaseType(sqlType))
|
||||
}
|
||||
|
||||
// IsArrayType reports whether the SQL type has one or more [] suffixes.
|
||||
func IsArrayType(sqlType string) bool {
|
||||
t := normalizeTypeToken(sqlType)
|
||||
return strings.HasSuffix(t, "[]")
|
||||
}
|
||||
|
||||
// ElementType returns the underlying element type for array types.
|
||||
// For non-array types, it returns the input unchanged.
|
||||
func ElementType(sqlType string) string {
|
||||
t := normalizeTypeToken(sqlType)
|
||||
return stripArraySuffixes(t)
|
||||
}
|
||||
|
||||
// CanonicalizeBaseType resolves aliases to canonical PostgreSQL type names.
|
||||
func CanonicalizeBaseType(baseType string) string {
|
||||
base := strings.ToLower(normalizeTypeToken(baseType))
|
||||
if canonical, ok := postgresTypeAliases[base]; ok {
|
||||
return canonical
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// EquivalentBaseType resolves broader SQL-equivalent spellings to a common comparable form.
|
||||
func EquivalentBaseType(baseType string) string {
|
||||
base := CanonicalizeBaseType(baseType)
|
||||
if equivalent, ok := postgresEquivalentBaseTypes[base]; ok {
|
||||
return equivalent
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// NormalizeEquivalentSQLType returns a normalized SQL type string suitable for equality checks.
|
||||
// Equivalent spellings such as "character varying(255)" and "varchar(255)" normalize identically.
|
||||
func NormalizeEquivalentSQLType(sqlType string) string {
|
||||
t := normalizeTypeToken(sqlType)
|
||||
if t == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
arrayDepth := 0
|
||||
for strings.HasSuffix(t, "[]") {
|
||||
arrayDepth++
|
||||
t = strings.TrimSpace(strings.TrimSuffix(t, "[]"))
|
||||
}
|
||||
|
||||
modifier := ""
|
||||
if idx := strings.Index(t, "("); idx >= 0 {
|
||||
modifier = strings.TrimSpace(t[idx:])
|
||||
t = strings.TrimSpace(t[:idx])
|
||||
}
|
||||
|
||||
base := EquivalentBaseType(t)
|
||||
normalized := base + modifier
|
||||
for i := 0; i < arrayDepth; i++ {
|
||||
normalized += "[]"
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
// EquivalentSQLTypeVariants returns equivalent PostgreSQL spellings for a SQL type.
|
||||
// Examples:
|
||||
// - varchar(255) -> ["varchar(255)", "character varying(255)"]
|
||||
// - timestamptz -> ["timestamptz", "timestamp with time zone"]
|
||||
func EquivalentSQLTypeVariants(sqlType string) []string {
|
||||
t := normalizeTypeToken(sqlType)
|
||||
if t == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
arrayDepth := 0
|
||||
for strings.HasSuffix(t, "[]") {
|
||||
arrayDepth++
|
||||
t = strings.TrimSpace(strings.TrimSuffix(t, "[]"))
|
||||
}
|
||||
|
||||
modifier := ""
|
||||
if idx := strings.Index(t, "("); idx >= 0 {
|
||||
modifier = strings.TrimSpace(t[idx:])
|
||||
t = strings.TrimSpace(t[:idx])
|
||||
}
|
||||
|
||||
base := EquivalentBaseType(t)
|
||||
bases := postgresEquivalentBaseTypeVariants[base]
|
||||
if len(bases) == 0 {
|
||||
bases = []string{base}
|
||||
}
|
||||
|
||||
seen := make(map[string]bool, len(bases))
|
||||
result := make([]string, 0, len(bases))
|
||||
for _, variantBase := range bases {
|
||||
variant := variantBase + modifier
|
||||
for i := 0; i < arrayDepth; i++ {
|
||||
variant += "[]"
|
||||
}
|
||||
if !seen[variant] {
|
||||
seen[variant] = true
|
||||
result = append(result, variant)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// IsKnownPostgresType reports whether a type (including array forms) exists in the registry.
|
||||
func IsKnownPostgresType(sqlType string) bool {
|
||||
base := CanonicalizeBaseType(ExtractBaseTypeLower(sqlType))
|
||||
_, ok := postgresBaseTypes[base]
|
||||
return ok
|
||||
}
|
||||
|
||||
// SupportsLength reports if this SQL type accepts a single length/dimension modifier.
|
||||
func SupportsLength(sqlType string) bool {
|
||||
base := CanonicalizeBaseType(ExtractBaseTypeLower(sqlType))
|
||||
spec, ok := postgresBaseTypes[base]
|
||||
return ok && spec.SupportsLength
|
||||
}
|
||||
|
||||
// SupportsPrecision reports if this SQL type accepts precision (and possibly scale).
|
||||
func SupportsPrecision(sqlType string) bool {
|
||||
base := CanonicalizeBaseType(ExtractBaseTypeLower(sqlType))
|
||||
spec, ok := postgresBaseTypes[base]
|
||||
return ok && spec.SupportsPrecision
|
||||
}
|
||||
|
||||
// HasExplicitTypeModifier reports if the type already includes "(...)".
|
||||
func HasExplicitTypeModifier(sqlType string) bool {
|
||||
return strings.Contains(sqlType, "(")
|
||||
}
|
||||
|
||||
func stripArraySuffixes(t string) string {
|
||||
for strings.HasSuffix(t, "[]") {
|
||||
t = strings.TrimSpace(strings.TrimSuffix(t, "[]"))
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func normalizeTypeToken(t string) string {
|
||||
return strings.Join(strings.Fields(strings.TrimSpace(t)), " ")
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package pgsql
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPostgresTypeRegistry_MasterListIncludesRequestedTypes(t *testing.T) {
|
||||
required := []string{
|
||||
"vector",
|
||||
"integer",
|
||||
"citext",
|
||||
}
|
||||
|
||||
types := make(map[string]bool)
|
||||
for _, typ := range GetPostgresTypes(true) {
|
||||
types[typ] = true
|
||||
}
|
||||
|
||||
for _, typ := range required {
|
||||
if !types[typ] {
|
||||
t.Fatalf("master type list missing %q", typ)
|
||||
}
|
||||
if !types[typ+"[]"] {
|
||||
t.Fatalf("master type list missing array variant %q", typ+"[]")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresTypeRegistry_TypeParsingAndCapabilities(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
wantBase string
|
||||
wantCanonicalBase string
|
||||
wantArray bool
|
||||
wantKnown bool
|
||||
wantLength bool
|
||||
wantPrecision bool
|
||||
}{
|
||||
{
|
||||
input: "integer[]",
|
||||
wantBase: "integer",
|
||||
wantCanonicalBase: "integer",
|
||||
wantArray: true,
|
||||
wantKnown: true,
|
||||
},
|
||||
{
|
||||
input: "citext[]",
|
||||
wantBase: "citext",
|
||||
wantCanonicalBase: "citext",
|
||||
wantArray: true,
|
||||
wantKnown: true,
|
||||
},
|
||||
{
|
||||
input: "vector(1536)",
|
||||
wantBase: "vector",
|
||||
wantCanonicalBase: "vector",
|
||||
wantKnown: true,
|
||||
wantLength: false,
|
||||
},
|
||||
{
|
||||
input: "numeric(10,2)",
|
||||
wantBase: "numeric",
|
||||
wantCanonicalBase: "numeric",
|
||||
wantKnown: true,
|
||||
wantPrecision: true,
|
||||
},
|
||||
{
|
||||
input: "int4",
|
||||
wantBase: "int4",
|
||||
wantCanonicalBase: "integer",
|
||||
wantKnown: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
base := ExtractBaseTypeLower(tt.input)
|
||||
if base != tt.wantBase {
|
||||
t.Fatalf("ExtractBaseTypeLower(%q) = %q, want %q", tt.input, base, tt.wantBase)
|
||||
}
|
||||
|
||||
canonical := CanonicalizeBaseType(base)
|
||||
if canonical != tt.wantCanonicalBase {
|
||||
t.Fatalf("CanonicalizeBaseType(%q) = %q, want %q", base, canonical, tt.wantCanonicalBase)
|
||||
}
|
||||
|
||||
if IsArrayType(tt.input) != tt.wantArray {
|
||||
t.Fatalf("IsArrayType(%q) = %v, want %v", tt.input, IsArrayType(tt.input), tt.wantArray)
|
||||
}
|
||||
if IsKnownPostgresType(tt.input) != tt.wantKnown {
|
||||
t.Fatalf("IsKnownPostgresType(%q) = %v, want %v", tt.input, IsKnownPostgresType(tt.input), tt.wantKnown)
|
||||
}
|
||||
if SupportsLength(tt.input) != tt.wantLength {
|
||||
t.Fatalf("SupportsLength(%q) = %v, want %v", tt.input, SupportsLength(tt.input), tt.wantLength)
|
||||
}
|
||||
if SupportsPrecision(tt.input) != tt.wantPrecision {
|
||||
t.Fatalf("SupportsPrecision(%q) = %v, want %v", tt.input, SupportsPrecision(tt.input), tt.wantPrecision)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeEquivalentSQLType(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{input: "character varying(255)", want: "varchar(255)"},
|
||||
{input: "varchar(255)", want: "varchar(255)"},
|
||||
{input: "timestamp with time zone", want: "timestamptz"},
|
||||
{input: "timestamptz", want: "timestamptz"},
|
||||
{input: "time without time zone", want: "time"},
|
||||
{input: "character varying(255)[]", want: "varchar(255)[]"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := NormalizeEquivalentSQLType(tt.input)
|
||||
if got != tt.want {
|
||||
t.Fatalf("NormalizeEquivalentSQLType(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquivalentSQLTypeVariants(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want []string
|
||||
}{
|
||||
{input: "character varying(255)", want: []string{"varchar(255)", "character varying(255)"}},
|
||||
{input: "timestamptz", want: []string{"timestamptz", "timestamp with time zone"}},
|
||||
{input: "text[]", want: []string{"text[]"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := EquivalentSQLTypeVariants(tt.input)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("EquivalentSQLTypeVariants(%q) len = %d, want %d (%v)", tt.input, len(got), len(tt.want), got)
|
||||
}
|
||||
for i := range tt.want {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Fatalf("EquivalentSQLTypeVariants(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/pgsql"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
)
|
||||
|
||||
@@ -700,16 +701,22 @@ func (r *Reader) extractBunTag(tag string) string {
|
||||
// parseTypeWithLength parses a type string and extracts length if present
|
||||
// e.g., "varchar(255)" returns ("varchar", 255)
|
||||
func (r *Reader) parseTypeWithLength(typeStr string) (baseType string, length int) {
|
||||
typeStr = strings.TrimSpace(typeStr)
|
||||
baseType = typeStr
|
||||
|
||||
// Check for type with length: varchar(255), char(10), etc.
|
||||
re := regexp.MustCompile(`^([a-zA-Z\s]+)\((\d+)\)$`)
|
||||
matches := re.FindStringSubmatch(typeStr)
|
||||
if len(matches) == 3 {
|
||||
if _, err := fmt.Sscanf(matches[2], "%d", &length); err == nil {
|
||||
baseType = strings.TrimSpace(matches[1])
|
||||
return
|
||||
rawBaseType := strings.TrimSpace(matches[1])
|
||||
if pgsql.SupportsLength(rawBaseType) {
|
||||
if _, err := fmt.Sscanf(matches[2], "%d", &length); err == nil {
|
||||
baseType = pgsql.CanonicalizeBaseType(rawBaseType)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
baseType = typeStr
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -739,7 +746,7 @@ func (r *Reader) goTypeToSQL(expr ast.Expr) string {
|
||||
if t.Sel.Name == "Time" {
|
||||
return "timestamp"
|
||||
}
|
||||
case "resolvespec_common", "sql_types":
|
||||
case "sql_types":
|
||||
return r.sqlTypeToSQL(t.Sel.Name)
|
||||
}
|
||||
}
|
||||
@@ -780,7 +787,7 @@ func (r *Reader) isNullableGoType(expr ast.Expr) bool {
|
||||
case *ast.SelectorExpr:
|
||||
// Check for sql_types nullable types
|
||||
if ident, ok := t.X.(*ast.Ident); ok {
|
||||
if ident.Name == "resolvespec_common" || ident.Name == "sql_types" {
|
||||
if ident.Name == "sql_types" {
|
||||
return strings.HasPrefix(t.Sel.Name, "Sql")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,8 +71,11 @@ func TestReader_ReadDatabase_Simple(t *testing.T) {
|
||||
if !emailCol.NotNull {
|
||||
t.Error("Column 'email' should be NOT NULL (explicit 'notnull' tag)")
|
||||
}
|
||||
if emailCol.Type != "varchar" || emailCol.Length != 255 {
|
||||
t.Errorf("Expected email type 'varchar(255)', got '%s' with length %d", emailCol.Type, emailCol.Length)
|
||||
if emailCol.Type != "varchar" && emailCol.Type != "varchar(255)" {
|
||||
t.Errorf("Expected email type 'varchar' or 'varchar(255)', got '%s' with length %d", emailCol.Type, emailCol.Length)
|
||||
}
|
||||
if emailCol.Length != 255 {
|
||||
t.Errorf("Expected email length 255, got %d", emailCol.Length)
|
||||
}
|
||||
|
||||
// Verify name column - primitive string type should be NOT NULL by default in Bun
|
||||
@@ -356,6 +359,33 @@ func TestReader_ReadDatabase_Complex(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTypeWithLength_PreservesExplicitTypeModifiers(t *testing.T) {
|
||||
reader := &Reader{}
|
||||
|
||||
tests := []struct {
|
||||
input string
|
||||
wantType string
|
||||
wantLength int
|
||||
}{
|
||||
{"varchar(255)", "varchar", 255},
|
||||
{"character varying(120)", "character varying", 120},
|
||||
{"vector(1536)", "vector(1536)", 0},
|
||||
{"numeric(10,2)", "numeric(10,2)", 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
gotType, gotLength := reader.parseTypeWithLength(tt.input)
|
||||
if gotType != tt.wantType {
|
||||
t.Fatalf("parseTypeWithLength(%q) type = %q, want %q", tt.input, gotType, tt.wantType)
|
||||
}
|
||||
if gotLength != tt.wantLength {
|
||||
t.Fatalf("parseTypeWithLength(%q) length = %d, want %d", tt.input, gotLength, tt.wantLength)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReader_ReadSchema(t *testing.T) {
|
||||
opts := &readers.ReaderOptions{
|
||||
FilePath: filepath.Join("..", "..", "..", "tests", "assets", "bun", "simple.go"),
|
||||
@@ -485,9 +515,9 @@ func TestReader_NullableTypes(t *testing.T) {
|
||||
|
||||
// Test all nullability scenarios
|
||||
tests := []struct {
|
||||
column string
|
||||
notNull bool
|
||||
reason string
|
||||
column string
|
||||
notNull bool
|
||||
reason string
|
||||
}{
|
||||
{"id", true, "primary key"},
|
||||
{"user_id", true, "explicit notnull tag"},
|
||||
|
||||
+158
-82
@@ -567,110 +567,182 @@ func (r *Reader) parseDBML(content string) (*models.Database, error) {
|
||||
// parseColumn parses a DBML column definition
|
||||
func (r *Reader) parseColumn(line, tableName, schemaName string) (*models.Column, *models.Constraint) {
|
||||
// Format: column_name type [attributes] // comment
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) < 2 {
|
||||
lineNoComment, inlineComment := splitInlineComment(line)
|
||||
signature, attrs := splitColumnSignatureAndAttrs(lineNoComment)
|
||||
columnName, columnType, ok := parseColumnSignature(signature)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
columnName := stripQuotes(parts[0])
|
||||
columnType := stripQuotes(parts[1])
|
||||
|
||||
column := models.InitColumn(columnName, tableName, schemaName)
|
||||
column.Type = columnType
|
||||
|
||||
var constraint *models.Constraint
|
||||
|
||||
// Parse attributes in brackets
|
||||
if strings.Contains(line, "[") && strings.Contains(line, "]") {
|
||||
attrStart := strings.Index(line, "[")
|
||||
attrEnd := strings.Index(line, "]")
|
||||
if attrStart < attrEnd {
|
||||
attrs := line[attrStart+1 : attrEnd]
|
||||
attrList := strings.Split(attrs, ",")
|
||||
if attrs != "" {
|
||||
attrList := strings.Split(attrs, ",")
|
||||
|
||||
for _, attr := range attrList {
|
||||
attr = strings.TrimSpace(attr)
|
||||
for _, attr := range attrList {
|
||||
attr = strings.TrimSpace(attr)
|
||||
|
||||
if strings.Contains(attr, "primary key") || attr == "pk" {
|
||||
column.IsPrimaryKey = true
|
||||
column.NotNull = true
|
||||
} else if strings.Contains(attr, "not null") {
|
||||
column.NotNull = true
|
||||
} else if attr == "increment" {
|
||||
column.AutoIncrement = true
|
||||
} else if strings.HasPrefix(attr, "default:") {
|
||||
defaultVal := strings.TrimSpace(strings.TrimPrefix(attr, "default:"))
|
||||
column.Default = strings.Trim(defaultVal, "'\"")
|
||||
} else if attr == "unique" {
|
||||
// Create a unique constraint
|
||||
// Clean table name by removing leading underscores to avoid double underscores
|
||||
cleanTableName := strings.TrimLeft(tableName, "_")
|
||||
uniqueConstraint := models.InitConstraint(
|
||||
fmt.Sprintf("ukey_%s_%s", cleanTableName, columnName),
|
||||
models.UniqueConstraint,
|
||||
)
|
||||
uniqueConstraint.Schema = schemaName
|
||||
uniqueConstraint.Table = tableName
|
||||
uniqueConstraint.Columns = []string{columnName}
|
||||
// Store it to be added later
|
||||
if constraint == nil {
|
||||
constraint = uniqueConstraint
|
||||
}
|
||||
} else if strings.HasPrefix(attr, "note:") {
|
||||
// Parse column note/comment
|
||||
note := strings.TrimSpace(strings.TrimPrefix(attr, "note:"))
|
||||
column.Comment = strings.Trim(note, "'\"")
|
||||
} else if strings.HasPrefix(attr, "ref:") {
|
||||
// Parse inline reference
|
||||
// DBML semantics depend on context:
|
||||
// - On FK column: ref: < target means "this FK references target"
|
||||
// - On PK column: ref: < source means "source references this PK" (reverse notation)
|
||||
refStr := strings.TrimSpace(strings.TrimPrefix(attr, "ref:"))
|
||||
|
||||
// Check relationship direction operator
|
||||
refOp := strings.TrimSpace(refStr)
|
||||
var isReverse bool
|
||||
if strings.HasPrefix(refOp, "<") {
|
||||
// < means "is referenced by" - only makes sense on PK columns
|
||||
isReverse = column.IsPrimaryKey
|
||||
}
|
||||
// > means "references" - always a forward FK, never reverse
|
||||
|
||||
constraint = r.parseRef(refStr)
|
||||
if constraint != nil {
|
||||
if isReverse {
|
||||
// Reverse: parsed ref is SOURCE, current column is TARGET
|
||||
// Constraint should be ON the source table
|
||||
constraint.Schema = constraint.ReferencedSchema
|
||||
constraint.Table = constraint.ReferencedTable
|
||||
constraint.Columns = constraint.ReferencedColumns
|
||||
constraint.ReferencedSchema = schemaName
|
||||
constraint.ReferencedTable = tableName
|
||||
constraint.ReferencedColumns = []string{columnName}
|
||||
} else {
|
||||
// Forward: current column is SOURCE, parsed ref is TARGET
|
||||
// Standard FK: constraint is ON current table
|
||||
constraint.Schema = schemaName
|
||||
constraint.Table = tableName
|
||||
constraint.Columns = []string{columnName}
|
||||
}
|
||||
// Generate constraint name based on table and columns
|
||||
constraint.Name = fmt.Sprintf("fk_%s_%s", constraint.Table, strings.Join(constraint.Columns, "_"))
|
||||
if strings.Contains(attr, "primary key") || attr == "pk" {
|
||||
column.IsPrimaryKey = true
|
||||
column.NotNull = true
|
||||
} else if strings.Contains(attr, "not null") {
|
||||
column.NotNull = true
|
||||
} else if attr == "increment" {
|
||||
column.AutoIncrement = true
|
||||
} else if strings.HasPrefix(attr, "default:") {
|
||||
defaultVal := strings.TrimSpace(strings.TrimPrefix(attr, "default:"))
|
||||
column.Default = strings.Trim(defaultVal, "'\"")
|
||||
} else if attr == "unique" {
|
||||
// Create a unique constraint
|
||||
// Clean table name by removing leading underscores to avoid double underscores
|
||||
cleanTableName := strings.TrimLeft(tableName, "_")
|
||||
uniqueConstraint := models.InitConstraint(
|
||||
fmt.Sprintf("ukey_%s_%s", cleanTableName, columnName),
|
||||
models.UniqueConstraint,
|
||||
)
|
||||
uniqueConstraint.Schema = schemaName
|
||||
uniqueConstraint.Table = tableName
|
||||
uniqueConstraint.Columns = []string{columnName}
|
||||
// Store it to be added later
|
||||
if constraint == nil {
|
||||
constraint = uniqueConstraint
|
||||
}
|
||||
} else if strings.HasPrefix(attr, "note:") {
|
||||
// Parse column note/comment
|
||||
note := strings.TrimSpace(strings.TrimPrefix(attr, "note:"))
|
||||
column.Comment = strings.Trim(note, "'\"")
|
||||
} else if strings.HasPrefix(attr, "ref:") {
|
||||
// Parse inline reference
|
||||
// DBML semantics depend on context:
|
||||
// - On FK column: ref: < target means "this FK references target"
|
||||
// - On PK column: ref: < source means "source references this PK" (reverse notation)
|
||||
refStr := strings.TrimSpace(strings.TrimPrefix(attr, "ref:"))
|
||||
|
||||
// Check relationship direction operator
|
||||
refOp := strings.TrimSpace(refStr)
|
||||
var isReverse bool
|
||||
if strings.HasPrefix(refOp, "<") {
|
||||
// < means "is referenced by" - only makes sense on PK columns
|
||||
isReverse = column.IsPrimaryKey
|
||||
}
|
||||
// > means "references" - always a forward FK, never reverse
|
||||
|
||||
constraint = r.parseRef(refStr)
|
||||
if constraint != nil {
|
||||
if isReverse {
|
||||
// Reverse: parsed ref is SOURCE, current column is TARGET
|
||||
// Constraint should be ON the source table
|
||||
constraint.Schema = constraint.ReferencedSchema
|
||||
constraint.Table = constraint.ReferencedTable
|
||||
constraint.Columns = constraint.ReferencedColumns
|
||||
constraint.ReferencedSchema = schemaName
|
||||
constraint.ReferencedTable = tableName
|
||||
constraint.ReferencedColumns = []string{columnName}
|
||||
} else {
|
||||
// Forward: current column is SOURCE, parsed ref is TARGET
|
||||
// Standard FK: constraint is ON current table
|
||||
constraint.Schema = schemaName
|
||||
constraint.Table = tableName
|
||||
constraint.Columns = []string{columnName}
|
||||
}
|
||||
// Generate constraint name based on table and columns
|
||||
constraint.Name = fmt.Sprintf("fk_%s_%s", constraint.Table, strings.Join(constraint.Columns, "_"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse inline comment
|
||||
if strings.Contains(line, "//") {
|
||||
commentStart := strings.Index(line, "//")
|
||||
column.Comment = strings.TrimSpace(line[commentStart+2:])
|
||||
if inlineComment != "" {
|
||||
column.Comment = inlineComment
|
||||
}
|
||||
|
||||
return column, constraint
|
||||
}
|
||||
|
||||
func splitInlineComment(line string) (content string, inlineComment string) {
|
||||
commentStart := strings.Index(line, "//")
|
||||
if commentStart == -1 {
|
||||
return line, ""
|
||||
}
|
||||
|
||||
return strings.TrimSpace(line[:commentStart]), strings.TrimSpace(line[commentStart+2:])
|
||||
}
|
||||
|
||||
func splitColumnSignatureAndAttrs(line string) (signature string, attrs string) {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || !strings.HasSuffix(trimmed, "]") {
|
||||
return trimmed, ""
|
||||
}
|
||||
|
||||
bracketDepth := 0
|
||||
for i := len(trimmed) - 1; i >= 0; i-- {
|
||||
switch trimmed[i] {
|
||||
case ']':
|
||||
bracketDepth++
|
||||
case '[':
|
||||
bracketDepth--
|
||||
if bracketDepth == 0 {
|
||||
// DBML attributes are a trailing [ ... ] block preceded by whitespace.
|
||||
// This avoids confusing array types like text[] with attribute blocks.
|
||||
if i > 0 && (trimmed[i-1] == ' ' || trimmed[i-1] == '\t') {
|
||||
return strings.TrimSpace(trimmed[:i]), strings.TrimSpace(trimmed[i+1 : len(trimmed)-1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed, ""
|
||||
}
|
||||
|
||||
func parseColumnSignature(signature string) (columnName string, columnType string, ok bool) {
|
||||
signature = strings.TrimSpace(signature)
|
||||
if signature == "" {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
var splitAt int
|
||||
if signature[0] == '"' || signature[0] == '\'' {
|
||||
quote := signature[0]
|
||||
splitAt = 1
|
||||
for splitAt < len(signature) {
|
||||
if signature[splitAt] == quote {
|
||||
splitAt++
|
||||
break
|
||||
}
|
||||
splitAt++
|
||||
}
|
||||
} else {
|
||||
for splitAt < len(signature) && signature[splitAt] != ' ' && signature[splitAt] != '\t' {
|
||||
splitAt++
|
||||
}
|
||||
}
|
||||
|
||||
if splitAt <= 0 || splitAt >= len(signature) {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
columnName = stripQuotes(strings.TrimSpace(signature[:splitAt]))
|
||||
columnType = stripWrappingQuotes(strings.TrimSpace(signature[splitAt:]))
|
||||
if columnName == "" || columnType == "" {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
return columnName, columnType, true
|
||||
}
|
||||
|
||||
func stripWrappingQuotes(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) >= 2 && ((s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'')) {
|
||||
return s[1 : len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// parseIndex parses a DBML index definition
|
||||
func (r *Reader) parseIndex(line, tableName, schemaName string) *models.Index {
|
||||
// Format: (columns) [attributes] OR columnname [attributes]
|
||||
@@ -832,7 +904,11 @@ func (r *Reader) parseRef(refStr string) *models.Constraint {
|
||||
for _, action := range actionList {
|
||||
action = strings.TrimSpace(action)
|
||||
|
||||
if strings.HasPrefix(action, "ondelete:") {
|
||||
if strings.HasPrefix(action, "delete:") {
|
||||
constraint.OnDelete = strings.TrimSpace(strings.TrimPrefix(action, "delete:"))
|
||||
} else if strings.HasPrefix(action, "update:") {
|
||||
constraint.OnUpdate = strings.TrimSpace(strings.TrimPrefix(action, "update:"))
|
||||
} else if strings.HasPrefix(action, "ondelete:") {
|
||||
constraint.OnDelete = strings.TrimSpace(strings.TrimPrefix(action, "ondelete:"))
|
||||
} else if strings.HasPrefix(action, "onupdate:") {
|
||||
constraint.OnUpdate = strings.TrimSpace(strings.TrimPrefix(action, "onupdate:"))
|
||||
|
||||
@@ -839,6 +839,67 @@ func TestConstraintNaming(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseColumn_PostgresTypes(t *testing.T) {
|
||||
reader := &Reader{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
line string
|
||||
wantName string
|
||||
wantType string
|
||||
wantNotNull bool
|
||||
wantComment string
|
||||
}{
|
||||
{
|
||||
name: "array type with attrs",
|
||||
line: "tags text[] [not null]",
|
||||
wantName: "tags",
|
||||
wantType: "text[]",
|
||||
wantNotNull: true,
|
||||
},
|
||||
{
|
||||
name: "vector with dimension",
|
||||
line: "embedding vector(1536)",
|
||||
wantName: "embedding",
|
||||
wantType: "vector(1536)",
|
||||
},
|
||||
{
|
||||
name: "multi word timestamp type",
|
||||
line: "published_at timestamp with time zone",
|
||||
wantName: "published_at",
|
||||
wantType: "timestamp with time zone",
|
||||
},
|
||||
{
|
||||
name: "array type with inline comment",
|
||||
line: "labels varchar(20)[] // column labels",
|
||||
wantName: "labels",
|
||||
wantType: "varchar(20)[]",
|
||||
wantComment: "column labels",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
col, _ := reader.parseColumn(tt.line, "events", "public")
|
||||
if col == nil {
|
||||
t.Fatalf("parseColumn() returned nil column")
|
||||
}
|
||||
if col.Name != tt.wantName {
|
||||
t.Errorf("column name = %q, want %q", col.Name, tt.wantName)
|
||||
}
|
||||
if col.Type != tt.wantType {
|
||||
t.Errorf("column type = %q, want %q", col.Type, tt.wantType)
|
||||
}
|
||||
if col.NotNull != tt.wantNotNull {
|
||||
t.Errorf("column not null = %v, want %v", col.NotNull, tt.wantNotNull)
|
||||
}
|
||||
if col.Comment != tt.wantComment {
|
||||
t.Errorf("column comment = %q, want %q", col.Comment, tt.wantComment)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func getKeys[V any](m map[string]V) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/pgsql"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
)
|
||||
|
||||
@@ -232,7 +233,19 @@ func (r *Reader) convertField(dctxField *models.DCTXField, tableName string) ([]
|
||||
|
||||
// mapDataType maps Clarion data types to SQL types
|
||||
func (r *Reader) mapDataType(clarionType string, size int) (sqlType string, precision int) {
|
||||
switch strings.ToUpper(clarionType) {
|
||||
trimmedType := strings.TrimSpace(clarionType)
|
||||
|
||||
// Preserve known PostgreSQL types (including arrays and extension types)
|
||||
// from DCTX input instead of coercing them to generic text.
|
||||
if pgsql.IsKnownPostgresType(trimmedType) {
|
||||
pgType := canonicalizePostgresType(trimmedType)
|
||||
if !pgsql.HasExplicitTypeModifier(pgType) && size > 0 && pgsql.SupportsLength(pgType) {
|
||||
return pgType, size
|
||||
}
|
||||
return pgType, 0
|
||||
}
|
||||
|
||||
switch strings.ToUpper(trimmedType) {
|
||||
case "LONG":
|
||||
if size == 8 {
|
||||
return "bigint", 0
|
||||
@@ -306,6 +319,32 @@ func (r *Reader) mapDataType(clarionType string, size int) (sqlType string, prec
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalizePostgresType(typeStr string) string {
|
||||
t := strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(typeStr)), " "))
|
||||
if t == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Handle array suffixes
|
||||
arrayCount := 0
|
||||
for strings.HasSuffix(t, "[]") {
|
||||
arrayCount++
|
||||
t = strings.TrimSpace(strings.TrimSuffix(t, "[]"))
|
||||
}
|
||||
|
||||
// Handle optional type modifier
|
||||
modifier := ""
|
||||
if idx := strings.Index(t, "("); idx > 0 {
|
||||
if end := strings.LastIndex(t, ")"); end > idx {
|
||||
modifier = t[idx : end+1]
|
||||
t = strings.TrimSpace(t[:idx])
|
||||
}
|
||||
}
|
||||
|
||||
base := pgsql.CanonicalizeBaseType(t)
|
||||
return base + modifier + strings.Repeat("[]", arrayCount)
|
||||
}
|
||||
|
||||
// processKeys processes DCTX keys and converts them to indexes and primary keys
|
||||
func (r *Reader) processKeys(dctxTable *models.DCTXTable, table *models.Table, fieldGuidMap map[string]string) error {
|
||||
for _, dctxKey := range dctxTable.Keys {
|
||||
|
||||
@@ -493,3 +493,55 @@ func TestRelationships(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapDataType_PostgresTypes(t *testing.T) {
|
||||
reader := &Reader{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
inputType string
|
||||
size int
|
||||
wantType string
|
||||
wantLength int
|
||||
}{
|
||||
{
|
||||
name: "integer array preserved",
|
||||
inputType: "integer[]",
|
||||
wantType: "integer[]",
|
||||
},
|
||||
{
|
||||
name: "citext array preserved",
|
||||
inputType: "citext[]",
|
||||
wantType: "citext[]",
|
||||
},
|
||||
{
|
||||
name: "vector modifier preserved",
|
||||
inputType: "vector(1536)",
|
||||
wantType: "vector(1536)",
|
||||
},
|
||||
{
|
||||
name: "alias canonicalized in array",
|
||||
inputType: "int4[]",
|
||||
wantType: "integer[]",
|
||||
},
|
||||
{
|
||||
name: "varchar length from size",
|
||||
inputType: "varchar",
|
||||
size: 120,
|
||||
wantType: "varchar",
|
||||
wantLength: 120,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotType, gotLength := reader.mapDataType(tt.inputType, tt.size)
|
||||
if gotType != tt.wantType {
|
||||
t.Fatalf("mapDataType(%q, %d) type = %q, want %q", tt.inputType, tt.size, gotType, tt.wantType)
|
||||
}
|
||||
if gotLength != tt.wantLength {
|
||||
t.Fatalf("mapDataType(%q, %d) length = %d, want %d", tt.inputType, tt.size, gotLength, tt.wantLength)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// Package readers provides interfaces and implementations for reading database schemas
|
||||
// from various input formats and data sources.
|
||||
//
|
||||
// # Overview
|
||||
//
|
||||
// The readers package defines a common Reader interface that all format-specific readers
|
||||
// implement. This allows RelSpec to read database schemas from multiple sources including:
|
||||
// - Live databases (PostgreSQL, SQLite)
|
||||
// - Schema definition files (DBML, DCTX, DrawDB, GraphQL)
|
||||
// - ORM model files (GORM, Bun, Drizzle, Prisma, TypeORM)
|
||||
// - Data interchange formats (JSON, YAML)
|
||||
//
|
||||
// # Architecture
|
||||
//
|
||||
// Each reader implementation is located in its own subpackage (e.g., pkg/readers/dbml,
|
||||
// pkg/readers/pgsql) and implements the Reader interface, supporting three levels of
|
||||
// granularity:
|
||||
// - ReadDatabase() - Read complete database with all schemas
|
||||
// - ReadSchema() - Read single schema with all tables
|
||||
// - ReadTable() - Read single table with all columns and metadata
|
||||
//
|
||||
// # Usage
|
||||
//
|
||||
// Readers are instantiated with ReaderOptions containing source-specific configuration:
|
||||
//
|
||||
// // Read from file
|
||||
// reader := dbml.NewReader(&readers.ReaderOptions{
|
||||
// FilePath: "schema.dbml",
|
||||
// })
|
||||
// db, err := reader.ReadDatabase()
|
||||
//
|
||||
// // Read from database
|
||||
// reader := pgsql.NewReader(&readers.ReaderOptions{
|
||||
// ConnectionString: "postgres://user:pass@localhost/mydb",
|
||||
// })
|
||||
// db, err := reader.ReadDatabase()
|
||||
//
|
||||
// # Supported Formats
|
||||
//
|
||||
// - dbml: Database Markup Language files
|
||||
// - dctx: DCTX schema files
|
||||
// - drawdb: DrawDB JSON format
|
||||
// - graphql: GraphQL schema definition language
|
||||
// - json: JSON database schema
|
||||
// - yaml: YAML database schema
|
||||
// - gorm: Go GORM model structs
|
||||
// - bun: Go Bun model structs
|
||||
// - drizzle: TypeScript Drizzle ORM schemas
|
||||
// - prisma: Prisma schema language
|
||||
// - typeorm: TypeScript TypeORM entities
|
||||
// - pgsql: PostgreSQL live database introspection
|
||||
// - sqlite: SQLite database files
|
||||
package readers
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/pgsql"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers/drawdb"
|
||||
)
|
||||
@@ -231,30 +232,35 @@ func (r *Reader) convertToColumn(field *drawdb.DrawDBField, tableName, schemaNam
|
||||
|
||||
// Parse type and dimensions
|
||||
typeStr := field.Type
|
||||
typeStr = strings.TrimSpace(typeStr)
|
||||
column.Type = typeStr
|
||||
|
||||
// Try to extract length/precision from type string like "varchar(255)" or "decimal(10,2)"
|
||||
if strings.Contains(typeStr, "(") {
|
||||
parts := strings.Split(typeStr, "(")
|
||||
column.Type = parts[0]
|
||||
baseType := strings.TrimSpace(parts[0])
|
||||
|
||||
if len(parts) > 1 {
|
||||
dimensions := strings.TrimSuffix(parts[1], ")")
|
||||
if strings.Contains(dimensions, ",") {
|
||||
// Precision and scale (e.g., decimal(10,2))
|
||||
dims := strings.Split(dimensions, ",")
|
||||
if precision, err := strconv.Atoi(strings.TrimSpace(dims[0])); err == nil {
|
||||
column.Precision = precision
|
||||
}
|
||||
if len(dims) > 1 {
|
||||
if scale, err := strconv.Atoi(strings.TrimSpace(dims[1])); err == nil {
|
||||
column.Scale = scale
|
||||
// Precision and scale (e.g., decimal(10,2), numeric(10,2))
|
||||
if pgsql.SupportsPrecision(baseType) {
|
||||
dims := strings.Split(dimensions, ",")
|
||||
if precision, err := strconv.Atoi(strings.TrimSpace(dims[0])); err == nil {
|
||||
column.Precision = precision
|
||||
}
|
||||
if len(dims) > 1 {
|
||||
if scale, err := strconv.Atoi(strings.TrimSpace(dims[1])); err == nil {
|
||||
column.Scale = scale
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Just length (e.g., varchar(255))
|
||||
if length, err := strconv.Atoi(dimensions); err == nil {
|
||||
column.Length = length
|
||||
if pgsql.SupportsLength(baseType) {
|
||||
if length, err := strconv.Atoi(dimensions); err == nil {
|
||||
column.Length = length
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers/drawdb"
|
||||
)
|
||||
|
||||
func TestReader_ReadDatabase_Simple(t *testing.T) {
|
||||
@@ -288,6 +289,61 @@ func TestReader_ReadDatabase_Complex(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToColumn_PreservesExplicitTypeModifiers(t *testing.T) {
|
||||
reader := &Reader{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
fieldType string
|
||||
wantType string
|
||||
wantLength int
|
||||
wantPrecision int
|
||||
wantScale int
|
||||
}{
|
||||
{
|
||||
name: "varchar with length",
|
||||
fieldType: "varchar(255)",
|
||||
wantType: "varchar(255)",
|
||||
wantLength: 255,
|
||||
},
|
||||
{
|
||||
name: "numeric precision/scale",
|
||||
fieldType: "numeric(10,2)",
|
||||
wantType: "numeric(10,2)",
|
||||
wantPrecision: 10,
|
||||
wantScale: 2,
|
||||
},
|
||||
{
|
||||
name: "custom vector modifier",
|
||||
fieldType: "vector(1536)",
|
||||
wantType: "vector(1536)",
|
||||
wantLength: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
field := &drawdb.DrawDBField{
|
||||
Name: tt.name,
|
||||
Type: tt.fieldType,
|
||||
}
|
||||
col := reader.convertToColumn(field, "events", "public")
|
||||
if col.Type != tt.wantType {
|
||||
t.Fatalf("column type = %q, want %q", col.Type, tt.wantType)
|
||||
}
|
||||
if col.Length != tt.wantLength {
|
||||
t.Fatalf("column length = %d, want %d", col.Length, tt.wantLength)
|
||||
}
|
||||
if col.Precision != tt.wantPrecision {
|
||||
t.Fatalf("column precision = %d, want %d", col.Precision, tt.wantPrecision)
|
||||
}
|
||||
if col.Scale != tt.wantScale {
|
||||
t.Fatalf("column scale = %d, want %d", col.Scale, tt.wantScale)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReader_ReadSchema(t *testing.T) {
|
||||
opts := &readers.ReaderOptions{
|
||||
FilePath: filepath.Join("..", "..", "..", "tests", "assets", "drawdb", "simple.json"),
|
||||
|
||||
+12
-17
@@ -12,6 +12,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/pgsql"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
)
|
||||
|
||||
@@ -676,19 +677,8 @@ 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
|
||||
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())
|
||||
// Remove "Model" prefix if present, use the name as-is without transformation
|
||||
return strings.TrimPrefix(structName, "Model")
|
||||
}
|
||||
|
||||
// parseColumn parses a struct field into a Column model
|
||||
@@ -784,11 +774,14 @@ func (r *Reader) extractGormTag(tag string) string {
|
||||
// parseTypeWithLength parses a type string and extracts length if present
|
||||
// e.g., "varchar(255)" returns ("varchar", 255)
|
||||
func (r *Reader) parseTypeWithLength(typeStr string) (baseType string, length int) {
|
||||
typeStr = strings.TrimSpace(typeStr)
|
||||
baseType = typeStr
|
||||
|
||||
// Check for type with length: varchar(255), char(10), etc.
|
||||
// Also handle precision/scale: numeric(10,2)
|
||||
if strings.Contains(typeStr, "(") {
|
||||
idx := strings.Index(typeStr, "(")
|
||||
baseType = strings.TrimSpace(typeStr[:idx])
|
||||
rawBaseType := strings.TrimSpace(typeStr[:idx])
|
||||
|
||||
// Extract numbers from parentheses
|
||||
parens := typeStr[idx+1:]
|
||||
@@ -796,14 +789,16 @@ func (r *Reader) parseTypeWithLength(typeStr string) (baseType string, length in
|
||||
parens = parens[:endIdx]
|
||||
}
|
||||
|
||||
// For now, just handle single number (length)
|
||||
if !strings.Contains(parens, ",") {
|
||||
// Only treat as "length" for text-ish SQL types.
|
||||
// This avoids converting custom modifiers like vector(1536) into Length.
|
||||
if pgsql.SupportsLength(rawBaseType) && !strings.Contains(parens, ",") {
|
||||
if _, err := fmt.Sscanf(parens, "%d", &length); err == nil {
|
||||
baseType = pgsql.CanonicalizeBaseType(rawBaseType)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
baseType = typeStr
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -71,8 +71,11 @@ func TestReader_ReadDatabase_Simple(t *testing.T) {
|
||||
if !emailCol.NotNull {
|
||||
t.Error("Column 'email' should be NOT NULL (explicit 'not null' tag)")
|
||||
}
|
||||
if emailCol.Type != "varchar" || emailCol.Length != 255 {
|
||||
t.Errorf("Expected email type 'varchar(255)', got '%s' with length %d", emailCol.Type, emailCol.Length)
|
||||
if emailCol.Type != "varchar" && emailCol.Type != "varchar(255)" {
|
||||
t.Errorf("Expected email type 'varchar' or 'varchar(255)', got '%s' with length %d", emailCol.Type, emailCol.Length)
|
||||
}
|
||||
if emailCol.Length != 255 {
|
||||
t.Errorf("Expected email length 255, got %d", emailCol.Length)
|
||||
}
|
||||
|
||||
// Verify name column - primitive string type should be NOT NULL by default
|
||||
@@ -363,6 +366,33 @@ func TestReader_ReadDatabase_Complex(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTypeWithLength_PreservesExplicitTypeModifiers(t *testing.T) {
|
||||
reader := &Reader{}
|
||||
|
||||
tests := []struct {
|
||||
input string
|
||||
wantType string
|
||||
wantLength int
|
||||
}{
|
||||
{"varchar(255)", "varchar", 255},
|
||||
{"character varying(120)", "character varying", 120},
|
||||
{"vector(1536)", "vector(1536)", 0},
|
||||
{"numeric(10,2)", "numeric(10,2)", 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
gotType, gotLength := reader.parseTypeWithLength(tt.input)
|
||||
if gotType != tt.wantType {
|
||||
t.Fatalf("parseTypeWithLength(%q) type = %q, want %q", tt.input, gotType, tt.wantType)
|
||||
}
|
||||
if gotLength != tt.wantLength {
|
||||
t.Fatalf("parseTypeWithLength(%q) length = %d, want %d", tt.input, gotLength, tt.wantLength)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReader_ReadSchema(t *testing.T) {
|
||||
opts := &readers.ReaderOptions{
|
||||
FilePath: filepath.Join("..", "..", "..", "tests", "assets", "gorm", "simple.go"),
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# MSSQL Reader
|
||||
|
||||
Reads database schema from Microsoft SQL Server databases using a live connection.
|
||||
|
||||
## Features
|
||||
|
||||
- **Live Connection**: Connects to MSSQL databases using the Microsoft ODBC driver
|
||||
- **Multi-Schema Support**: Reads multiple schemas with full support for user-defined schemas
|
||||
- **Comprehensive Metadata**: Reads tables, columns, constraints, indexes, and extended properties
|
||||
- **Type Mapping**: Converts MSSQL types to canonical types for cross-database compatibility
|
||||
- **Extended Properties**: Extracts table and column descriptions from MS_Description
|
||||
- **Identity Columns**: Maps IDENTITY columns to AutoIncrement
|
||||
- **Relationships**: Derives relationships from foreign key constraints
|
||||
|
||||
## Connection String Format
|
||||
|
||||
```
|
||||
sqlserver://[user[:password]@][host][:port][?query]
|
||||
```
|
||||
|
||||
Examples:
|
||||
```
|
||||
sqlserver://sa:password@localhost/dbname
|
||||
sqlserver://user:pass@192.168.1.100:1433/production
|
||||
sqlserver://localhost/testdb?encrypt=disable
|
||||
```
|
||||
|
||||
## Supported Constraints
|
||||
|
||||
- Primary Keys
|
||||
- Foreign Keys (with ON DELETE and ON UPDATE actions)
|
||||
- Unique Constraints
|
||||
- Check Constraints
|
||||
|
||||
## Type Mappings
|
||||
|
||||
| MSSQL Type | Canonical Type |
|
||||
|------------|----------------|
|
||||
| INT | int |
|
||||
| BIGINT | int64 |
|
||||
| SMALLINT | int16 |
|
||||
| TINYINT | int8 |
|
||||
| BIT | bool |
|
||||
| REAL | float32 |
|
||||
| FLOAT | float64 |
|
||||
| NUMERIC, DECIMAL | decimal |
|
||||
| NVARCHAR, VARCHAR | string |
|
||||
| DATETIME2 | timestamp |
|
||||
| DATETIMEOFFSET | timestamptz |
|
||||
| UNIQUEIDENTIFIER | uuid |
|
||||
| VARBINARY | bytea |
|
||||
| DATE | date |
|
||||
| TIME | time |
|
||||
|
||||
## Usage
|
||||
|
||||
```go
|
||||
import "git.warky.dev/wdevs/relspecgo/pkg/readers/mssql"
|
||||
import "git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
|
||||
reader := mssql.NewReader(&readers.ReaderOptions{
|
||||
ConnectionString: "sqlserver://sa:password@localhost/mydb",
|
||||
})
|
||||
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Process schema...
|
||||
for _, schema := range db.Schemas {
|
||||
fmt.Printf("Schema: %s\n", schema.Name)
|
||||
for _, table := range schema.Tables {
|
||||
fmt.Printf(" Table: %s\n", table.Name)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Run tests with:
|
||||
```bash
|
||||
go test ./pkg/readers/mssql/...
|
||||
```
|
||||
|
||||
For integration testing with a live MSSQL database:
|
||||
```bash
|
||||
docker-compose up -d mssql
|
||||
go test -tags=integration ./pkg/readers/mssql/...
|
||||
docker-compose down
|
||||
```
|
||||
@@ -0,0 +1,416 @@
|
||||
package mssql
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
// querySchemas retrieves all user-defined schemas from the database
|
||||
func (r *Reader) querySchemas() ([]*models.Schema, error) {
|
||||
query := `
|
||||
SELECT s.name, ISNULL(ep.value, '') as description
|
||||
FROM sys.schemas s
|
||||
LEFT JOIN sys.extended_properties ep
|
||||
ON ep.major_id = s.schema_id
|
||||
AND ep.minor_id = 0
|
||||
AND ep.class = 3
|
||||
AND ep.name = 'MS_Description'
|
||||
WHERE s.name NOT IN ('dbo', 'guest', 'INFORMATION_SCHEMA', 'sys')
|
||||
ORDER BY s.name
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(r.ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
schemas := make([]*models.Schema, 0)
|
||||
for rows.Next() {
|
||||
var name, description string
|
||||
|
||||
if err := rows.Scan(&name, &description); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
schema := models.InitSchema(name)
|
||||
if description != "" {
|
||||
schema.Description = description
|
||||
}
|
||||
|
||||
schemas = append(schemas, schema)
|
||||
}
|
||||
|
||||
// Always include dbo schema if it has tables
|
||||
dboSchema := models.InitSchema("dbo")
|
||||
schemas = append(schemas, dboSchema)
|
||||
|
||||
return schemas, rows.Err()
|
||||
}
|
||||
|
||||
// queryTables retrieves all tables for a given schema
|
||||
func (r *Reader) queryTables(schemaName string) ([]*models.Table, error) {
|
||||
query := `
|
||||
SELECT t.table_schema, t.table_name, ISNULL(ep.value, '') as description
|
||||
FROM information_schema.tables t
|
||||
LEFT JOIN sys.extended_properties ep
|
||||
ON ep.major_id = OBJECT_ID(QUOTENAME(t.table_schema) + '.' + QUOTENAME(t.table_name))
|
||||
AND ep.minor_id = 0
|
||||
AND ep.class = 1
|
||||
AND ep.name = 'MS_Description'
|
||||
WHERE t.table_schema = ? AND t.table_type = 'BASE TABLE'
|
||||
ORDER BY t.table_name
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(r.ctx, query, schemaName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
tables := make([]*models.Table, 0)
|
||||
for rows.Next() {
|
||||
var schema, tableName, description string
|
||||
|
||||
if err := rows.Scan(&schema, &tableName, &description); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
table := models.InitTable(tableName, schema)
|
||||
if description != "" {
|
||||
table.Description = description
|
||||
}
|
||||
|
||||
tables = append(tables, table)
|
||||
}
|
||||
|
||||
return tables, rows.Err()
|
||||
}
|
||||
|
||||
// queryColumns retrieves all columns for tables in a schema
|
||||
// Returns map[schema.table]map[columnName]*Column
|
||||
func (r *Reader) queryColumns(schemaName string) (map[string]map[string]*models.Column, error) {
|
||||
query := `
|
||||
SELECT
|
||||
c.table_schema,
|
||||
c.table_name,
|
||||
c.column_name,
|
||||
c.ordinal_position,
|
||||
c.column_default,
|
||||
c.is_nullable,
|
||||
c.data_type,
|
||||
c.character_maximum_length,
|
||||
c.numeric_precision,
|
||||
c.numeric_scale,
|
||||
ISNULL(ep.value, '') as description,
|
||||
COLUMNPROPERTY(OBJECT_ID(QUOTENAME(c.table_schema) + '.' + QUOTENAME(c.table_name)), c.column_name, 'IsIdentity') as is_identity
|
||||
FROM information_schema.columns c
|
||||
LEFT JOIN sys.extended_properties ep
|
||||
ON ep.major_id = OBJECT_ID(QUOTENAME(c.table_schema) + '.' + QUOTENAME(c.table_name))
|
||||
AND ep.minor_id = COLUMNPROPERTY(OBJECT_ID(QUOTENAME(c.table_schema) + '.' + QUOTENAME(c.table_name)), c.column_name, 'ColumnId')
|
||||
AND ep.class = 1
|
||||
AND ep.name = 'MS_Description'
|
||||
WHERE c.table_schema = ?
|
||||
ORDER BY c.table_schema, c.table_name, c.ordinal_position
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(r.ctx, query, schemaName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
columnsMap := make(map[string]map[string]*models.Column)
|
||||
|
||||
for rows.Next() {
|
||||
var schema, tableName, columnName, isNullable, dataType, description string
|
||||
var ordinalPosition int
|
||||
var columnDefault, charMaxLength, numPrecision, numScale, isIdentity *int
|
||||
|
||||
if err := rows.Scan(&schema, &tableName, &columnName, &ordinalPosition, &columnDefault, &isNullable, &dataType, &charMaxLength, &numPrecision, &numScale, &description, &isIdentity); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
column := models.InitColumn(columnName, tableName, schema)
|
||||
column.Type = r.mapDataType(dataType)
|
||||
column.NotNull = (isNullable == "NO")
|
||||
column.Sequence = uint(ordinalPosition)
|
||||
|
||||
if description != "" {
|
||||
column.Description = description
|
||||
}
|
||||
|
||||
// Check if this is an identity column (auto-increment)
|
||||
if isIdentity != nil && *isIdentity == 1 {
|
||||
column.AutoIncrement = true
|
||||
}
|
||||
|
||||
if charMaxLength != nil && *charMaxLength > 0 {
|
||||
column.Length = *charMaxLength
|
||||
}
|
||||
|
||||
if numPrecision != nil && *numPrecision > 0 {
|
||||
column.Precision = *numPrecision
|
||||
}
|
||||
|
||||
if numScale != nil && *numScale > 0 {
|
||||
column.Scale = *numScale
|
||||
}
|
||||
|
||||
// Create table key
|
||||
tableKey := schema + "." + tableName
|
||||
if columnsMap[tableKey] == nil {
|
||||
columnsMap[tableKey] = make(map[string]*models.Column)
|
||||
}
|
||||
columnsMap[tableKey][columnName] = column
|
||||
}
|
||||
|
||||
return columnsMap, rows.Err()
|
||||
}
|
||||
|
||||
// queryPrimaryKeys retrieves all primary key constraints for a schema
|
||||
// Returns map[schema.table]*Constraint
|
||||
func (r *Reader) queryPrimaryKeys(schemaName string) (map[string]*models.Constraint, error) {
|
||||
query := `
|
||||
SELECT
|
||||
s.name as schema_name,
|
||||
t.name as table_name,
|
||||
i.name as constraint_name,
|
||||
STRING_AGG(c.name, ',') WITHIN GROUP (ORDER BY ic.key_ordinal) as columns
|
||||
FROM sys.tables t
|
||||
INNER JOIN sys.indexes i ON t.object_id = i.object_id AND i.is_primary_key = 1
|
||||
INNER JOIN sys.schemas s ON t.schema_id = s.schema_id
|
||||
INNER JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id
|
||||
INNER JOIN sys.columns c ON t.object_id = c.object_id AND ic.column_id = c.column_id
|
||||
WHERE s.name = ?
|
||||
GROUP BY s.name, t.name, i.name
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(r.ctx, query, schemaName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
primaryKeys := make(map[string]*models.Constraint)
|
||||
|
||||
for rows.Next() {
|
||||
var schema, tableName, constraintName, columnsStr string
|
||||
|
||||
if err := rows.Scan(&schema, &tableName, &constraintName, &columnsStr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
columns := strings.Split(columnsStr, ",")
|
||||
|
||||
constraint := models.InitConstraint(constraintName, models.PrimaryKeyConstraint)
|
||||
constraint.Schema = schema
|
||||
constraint.Table = tableName
|
||||
constraint.Columns = columns
|
||||
|
||||
tableKey := schema + "." + tableName
|
||||
primaryKeys[tableKey] = constraint
|
||||
}
|
||||
|
||||
return primaryKeys, rows.Err()
|
||||
}
|
||||
|
||||
// queryForeignKeys retrieves all foreign key constraints for a schema
|
||||
// Returns map[schema.table][]*Constraint
|
||||
func (r *Reader) queryForeignKeys(schemaName string) (map[string][]*models.Constraint, error) {
|
||||
query := `
|
||||
SELECT
|
||||
s.name as schema_name,
|
||||
t.name as table_name,
|
||||
fk.name as constraint_name,
|
||||
rs.name as referenced_schema,
|
||||
rt.name as referenced_table,
|
||||
STRING_AGG(c.name, ',') WITHIN GROUP (ORDER BY fkc.constraint_column_id) as columns,
|
||||
STRING_AGG(rc.name, ',') WITHIN GROUP (ORDER BY fkc.constraint_column_id) as referenced_columns,
|
||||
fk.delete_referential_action_desc,
|
||||
fk.update_referential_action_desc
|
||||
FROM sys.foreign_keys fk
|
||||
INNER JOIN sys.tables t ON fk.parent_object_id = t.object_id
|
||||
INNER JOIN sys.tables rt ON fk.referenced_object_id = rt.object_id
|
||||
INNER JOIN sys.schemas s ON t.schema_id = s.schema_id
|
||||
INNER JOIN sys.schemas rs ON rt.schema_id = rs.schema_id
|
||||
INNER JOIN sys.foreign_key_columns fkc ON fk.object_id = fkc.constraint_object_id
|
||||
INNER JOIN sys.columns c ON fkc.parent_object_id = c.object_id AND fkc.parent_column_id = c.column_id
|
||||
INNER JOIN sys.columns rc ON fkc.referenced_object_id = rc.object_id AND fkc.referenced_column_id = rc.column_id
|
||||
WHERE s.name = ?
|
||||
GROUP BY s.name, t.name, fk.name, rs.name, rt.name, fk.delete_referential_action_desc, fk.update_referential_action_desc
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(r.ctx, query, schemaName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
foreignKeys := make(map[string][]*models.Constraint)
|
||||
|
||||
for rows.Next() {
|
||||
var schema, tableName, constraintName, refSchema, refTable, columnsStr, refColumnsStr, deleteAction, updateAction string
|
||||
|
||||
if err := rows.Scan(&schema, &tableName, &constraintName, &refSchema, &refTable, &columnsStr, &refColumnsStr, &deleteAction, &updateAction); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
columns := strings.Split(columnsStr, ",")
|
||||
refColumns := strings.Split(refColumnsStr, ",")
|
||||
|
||||
constraint := models.InitConstraint(constraintName, models.ForeignKeyConstraint)
|
||||
constraint.Schema = schema
|
||||
constraint.Table = tableName
|
||||
constraint.Columns = columns
|
||||
constraint.ReferencedSchema = refSchema
|
||||
constraint.ReferencedTable = refTable
|
||||
constraint.ReferencedColumns = refColumns
|
||||
constraint.OnDelete = strings.ToUpper(deleteAction)
|
||||
constraint.OnUpdate = strings.ToUpper(updateAction)
|
||||
|
||||
tableKey := schema + "." + tableName
|
||||
foreignKeys[tableKey] = append(foreignKeys[tableKey], constraint)
|
||||
}
|
||||
|
||||
return foreignKeys, rows.Err()
|
||||
}
|
||||
|
||||
// queryUniqueConstraints retrieves all unique constraints for a schema
|
||||
// Returns map[schema.table][]*Constraint
|
||||
func (r *Reader) queryUniqueConstraints(schemaName string) (map[string][]*models.Constraint, error) {
|
||||
query := `
|
||||
SELECT
|
||||
s.name as schema_name,
|
||||
t.name as table_name,
|
||||
i.name as constraint_name,
|
||||
STRING_AGG(c.name, ',') WITHIN GROUP (ORDER BY ic.key_ordinal) as columns
|
||||
FROM sys.tables t
|
||||
INNER JOIN sys.indexes i ON t.object_id = i.object_id AND i.is_unique = 1 AND i.is_primary_key = 0
|
||||
INNER JOIN sys.schemas s ON t.schema_id = s.schema_id
|
||||
INNER JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id
|
||||
INNER JOIN sys.columns c ON t.object_id = c.object_id AND ic.column_id = c.column_id
|
||||
WHERE s.name = ?
|
||||
GROUP BY s.name, t.name, i.name
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(r.ctx, query, schemaName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
uniqueConstraints := make(map[string][]*models.Constraint)
|
||||
|
||||
for rows.Next() {
|
||||
var schema, tableName, constraintName, columnsStr string
|
||||
|
||||
if err := rows.Scan(&schema, &tableName, &constraintName, &columnsStr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
columns := strings.Split(columnsStr, ",")
|
||||
|
||||
constraint := models.InitConstraint(constraintName, models.UniqueConstraint)
|
||||
constraint.Schema = schema
|
||||
constraint.Table = tableName
|
||||
constraint.Columns = columns
|
||||
|
||||
tableKey := schema + "." + tableName
|
||||
uniqueConstraints[tableKey] = append(uniqueConstraints[tableKey], constraint)
|
||||
}
|
||||
|
||||
return uniqueConstraints, rows.Err()
|
||||
}
|
||||
|
||||
// queryCheckConstraints retrieves all check constraints for a schema
|
||||
// Returns map[schema.table][]*Constraint
|
||||
func (r *Reader) queryCheckConstraints(schemaName string) (map[string][]*models.Constraint, error) {
|
||||
query := `
|
||||
SELECT
|
||||
s.name as schema_name,
|
||||
t.name as table_name,
|
||||
cc.name as constraint_name,
|
||||
cc.definition
|
||||
FROM sys.tables t
|
||||
INNER JOIN sys.check_constraints cc ON t.object_id = cc.parent_object_id
|
||||
INNER JOIN sys.schemas s ON t.schema_id = s.schema_id
|
||||
WHERE s.name = ?
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(r.ctx, query, schemaName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
checkConstraints := make(map[string][]*models.Constraint)
|
||||
|
||||
for rows.Next() {
|
||||
var schema, tableName, constraintName, definition string
|
||||
|
||||
if err := rows.Scan(&schema, &tableName, &constraintName, &definition); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
constraint := models.InitConstraint(constraintName, models.CheckConstraint)
|
||||
constraint.Schema = schema
|
||||
constraint.Table = tableName
|
||||
constraint.Expression = definition
|
||||
|
||||
tableKey := schema + "." + tableName
|
||||
checkConstraints[tableKey] = append(checkConstraints[tableKey], constraint)
|
||||
}
|
||||
|
||||
return checkConstraints, rows.Err()
|
||||
}
|
||||
|
||||
// queryIndexes retrieves all indexes for a schema
|
||||
// Returns map[schema.table][]*Index
|
||||
func (r *Reader) queryIndexes(schemaName string) (map[string][]*models.Index, error) {
|
||||
query := `
|
||||
SELECT
|
||||
s.name as schema_name,
|
||||
t.name as table_name,
|
||||
i.name as index_name,
|
||||
i.is_unique,
|
||||
STRING_AGG(c.name, ',') WITHIN GROUP (ORDER BY ic.key_ordinal) as columns
|
||||
FROM sys.tables t
|
||||
INNER JOIN sys.indexes i ON t.object_id = i.object_id AND i.is_primary_key = 0 AND i.name IS NOT NULL
|
||||
INNER JOIN sys.schemas s ON t.schema_id = s.schema_id
|
||||
INNER JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id
|
||||
INNER JOIN sys.columns c ON t.object_id = c.object_id AND ic.column_id = c.column_id
|
||||
WHERE s.name = ?
|
||||
GROUP BY s.name, t.name, i.name, i.is_unique
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(r.ctx, query, schemaName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
indexes := make(map[string][]*models.Index)
|
||||
|
||||
for rows.Next() {
|
||||
var schema, tableName, indexName, columnsStr string
|
||||
var isUnique int
|
||||
|
||||
if err := rows.Scan(&schema, &tableName, &indexName, &isUnique, &columnsStr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
columns := strings.Split(columnsStr, ",")
|
||||
|
||||
index := models.InitIndex(indexName, tableName, schema)
|
||||
index.Columns = columns
|
||||
index.Unique = (isUnique == 1)
|
||||
index.Type = "btree" // MSSQL uses btree by default
|
||||
|
||||
tableKey := schema + "." + tableName
|
||||
indexes[tableKey] = append(indexes[tableKey], index)
|
||||
}
|
||||
|
||||
return indexes, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package mssql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
_ "github.com/microsoft/go-mssqldb" // MSSQL driver
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/mssql"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
)
|
||||
|
||||
// Reader implements the readers.Reader interface for MSSQL databases
|
||||
type Reader struct {
|
||||
options *readers.ReaderOptions
|
||||
db *sql.DB
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// NewReader creates a new MSSQL reader
|
||||
func NewReader(options *readers.ReaderOptions) *Reader {
|
||||
return &Reader{
|
||||
options: options,
|
||||
ctx: context.Background(),
|
||||
}
|
||||
}
|
||||
|
||||
// ReadDatabase reads the entire database schema from MSSQL
|
||||
func (r *Reader) ReadDatabase() (*models.Database, error) {
|
||||
// Validate connection string
|
||||
if r.options.ConnectionString == "" {
|
||||
return nil, fmt.Errorf("connection string is required")
|
||||
}
|
||||
|
||||
// Connect to the database
|
||||
if err := r.connect(); err != nil {
|
||||
return nil, fmt.Errorf("failed to connect: %w", err)
|
||||
}
|
||||
defer r.close()
|
||||
|
||||
// Get database name
|
||||
var dbName string
|
||||
err := r.db.QueryRowContext(r.ctx, "SELECT DB_NAME()").Scan(&dbName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get database name: %w", err)
|
||||
}
|
||||
|
||||
// Initialize database model
|
||||
db := models.InitDatabase(dbName)
|
||||
db.DatabaseType = models.MSSQLDatabaseType
|
||||
db.SourceFormat = "mssql"
|
||||
|
||||
// Get MSSQL version
|
||||
var version string
|
||||
err = r.db.QueryRowContext(r.ctx, "SELECT @@VERSION").Scan(&version)
|
||||
if err == nil {
|
||||
db.DatabaseVersion = version
|
||||
}
|
||||
|
||||
// Query all schemas
|
||||
schemas, err := r.querySchemas()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query schemas: %w", err)
|
||||
}
|
||||
|
||||
// Process each schema
|
||||
for _, schema := range schemas {
|
||||
// Query tables for this schema
|
||||
tables, err := r.queryTables(schema.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query tables for schema %s: %w", schema.Name, err)
|
||||
}
|
||||
schema.Tables = tables
|
||||
|
||||
// Query columns for tables
|
||||
columnsMap, err := r.queryColumns(schema.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query columns for schema %s: %w", schema.Name, err)
|
||||
}
|
||||
|
||||
// Populate table columns
|
||||
for _, table := range schema.Tables {
|
||||
tableKey := schema.Name + "." + table.Name
|
||||
if cols, exists := columnsMap[tableKey]; exists {
|
||||
table.Columns = cols
|
||||
}
|
||||
}
|
||||
|
||||
// Query primary keys
|
||||
primaryKeys, err := r.queryPrimaryKeys(schema.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query primary keys for schema %s: %w", schema.Name, err)
|
||||
}
|
||||
|
||||
// Apply primary keys to tables
|
||||
for _, table := range schema.Tables {
|
||||
tableKey := schema.Name + "." + table.Name
|
||||
if pk, exists := primaryKeys[tableKey]; exists {
|
||||
table.Constraints[pk.Name] = pk
|
||||
// Mark columns as primary key and not null
|
||||
for _, colName := range pk.Columns {
|
||||
if col, colExists := table.Columns[colName]; colExists {
|
||||
col.IsPrimaryKey = true
|
||||
col.NotNull = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Query foreign keys
|
||||
foreignKeys, err := r.queryForeignKeys(schema.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query foreign keys for schema %s: %w", schema.Name, err)
|
||||
}
|
||||
|
||||
// Apply foreign keys to tables
|
||||
for _, table := range schema.Tables {
|
||||
tableKey := schema.Name + "." + table.Name
|
||||
if fks, exists := foreignKeys[tableKey]; exists {
|
||||
for _, fk := range fks {
|
||||
table.Constraints[fk.Name] = fk
|
||||
// Derive relationship from foreign key
|
||||
r.deriveRelationship(table, fk)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Query unique constraints
|
||||
uniqueConstraints, err := r.queryUniqueConstraints(schema.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query unique constraints for schema %s: %w", schema.Name, err)
|
||||
}
|
||||
|
||||
// Apply unique constraints to tables
|
||||
for _, table := range schema.Tables {
|
||||
tableKey := schema.Name + "." + table.Name
|
||||
if ucs, exists := uniqueConstraints[tableKey]; exists {
|
||||
for _, uc := range ucs {
|
||||
table.Constraints[uc.Name] = uc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Query check constraints
|
||||
checkConstraints, err := r.queryCheckConstraints(schema.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query check constraints for schema %s: %w", schema.Name, err)
|
||||
}
|
||||
|
||||
// Apply check constraints to tables
|
||||
for _, table := range schema.Tables {
|
||||
tableKey := schema.Name + "." + table.Name
|
||||
if ccs, exists := checkConstraints[tableKey]; exists {
|
||||
for _, cc := range ccs {
|
||||
table.Constraints[cc.Name] = cc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Query indexes
|
||||
indexes, err := r.queryIndexes(schema.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query indexes for schema %s: %w", schema.Name, err)
|
||||
}
|
||||
|
||||
// Apply indexes to tables
|
||||
for _, table := range schema.Tables {
|
||||
tableKey := schema.Name + "." + table.Name
|
||||
if idxs, exists := indexes[tableKey]; exists {
|
||||
for _, idx := range idxs {
|
||||
table.Indexes[idx.Name] = idx
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set RefDatabase for schema
|
||||
schema.RefDatabase = db
|
||||
|
||||
// Set RefSchema for tables
|
||||
for _, table := range schema.Tables {
|
||||
table.RefSchema = schema
|
||||
}
|
||||
|
||||
// Add schema to database
|
||||
db.Schemas = append(db.Schemas, schema)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// ReadSchema reads a single schema (returns the first schema from the database)
|
||||
func (r *Reader) ReadSchema() (*models.Schema, error) {
|
||||
db, err := r.ReadDatabase()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(db.Schemas) == 0 {
|
||||
return nil, fmt.Errorf("no schemas found in database")
|
||||
}
|
||||
return db.Schemas[0], nil
|
||||
}
|
||||
|
||||
// ReadTable reads a single table (returns the first table from the first schema)
|
||||
func (r *Reader) ReadTable() (*models.Table, error) {
|
||||
schema, err := r.ReadSchema()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(schema.Tables) == 0 {
|
||||
return nil, fmt.Errorf("no tables found in schema")
|
||||
}
|
||||
return schema.Tables[0], nil
|
||||
}
|
||||
|
||||
// connect establishes a connection to the MSSQL database
|
||||
func (r *Reader) connect() error {
|
||||
db, err := sql.Open("mssql", r.options.ConnectionString)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Test connection
|
||||
if err = db.PingContext(r.ctx); err != nil {
|
||||
db.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
r.db = db
|
||||
return nil
|
||||
}
|
||||
|
||||
// close closes the database connection
|
||||
func (r *Reader) close() {
|
||||
if r.db != nil {
|
||||
r.db.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// mapDataType maps MSSQL data types to canonical types
|
||||
func (r *Reader) mapDataType(mssqlType string) string {
|
||||
return mssql.ConvertMSSQLToCanonical(mssqlType)
|
||||
}
|
||||
|
||||
// deriveRelationship creates a relationship from a foreign key constraint
|
||||
func (r *Reader) deriveRelationship(table *models.Table, fk *models.Constraint) {
|
||||
relationshipName := fmt.Sprintf("%s_to_%s", table.Name, fk.ReferencedTable)
|
||||
|
||||
relationship := models.InitRelationship(relationshipName, models.OneToMany)
|
||||
relationship.FromTable = table.Name
|
||||
relationship.FromSchema = table.Schema
|
||||
relationship.ToTable = fk.ReferencedTable
|
||||
relationship.ToSchema = fk.ReferencedSchema
|
||||
relationship.ForeignKey = fk.Name
|
||||
|
||||
// Store constraint actions in properties
|
||||
if fk.OnDelete != "" {
|
||||
relationship.Properties["on_delete"] = fk.OnDelete
|
||||
}
|
||||
if fk.OnUpdate != "" {
|
||||
relationship.Properties["on_update"] = fk.OnUpdate
|
||||
}
|
||||
|
||||
table.Relationships[relationshipName] = relationship
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package mssql
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/mssql"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestMapDataType tests MSSQL type mapping to canonical types
|
||||
func TestMapDataType(t *testing.T) {
|
||||
reader := NewReader(&readers.ReaderOptions{})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mssqlType string
|
||||
expectedType string
|
||||
}{
|
||||
{"INT to int", "INT", "int"},
|
||||
{"BIGINT to int64", "BIGINT", "int64"},
|
||||
{"BIT to bool", "BIT", "bool"},
|
||||
{"NVARCHAR to string", "NVARCHAR(255)", "string"},
|
||||
{"DATETIME2 to timestamp", "DATETIME2", "timestamp"},
|
||||
{"DATETIMEOFFSET to timestamptz", "DATETIMEOFFSET", "timestamptz"},
|
||||
{"UNIQUEIDENTIFIER to uuid", "UNIQUEIDENTIFIER", "uuid"},
|
||||
{"FLOAT to float64", "FLOAT", "float64"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := reader.mapDataType(tt.mssqlType)
|
||||
assert.Equal(t, tt.expectedType, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConvertCanonicalToMSSQL tests canonical to MSSQL type conversion
|
||||
func TestConvertCanonicalToMSSQL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
canonicalType string
|
||||
expectedMSSQL string
|
||||
}{
|
||||
{"int to INT", "int", "INT"},
|
||||
{"int64 to BIGINT", "int64", "BIGINT"},
|
||||
{"bool to BIT", "bool", "BIT"},
|
||||
{"string to NVARCHAR(255)", "string", "NVARCHAR(255)"},
|
||||
{"text to NVARCHAR(MAX)", "text", "NVARCHAR(MAX)"},
|
||||
{"timestamp to DATETIME2", "timestamp", "DATETIME2"},
|
||||
{"timestamptz to DATETIMEOFFSET", "timestamptz", "DATETIMEOFFSET"},
|
||||
{"uuid to UNIQUEIDENTIFIER", "uuid", "UNIQUEIDENTIFIER"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := mssql.ConvertCanonicalToMSSQL(tt.canonicalType)
|
||||
assert.Equal(t, tt.expectedMSSQL, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConvertMSSQLToCanonical tests MSSQL to canonical type conversion
|
||||
func TestConvertMSSQLToCanonical(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mssqlType string
|
||||
expectedType string
|
||||
}{
|
||||
{"INT to int", "INT", "int"},
|
||||
{"BIGINT to int64", "BIGINT", "int64"},
|
||||
{"BIT to bool", "BIT", "bool"},
|
||||
{"NVARCHAR with params", "NVARCHAR(255)", "string"},
|
||||
{"DATETIME2 to timestamp", "DATETIME2", "timestamp"},
|
||||
{"DATETIMEOFFSET to timestamptz", "DATETIMEOFFSET", "timestamptz"},
|
||||
{"UNIQUEIDENTIFIER to uuid", "UNIQUEIDENTIFIER", "uuid"},
|
||||
{"VARBINARY to bytea", "VARBINARY(MAX)", "bytea"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := mssql.ConvertMSSQLToCanonical(tt.mssqlType)
|
||||
assert.Equal(t, tt.expectedType, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,10 @@ postgres://user@localhost/mydb?sslmode=disable
|
||||
postgres://user:pass@db.example.com:5432/production?sslmode=require
|
||||
```
|
||||
|
||||
By default, relspec sets `application_name` to `relspecgo/<version>` for PostgreSQL
|
||||
sessions so they are identifiable in `pg_stat_activity`. If you provide
|
||||
`application_name` in the connection string, your explicit value is preserved.
|
||||
|
||||
## Extracted Information
|
||||
|
||||
### Tables
|
||||
|
||||
@@ -206,8 +206,19 @@ func (r *Reader) queryColumns(schemaName string) (map[string]map[string]*models.
|
||||
c.numeric_precision,
|
||||
c.numeric_scale,
|
||||
c.udt_name,
|
||||
pg_catalog.format_type(a.atttypid, a.atttypmod) as formatted_data_type,
|
||||
col_description((c.table_schema||'.'||c.table_name)::regclass, c.ordinal_position) as description
|
||||
FROM information_schema.columns c
|
||||
JOIN pg_catalog.pg_namespace n
|
||||
ON n.nspname = c.table_schema
|
||||
JOIN pg_catalog.pg_class cls
|
||||
ON cls.relname = c.table_name
|
||||
AND cls.relnamespace = n.oid
|
||||
JOIN pg_catalog.pg_attribute a
|
||||
ON a.attrelid = cls.oid
|
||||
AND a.attname = c.column_name
|
||||
AND a.attnum > 0
|
||||
AND NOT a.attisdropped
|
||||
WHERE c.table_schema = $1
|
||||
ORDER BY c.table_schema, c.table_name, c.ordinal_position
|
||||
`
|
||||
@@ -221,31 +232,35 @@ func (r *Reader) queryColumns(schemaName string) (map[string]map[string]*models.
|
||||
columnsMap := make(map[string]map[string]*models.Column)
|
||||
|
||||
for rows.Next() {
|
||||
var schema, tableName, columnName, isNullable, dataType, udtName string
|
||||
var schema, tableName, columnName, isNullable, dataType, udtName, formattedDataType string
|
||||
var ordinalPosition int
|
||||
var columnDefault, description *string
|
||||
var charMaxLength, numPrecision, numScale *int
|
||||
|
||||
if err := rows.Scan(&schema, &tableName, &columnName, &ordinalPosition, &columnDefault, &isNullable, &dataType, &charMaxLength, &numPrecision, &numScale, &udtName, &description); err != nil {
|
||||
if err := rows.Scan(&schema, &tableName, &columnName, &ordinalPosition, &columnDefault, &isNullable, &dataType, &charMaxLength, &numPrecision, &numScale, &udtName, &formattedDataType, &description); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
column := models.InitColumn(columnName, tableName, schema)
|
||||
column.Type = r.mapDataType(dataType, udtName)
|
||||
column.NotNull = (isNullable == "NO")
|
||||
column.Sequence = uint(ordinalPosition)
|
||||
|
||||
// Check if this is a serial type (has nextval default)
|
||||
hasNextval := false
|
||||
if columnDefault != nil {
|
||||
// Parse default value - remove nextval for sequences
|
||||
defaultVal := *columnDefault
|
||||
if strings.HasPrefix(defaultVal, "nextval") {
|
||||
hasNextval = true
|
||||
column.AutoIncrement = true
|
||||
column.Default = defaultVal
|
||||
} else {
|
||||
column.Default = defaultVal
|
||||
column.Default = normalizePostgresDefault(defaultVal)
|
||||
}
|
||||
}
|
||||
|
||||
// Map data type, preserving serial types when detected
|
||||
column.Type = r.mapDataType(dataType, udtName, formattedDataType, hasNextval)
|
||||
column.NotNull = (isNullable == "NO")
|
||||
column.Sequence = uint(ordinalPosition)
|
||||
|
||||
if description != nil {
|
||||
column.Description = *description
|
||||
}
|
||||
@@ -255,7 +270,15 @@ func (r *Reader) queryColumns(schemaName string) (map[string]map[string]*models.
|
||||
}
|
||||
|
||||
if numPrecision != nil {
|
||||
column.Precision = *numPrecision
|
||||
// For integer and serial types, numeric_precision is a bit-width (32, 64, 16)
|
||||
// not a user-visible column parameter. Only store precision for types where
|
||||
// it represents actual decimal/scale precision (numeric, decimal, float).
|
||||
switch column.Type {
|
||||
case "integer", "bigint", "smallint", "serial", "bigserial", "smallserial":
|
||||
// skip — bit-width, not a column parameter
|
||||
default:
|
||||
column.Precision = *numPrecision
|
||||
}
|
||||
}
|
||||
|
||||
if numScale != nil {
|
||||
@@ -598,3 +621,30 @@ func (r *Reader) parseIndexDefinition(indexName, tableName, schema, indexDef str
|
||||
|
||||
return index, nil
|
||||
}
|
||||
|
||||
// normalizePostgresDefault converts a raw PostgreSQL column_default expression into the
|
||||
// unquoted string value that the model convention expects. PostgreSQL stores string
|
||||
// literal defaults as 'value' or 'value'::type (e.g. '{}'::text[]), while every other
|
||||
// reader stores the bare value so the writer can re-quote it correctly.
|
||||
func normalizePostgresDefault(defaultVal string) string {
|
||||
if !strings.HasPrefix(defaultVal, "'") {
|
||||
return defaultVal
|
||||
}
|
||||
// Decode the SQL string literal: skip the leading quote, unescape '' → ', stop at
|
||||
// the first unescaped closing quote (any trailing ::cast is ignored).
|
||||
rest := defaultVal[1:]
|
||||
var buf strings.Builder
|
||||
for i := 0; i < len(rest); i++ {
|
||||
if rest[i] == '\'' {
|
||||
if i+1 < len(rest) && rest[i+1] == '\'' {
|
||||
buf.WriteByte('\'')
|
||||
i++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
} else {
|
||||
buf.WriteByte(rest[i])
|
||||
}
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
+50
-54
@@ -3,6 +3,7 @@ package pgsql
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
@@ -243,7 +244,7 @@ func (r *Reader) ReadTable() (*models.Table, error) {
|
||||
|
||||
// connect establishes a connection to the PostgreSQL database
|
||||
func (r *Reader) connect() error {
|
||||
conn, err := pgx.Connect(r.ctx, r.options.ConnectionString)
|
||||
conn, err := pgsql.Connect(r.ctx, r.options.ConnectionString, "reader-pgsql")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -258,69 +259,64 @@ func (r *Reader) close() {
|
||||
}
|
||||
}
|
||||
|
||||
// mapDataType maps PostgreSQL data types to canonical types
|
||||
func (r *Reader) mapDataType(pgType, udtName string) string {
|
||||
// Map common PostgreSQL types
|
||||
typeMap := map[string]string{
|
||||
"integer": "int",
|
||||
"bigint": "int64",
|
||||
"smallint": "int16",
|
||||
"int": "int",
|
||||
"int2": "int16",
|
||||
"int4": "int",
|
||||
"int8": "int64",
|
||||
"serial": "int",
|
||||
"bigserial": "int64",
|
||||
"smallserial": "int16",
|
||||
"numeric": "decimal",
|
||||
"decimal": "decimal",
|
||||
"real": "float32",
|
||||
"double precision": "float64",
|
||||
"float4": "float32",
|
||||
"float8": "float64",
|
||||
"money": "decimal",
|
||||
"character varying": "string",
|
||||
"varchar": "string",
|
||||
"character": "string",
|
||||
"char": "string",
|
||||
"text": "string",
|
||||
"boolean": "bool",
|
||||
"bool": "bool",
|
||||
"date": "date",
|
||||
"time": "time",
|
||||
"time without time zone": "time",
|
||||
"time with time zone": "timetz",
|
||||
"timestamp": "timestamp",
|
||||
"timestamp without time zone": "timestamp",
|
||||
"timestamp with time zone": "timestamptz",
|
||||
"timestamptz": "timestamptz",
|
||||
"interval": "interval",
|
||||
"uuid": "uuid",
|
||||
"json": "json",
|
||||
"jsonb": "jsonb",
|
||||
"bytea": "bytea",
|
||||
"inet": "inet",
|
||||
"cidr": "cidr",
|
||||
"macaddr": "macaddr",
|
||||
"xml": "xml",
|
||||
// mapDataType maps a PostgreSQL data type to its canonical RelSpec name.
|
||||
// For known built-in types, dimensions are stripped from the type string (they are
|
||||
// stored separately in column.Length/Precision/Scale). For custom types (e.g.
|
||||
// vector(1536), postgis geometries), the full formatted type is preserved.
|
||||
func (r *Reader) mapDataType(pgType, udtName, formattedType string, hasNextval bool) string {
|
||||
normalizedPGType := strings.ToLower(strings.TrimSpace(pgType))
|
||||
|
||||
// Detect serial types from nextval defaults before anything else.
|
||||
if hasNextval {
|
||||
switch normalizedPGType {
|
||||
case "integer", "int", "int4":
|
||||
return "serial"
|
||||
case "bigint", "int8":
|
||||
return "bigserial"
|
||||
case "smallint", "int2":
|
||||
return "smallserial"
|
||||
}
|
||||
}
|
||||
|
||||
// Try mapped type first
|
||||
if mapped, exists := typeMap[pgType]; exists {
|
||||
return mapped
|
||||
// information_schema reports arrays generically as "ARRAY" with udt_name like "_text".
|
||||
if strings.EqualFold(pgType, "ARRAY") && strings.HasPrefix(udtName, "_") && len(udtName) > 1 {
|
||||
return udtName[1:] + "[]"
|
||||
}
|
||||
|
||||
// Use pgsql utilities if available
|
||||
if pgsql.ValidSQLType(pgType) {
|
||||
return pgsql.GetSQLType(pgType)
|
||||
// Use the database-formatted type when available. For known built-in types, strip
|
||||
// embedded dimensions (they are stored in column.Length/Precision/Scale separately).
|
||||
// For unknown/custom types, keep the full formatted string (e.g. vector(1536)).
|
||||
if strings.TrimSpace(formattedType) != "" {
|
||||
lower := strings.ToLower(strings.TrimSpace(formattedType))
|
||||
isArray := strings.HasSuffix(lower, "[]")
|
||||
base := strings.TrimSuffix(lower, "[]")
|
||||
if idx := strings.Index(base, "("); idx >= 0 {
|
||||
base = strings.TrimSpace(base[:idx])
|
||||
}
|
||||
canonical := pgsql.NormalizePGType(base)
|
||||
if pgsql.IsKnownPGBaseType(canonical) {
|
||||
if isArray {
|
||||
return canonical + "[]"
|
||||
}
|
||||
return canonical
|
||||
}
|
||||
return formattedType
|
||||
}
|
||||
|
||||
// Return UDT name for custom types
|
||||
// Fall back to normalizing the information_schema type name directly.
|
||||
canonical := pgsql.NormalizePGType(normalizedPGType)
|
||||
if pgsql.IsKnownPGBaseType(canonical) {
|
||||
return canonical
|
||||
}
|
||||
|
||||
// Return UDT name for custom types.
|
||||
if udtName != "" {
|
||||
if strings.HasPrefix(udtName, "_") && len(udtName) > 1 {
|
||||
return udtName[1:] + "[]"
|
||||
}
|
||||
return udtName
|
||||
}
|
||||
|
||||
// Default to the original type
|
||||
return pgType
|
||||
}
|
||||
|
||||
|
||||
@@ -173,35 +173,58 @@ func TestMapDataType(t *testing.T) {
|
||||
reader := &Reader{}
|
||||
|
||||
tests := []struct {
|
||||
pgType string
|
||||
udtName string
|
||||
expected string
|
||||
pgType string
|
||||
udtName string
|
||||
formattedType string
|
||||
expected string
|
||||
}{
|
||||
{"integer", "int4", "int"},
|
||||
{"bigint", "int8", "int64"},
|
||||
{"smallint", "int2", "int16"},
|
||||
{"character varying", "varchar", "string"},
|
||||
{"text", "text", "string"},
|
||||
{"boolean", "bool", "bool"},
|
||||
{"timestamp without time zone", "timestamp", "timestamp"},
|
||||
{"timestamp with time zone", "timestamptz", "timestamptz"},
|
||||
{"json", "json", "json"},
|
||||
{"jsonb", "jsonb", "jsonb"},
|
||||
{"uuid", "uuid", "uuid"},
|
||||
{"numeric", "numeric", "decimal"},
|
||||
{"real", "float4", "float32"},
|
||||
{"double precision", "float8", "float64"},
|
||||
{"date", "date", "date"},
|
||||
{"time without time zone", "time", "time"},
|
||||
{"bytea", "bytea", "bytea"},
|
||||
{"unknown_type", "custom", "custom"}, // Should return UDT name
|
||||
{"integer", "int4", "", "integer"},
|
||||
{"bigint", "int8", "", "bigint"},
|
||||
{"smallint", "int2", "", "smallint"},
|
||||
{"character varying", "varchar", "", "varchar"},
|
||||
{"text", "text", "", "text"},
|
||||
{"boolean", "bool", "", "boolean"},
|
||||
{"timestamp without time zone", "timestamp", "", "timestamp"},
|
||||
{"timestamp with time zone", "timestamptz", "", "timestamptz"},
|
||||
{"json", "json", "", "json"},
|
||||
{"jsonb", "jsonb", "", "jsonb"},
|
||||
{"uuid", "uuid", "", "uuid"},
|
||||
{"numeric", "numeric", "", "numeric"},
|
||||
{"real", "float4", "", "real"},
|
||||
{"double precision", "float8", "", "double precision"},
|
||||
{"date", "date", "", "date"},
|
||||
{"time without time zone", "time", "", "time"},
|
||||
{"bytea", "bytea", "", "bytea"},
|
||||
{"unknown_type", "custom", "", "custom"}, // Should return UDT name
|
||||
{"ARRAY", "_text", "", "text[]"},
|
||||
{"USER-DEFINED", "vector", "vector(1536)", "vector(1536)"},
|
||||
{"character varying", "varchar", "character varying(255)", "varchar"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.pgType, func(t *testing.T) {
|
||||
result := reader.mapDataType(tt.pgType, tt.udtName)
|
||||
result := reader.mapDataType(tt.pgType, tt.udtName, tt.formattedType, false)
|
||||
if result != tt.expected {
|
||||
t.Errorf("mapDataType(%s, %s) = %s, expected %s", tt.pgType, tt.udtName, result, tt.expected)
|
||||
t.Errorf("mapDataType(%s, %s, %s) = %s, expected %s", tt.pgType, tt.udtName, tt.formattedType, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Test serial type detection with hasNextval=true
|
||||
serialTests := []struct {
|
||||
pgType string
|
||||
expected string
|
||||
}{
|
||||
{"integer", "serial"},
|
||||
{"bigint", "bigserial"},
|
||||
{"smallint", "smallserial"},
|
||||
}
|
||||
|
||||
for _, tt := range serialTests {
|
||||
t.Run(tt.pgType+"_with_nextval", func(t *testing.T) {
|
||||
result := reader.mapDataType(tt.pgType, "", "", true)
|
||||
if result != tt.expected {
|
||||
t.Errorf("mapDataType(%s, '', '', true) = %s, expected %s", tt.pgType, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -211,63 +234,63 @@ func TestParseIndexDefinition(t *testing.T) {
|
||||
reader := &Reader{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
indexName string
|
||||
tableName string
|
||||
schema string
|
||||
indexDef string
|
||||
wantType string
|
||||
wantUnique bool
|
||||
name string
|
||||
indexName string
|
||||
tableName string
|
||||
schema string
|
||||
indexDef string
|
||||
wantType string
|
||||
wantUnique bool
|
||||
wantColumns int
|
||||
}{
|
||||
{
|
||||
name: "simple btree index",
|
||||
indexName: "idx_users_email",
|
||||
tableName: "users",
|
||||
schema: "public",
|
||||
indexDef: "CREATE INDEX idx_users_email ON public.users USING btree (email)",
|
||||
wantType: "btree",
|
||||
wantUnique: false,
|
||||
name: "simple btree index",
|
||||
indexName: "idx_users_email",
|
||||
tableName: "users",
|
||||
schema: "public",
|
||||
indexDef: "CREATE INDEX idx_users_email ON public.users USING btree (email)",
|
||||
wantType: "btree",
|
||||
wantUnique: false,
|
||||
wantColumns: 1,
|
||||
},
|
||||
{
|
||||
name: "unique index",
|
||||
indexName: "idx_users_username",
|
||||
tableName: "users",
|
||||
schema: "public",
|
||||
indexDef: "CREATE UNIQUE INDEX idx_users_username ON public.users USING btree (username)",
|
||||
wantType: "btree",
|
||||
wantUnique: true,
|
||||
name: "unique index",
|
||||
indexName: "idx_users_username",
|
||||
tableName: "users",
|
||||
schema: "public",
|
||||
indexDef: "CREATE UNIQUE INDEX idx_users_username ON public.users USING btree (username)",
|
||||
wantType: "btree",
|
||||
wantUnique: true,
|
||||
wantColumns: 1,
|
||||
},
|
||||
{
|
||||
name: "composite index",
|
||||
indexName: "idx_users_name",
|
||||
tableName: "users",
|
||||
schema: "public",
|
||||
indexDef: "CREATE INDEX idx_users_name ON public.users USING btree (first_name, last_name)",
|
||||
wantType: "btree",
|
||||
wantUnique: false,
|
||||
name: "composite index",
|
||||
indexName: "idx_users_name",
|
||||
tableName: "users",
|
||||
schema: "public",
|
||||
indexDef: "CREATE INDEX idx_users_name ON public.users USING btree (first_name, last_name)",
|
||||
wantType: "btree",
|
||||
wantUnique: false,
|
||||
wantColumns: 2,
|
||||
},
|
||||
{
|
||||
name: "gin index",
|
||||
indexName: "idx_posts_tags",
|
||||
tableName: "posts",
|
||||
schema: "public",
|
||||
indexDef: "CREATE INDEX idx_posts_tags ON public.posts USING gin (tags)",
|
||||
wantType: "gin",
|
||||
wantUnique: false,
|
||||
name: "gin index",
|
||||
indexName: "idx_posts_tags",
|
||||
tableName: "posts",
|
||||
schema: "public",
|
||||
indexDef: "CREATE INDEX idx_posts_tags ON public.posts USING gin (tags)",
|
||||
wantType: "gin",
|
||||
wantUnique: false,
|
||||
wantColumns: 1,
|
||||
},
|
||||
{
|
||||
name: "partial index with where clause",
|
||||
indexName: "idx_users_active",
|
||||
tableName: "users",
|
||||
schema: "public",
|
||||
indexDef: "CREATE INDEX idx_users_active ON public.users USING btree (id) WHERE (active = true)",
|
||||
wantType: "btree",
|
||||
wantUnique: false,
|
||||
name: "partial index with where clause",
|
||||
indexName: "idx_users_active",
|
||||
tableName: "users",
|
||||
schema: "public",
|
||||
indexDef: "CREATE INDEX idx_users_active ON public.users USING btree (id) WHERE (active = true)",
|
||||
wantType: "btree",
|
||||
wantUnique: false,
|
||||
wantColumns: 1,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ func (r *Reader) ReadTable() (*models.Table, error) {
|
||||
// parsePrisma parses Prisma schema content and returns a Database model
|
||||
func (r *Reader) parsePrisma(content string) (*models.Database, error) {
|
||||
db := models.InitDatabase("database")
|
||||
db.SourceFormat = "prisma"
|
||||
|
||||
if r.options.Metadata != nil {
|
||||
if name, ok := r.options.Metadata["name"].(string); ok {
|
||||
@@ -139,7 +140,7 @@ func (r *Reader) parsePrisma(content string) (*models.Database, error) {
|
||||
case "datasource":
|
||||
r.parseDatasource(blockContent, db)
|
||||
case "generator":
|
||||
// We don't need to do anything with generator blocks
|
||||
r.parseGenerator(blockContent, db)
|
||||
case "model":
|
||||
if currentTable != nil {
|
||||
r.parseModelFields(blockContent, currentTable)
|
||||
@@ -173,10 +174,34 @@ func (r *Reader) parsePrisma(content string) (*models.Database, error) {
|
||||
// Second pass: resolve relationships
|
||||
r.resolveRelationships(schema)
|
||||
|
||||
if db.SourceFormat == "prisma" && r.options != nil && r.options.Prisma7 {
|
||||
db.SourceFormat = "prisma7"
|
||||
}
|
||||
|
||||
db.Schemas = append(db.Schemas, schema)
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func (r *Reader) parseGenerator(lines []string, db *models.Database) {
|
||||
providerRegex := regexp.MustCompile(`provider\s*=\s*"([^"]+)"`)
|
||||
|
||||
for _, line := range lines {
|
||||
if matches := providerRegex.FindStringSubmatch(line); matches != nil {
|
||||
switch matches[1] {
|
||||
case "prisma-client":
|
||||
db.SourceFormat = "prisma7"
|
||||
default:
|
||||
db.SourceFormat = "prisma"
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if r.options != nil && r.options.Prisma7 {
|
||||
db.SourceFormat = "prisma7"
|
||||
}
|
||||
}
|
||||
|
||||
// parseDatasource extracts database type from datasource block
|
||||
func (r *Reader) parseDatasource(lines []string, db *models.Database) {
|
||||
providerRegex := regexp.MustCompile(`provider\s*=\s*"?(\w+)"?`)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package prisma
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
)
|
||||
|
||||
func TestReadDatabase_Prisma7GeneratorSetsSourceFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
schemaPath := filepath.Join(tmpDir, "schema.prisma")
|
||||
|
||||
content := `datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "./generated"
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
}`
|
||||
|
||||
if err := os.WriteFile(schemaPath, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("failed to write schema: %v", err)
|
||||
}
|
||||
|
||||
reader := NewReader(&readers.ReaderOptions{FilePath: schemaPath})
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDatabase() failed: %v", err)
|
||||
}
|
||||
|
||||
if db.SourceFormat != "prisma7" {
|
||||
t.Fatalf("expected SourceFormat prisma7, got %q", db.SourceFormat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadDatabase_Prisma7FlagSetsSourceFormatWithoutGenerator(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
schemaPath := filepath.Join(tmpDir, "schema.prisma")
|
||||
|
||||
content := `datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
}`
|
||||
|
||||
if err := os.WriteFile(schemaPath, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("failed to write schema: %v", err)
|
||||
}
|
||||
|
||||
reader := NewReader(&readers.ReaderOptions{
|
||||
FilePath: schemaPath,
|
||||
Prisma7: true,
|
||||
})
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDatabase() failed: %v", err)
|
||||
}
|
||||
|
||||
if db.SourceFormat != "prisma7" {
|
||||
t.Fatalf("expected SourceFormat prisma7 from flag, got %q", db.SourceFormat)
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,9 @@ type ReaderOptions struct {
|
||||
// ConnectionString is the database connection string (for DB readers)
|
||||
ConnectionString string
|
||||
|
||||
// Prisma7 enables Prisma 7-specific handling for Prisma schemas.
|
||||
Prisma7 bool
|
||||
|
||||
// Additional options can be added here as needed
|
||||
Metadata map[string]interface{}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# SQLite Reader
|
||||
|
||||
Reads database schema from SQLite database files.
|
||||
|
||||
## Usage
|
||||
|
||||
```go
|
||||
import (
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/sqlite"
|
||||
)
|
||||
|
||||
// Using file path
|
||||
options := &readers.ReaderOptions{
|
||||
FilePath: "path/to/database.db",
|
||||
}
|
||||
|
||||
reader := sqlite.NewReader(options)
|
||||
db, err := reader.ReadDatabase()
|
||||
|
||||
// Or using connection string
|
||||
options := &readers.ReaderOptions{
|
||||
ConnectionString: "path/to/database.db",
|
||||
}
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Reads tables with columns and data types
|
||||
- Reads views with definitions
|
||||
- Reads primary keys
|
||||
- Reads foreign keys with CASCADE actions
|
||||
- Reads indexes (non-auto-generated)
|
||||
- Maps SQLite types to canonical types
|
||||
- Derives relationships from foreign keys
|
||||
|
||||
## SQLite Specifics
|
||||
|
||||
- SQLite doesn't support schemas, creates single "main" schema
|
||||
- Uses pure Go driver (modernc.org/sqlite) - no CGo required
|
||||
- Supports both file path and connection string
|
||||
- Auto-increment detection for INTEGER PRIMARY KEY columns
|
||||
- Foreign keys require `PRAGMA foreign_keys = ON` to be set
|
||||
|
||||
## Example Schema
|
||||
|
||||
```sql
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username VARCHAR(50) NOT NULL UNIQUE,
|
||||
email VARCHAR(100) NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
## Type Mappings
|
||||
|
||||
| SQLite Type | Canonical Type |
|
||||
|-------------|---------------|
|
||||
| INTEGER, INT | int |
|
||||
| BIGINT | int64 |
|
||||
| REAL, DOUBLE | float64 |
|
||||
| TEXT, VARCHAR | string |
|
||||
| BLOB | bytea |
|
||||
| BOOLEAN | bool |
|
||||
| DATE | date |
|
||||
| DATETIME, TIMESTAMP | timestamp |
|
||||
@@ -0,0 +1,306 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
// queryTables retrieves all tables from the SQLite database
|
||||
func (r *Reader) queryTables() ([]*models.Table, error) {
|
||||
query := `
|
||||
SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table'
|
||||
AND name NOT LIKE 'sqlite_%'
|
||||
ORDER BY name
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(r.ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
tables := make([]*models.Table, 0)
|
||||
for rows.Next() {
|
||||
var tableName string
|
||||
|
||||
if err := rows.Scan(&tableName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
table := models.InitTable(tableName, "main")
|
||||
tables = append(tables, table)
|
||||
}
|
||||
|
||||
return tables, rows.Err()
|
||||
}
|
||||
|
||||
// queryViews retrieves all views from the SQLite database
|
||||
func (r *Reader) queryViews() ([]*models.View, error) {
|
||||
query := `
|
||||
SELECT name, sql
|
||||
FROM sqlite_master
|
||||
WHERE type = 'view'
|
||||
ORDER BY name
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(r.ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
views := make([]*models.View, 0)
|
||||
for rows.Next() {
|
||||
var viewName string
|
||||
var sql *string
|
||||
|
||||
if err := rows.Scan(&viewName, &sql); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
view := models.InitView(viewName, "main")
|
||||
if sql != nil {
|
||||
view.Definition = *sql
|
||||
}
|
||||
|
||||
views = append(views, view)
|
||||
}
|
||||
|
||||
return views, rows.Err()
|
||||
}
|
||||
|
||||
// queryColumns retrieves all columns for a given table or view
|
||||
func (r *Reader) queryColumns(tableName string) (map[string]*models.Column, error) {
|
||||
query := fmt.Sprintf("PRAGMA table_info(%s)", tableName)
|
||||
|
||||
rows, err := r.db.QueryContext(r.ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
columns := make(map[string]*models.Column)
|
||||
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, dataType string
|
||||
var notNull, pk int
|
||||
var defaultValue *string
|
||||
|
||||
if err := rows.Scan(&cid, &name, &dataType, ¬Null, &defaultValue, &pk); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
column := models.InitColumn(name, tableName, "main")
|
||||
column.Type = r.mapDataType(strings.ToUpper(dataType))
|
||||
column.NotNull = (notNull == 1)
|
||||
column.IsPrimaryKey = (pk > 0)
|
||||
column.Sequence = uint(cid + 1)
|
||||
|
||||
if defaultValue != nil {
|
||||
column.Default = *defaultValue
|
||||
}
|
||||
|
||||
// Check for autoincrement (SQLite uses INTEGER PRIMARY KEY AUTOINCREMENT)
|
||||
if pk > 0 && strings.EqualFold(dataType, "INTEGER") {
|
||||
column.AutoIncrement = r.isAutoIncrement(tableName, name)
|
||||
}
|
||||
|
||||
columns[name] = column
|
||||
}
|
||||
|
||||
return columns, rows.Err()
|
||||
}
|
||||
|
||||
// isAutoIncrement checks if a column is autoincrement
|
||||
func (r *Reader) isAutoIncrement(tableName, columnName string) bool {
|
||||
// Check sqlite_sequence table or parse CREATE TABLE statement
|
||||
query := `
|
||||
SELECT sql
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table' AND name = ?
|
||||
`
|
||||
|
||||
var sql string
|
||||
err := r.db.QueryRowContext(r.ctx, query, tableName).Scan(&sql)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if the SQL contains AUTOINCREMENT for this column
|
||||
return strings.Contains(strings.ToUpper(sql), strings.ToUpper(columnName)+" INTEGER PRIMARY KEY AUTOINCREMENT") ||
|
||||
strings.Contains(strings.ToUpper(sql), strings.ToUpper(columnName)+" INTEGER AUTOINCREMENT")
|
||||
}
|
||||
|
||||
// queryPrimaryKey retrieves the primary key constraint for a table
|
||||
func (r *Reader) queryPrimaryKey(tableName string) (*models.Constraint, error) {
|
||||
query := fmt.Sprintf("PRAGMA table_info(%s)", tableName)
|
||||
|
||||
rows, err := r.db.QueryContext(r.ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var pkColumns []string
|
||||
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, dataType string
|
||||
var notNull, pk int
|
||||
var defaultValue *string
|
||||
|
||||
if err := rows.Scan(&cid, &name, &dataType, ¬Null, &defaultValue, &pk); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if pk > 0 {
|
||||
pkColumns = append(pkColumns, name)
|
||||
}
|
||||
}
|
||||
|
||||
if len(pkColumns) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Create primary key constraint
|
||||
constraintName := fmt.Sprintf("%s_pkey", tableName)
|
||||
constraint := models.InitConstraint(constraintName, models.PrimaryKeyConstraint)
|
||||
constraint.Schema = "main"
|
||||
constraint.Table = tableName
|
||||
constraint.Columns = pkColumns
|
||||
|
||||
return constraint, rows.Err()
|
||||
}
|
||||
|
||||
// queryForeignKeys retrieves all foreign key constraints for a table
|
||||
func (r *Reader) queryForeignKeys(tableName string) ([]*models.Constraint, error) {
|
||||
query := fmt.Sprintf("PRAGMA foreign_key_list(%s)", tableName)
|
||||
|
||||
rows, err := r.db.QueryContext(r.ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// Group foreign keys by id (since composite FKs have multiple rows)
|
||||
fkMap := make(map[int]*models.Constraint)
|
||||
|
||||
for rows.Next() {
|
||||
var id, seq int
|
||||
var referencedTable, fromColumn, toColumn string
|
||||
var onUpdate, onDelete, match string
|
||||
|
||||
if err := rows.Scan(&id, &seq, &referencedTable, &fromColumn, &toColumn, &onUpdate, &onDelete, &match); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, exists := fkMap[id]; !exists {
|
||||
constraintName := fmt.Sprintf("%s_%s_fkey", tableName, referencedTable)
|
||||
if id > 0 {
|
||||
constraintName = fmt.Sprintf("%s_%s_fkey_%d", tableName, referencedTable, id)
|
||||
}
|
||||
|
||||
constraint := models.InitConstraint(constraintName, models.ForeignKeyConstraint)
|
||||
constraint.Schema = "main"
|
||||
constraint.Table = tableName
|
||||
constraint.ReferencedSchema = "main"
|
||||
constraint.ReferencedTable = referencedTable
|
||||
constraint.OnUpdate = onUpdate
|
||||
constraint.OnDelete = onDelete
|
||||
constraint.Columns = []string{}
|
||||
constraint.ReferencedColumns = []string{}
|
||||
|
||||
fkMap[id] = constraint
|
||||
}
|
||||
|
||||
// Add column to the constraint
|
||||
fkMap[id].Columns = append(fkMap[id].Columns, fromColumn)
|
||||
fkMap[id].ReferencedColumns = append(fkMap[id].ReferencedColumns, toColumn)
|
||||
}
|
||||
|
||||
// Convert map to slice
|
||||
foreignKeys := make([]*models.Constraint, 0, len(fkMap))
|
||||
for _, fk := range fkMap {
|
||||
foreignKeys = append(foreignKeys, fk)
|
||||
}
|
||||
|
||||
return foreignKeys, rows.Err()
|
||||
}
|
||||
|
||||
// queryIndexes retrieves all indexes for a table
|
||||
func (r *Reader) queryIndexes(tableName string) ([]*models.Index, error) {
|
||||
query := fmt.Sprintf("PRAGMA index_list(%s)", tableName)
|
||||
|
||||
rows, err := r.db.QueryContext(r.ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
indexes := make([]*models.Index, 0)
|
||||
|
||||
for rows.Next() {
|
||||
var seq int
|
||||
var name string
|
||||
var unique int
|
||||
var origin string
|
||||
var partial int
|
||||
|
||||
if err := rows.Scan(&seq, &name, &unique, &origin, &partial); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Skip auto-generated indexes (origin = 'pk' for primary keys, etc.)
|
||||
// origin: c = CREATE INDEX, u = UNIQUE constraint, pk = PRIMARY KEY
|
||||
if origin == "pk" || origin == "u" {
|
||||
continue
|
||||
}
|
||||
|
||||
index := models.InitIndex(name, tableName, "main")
|
||||
index.Unique = (unique == 1)
|
||||
|
||||
// Get index columns
|
||||
columns, err := r.queryIndexColumns(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
index.Columns = columns
|
||||
|
||||
indexes = append(indexes, index)
|
||||
}
|
||||
|
||||
return indexes, rows.Err()
|
||||
}
|
||||
|
||||
// queryIndexColumns retrieves the columns for a specific index
|
||||
func (r *Reader) queryIndexColumns(indexName string) ([]string, error) {
|
||||
query := fmt.Sprintf("PRAGMA index_info(%s)", indexName)
|
||||
|
||||
rows, err := r.db.QueryContext(r.ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
columns := make([]string, 0)
|
||||
|
||||
for rows.Next() {
|
||||
var seqno, cid int
|
||||
var name *string
|
||||
|
||||
if err := rows.Scan(&seqno, &cid, &name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if name != nil {
|
||||
columns = append(columns, *name)
|
||||
}
|
||||
}
|
||||
|
||||
return columns, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
_ "modernc.org/sqlite" // SQLite driver
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
sqlitepkg "git.warky.dev/wdevs/relspecgo/pkg/sqlite"
|
||||
)
|
||||
|
||||
// Reader implements the readers.Reader interface for SQLite databases
|
||||
type Reader struct {
|
||||
options *readers.ReaderOptions
|
||||
db *sql.DB
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// NewReader creates a new SQLite reader
|
||||
func NewReader(options *readers.ReaderOptions) *Reader {
|
||||
return &Reader{
|
||||
options: options,
|
||||
ctx: context.Background(),
|
||||
}
|
||||
}
|
||||
|
||||
// ReadDatabase reads the entire database schema from SQLite
|
||||
func (r *Reader) ReadDatabase() (*models.Database, error) {
|
||||
// Validate file path or connection string
|
||||
dbPath := r.options.FilePath
|
||||
if dbPath == "" && r.options.ConnectionString != "" {
|
||||
dbPath = r.options.ConnectionString
|
||||
}
|
||||
if dbPath == "" {
|
||||
return nil, fmt.Errorf("file path or connection string is required")
|
||||
}
|
||||
|
||||
// Connect to the database
|
||||
if err := r.connect(dbPath); err != nil {
|
||||
return nil, fmt.Errorf("failed to connect: %w", err)
|
||||
}
|
||||
defer r.close()
|
||||
|
||||
// Get database name from file path
|
||||
dbName := filepath.Base(dbPath)
|
||||
if dbName == "" {
|
||||
dbName = "sqlite"
|
||||
}
|
||||
|
||||
// Initialize database model
|
||||
db := models.InitDatabase(dbName)
|
||||
db.DatabaseType = models.SqlLiteDatabaseType
|
||||
db.SourceFormat = "sqlite"
|
||||
|
||||
// Get SQLite version
|
||||
var version string
|
||||
err := r.db.QueryRowContext(r.ctx, "SELECT sqlite_version()").Scan(&version)
|
||||
if err == nil {
|
||||
db.DatabaseVersion = version
|
||||
}
|
||||
|
||||
// SQLite doesn't have schemas, so we create a single "main" schema
|
||||
schema := models.InitSchema("main")
|
||||
schema.RefDatabase = db
|
||||
|
||||
// Query tables
|
||||
tables, err := r.queryTables()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query tables: %w", err)
|
||||
}
|
||||
schema.Tables = tables
|
||||
|
||||
// Query views
|
||||
views, err := r.queryViews()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query views: %w", err)
|
||||
}
|
||||
schema.Views = views
|
||||
|
||||
// Query columns for tables and views
|
||||
for _, table := range schema.Tables {
|
||||
columns, err := r.queryColumns(table.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query columns for table %s: %w", table.Name, err)
|
||||
}
|
||||
table.Columns = columns
|
||||
table.RefSchema = schema
|
||||
|
||||
// Query primary key
|
||||
pk, err := r.queryPrimaryKey(table.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query primary key for table %s: %w", table.Name, err)
|
||||
}
|
||||
if pk != nil {
|
||||
table.Constraints[pk.Name] = pk
|
||||
// Mark columns as primary key and not null
|
||||
for _, colName := range pk.Columns {
|
||||
if col, exists := table.Columns[colName]; exists {
|
||||
col.IsPrimaryKey = true
|
||||
col.NotNull = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Query foreign keys
|
||||
foreignKeys, err := r.queryForeignKeys(table.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query foreign keys for table %s: %w", table.Name, err)
|
||||
}
|
||||
for _, fk := range foreignKeys {
|
||||
table.Constraints[fk.Name] = fk
|
||||
// Derive relationship from foreign key
|
||||
r.deriveRelationship(table, fk)
|
||||
}
|
||||
|
||||
// Query indexes
|
||||
indexes, err := r.queryIndexes(table.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query indexes for table %s: %w", table.Name, err)
|
||||
}
|
||||
for _, idx := range indexes {
|
||||
table.Indexes[idx.Name] = idx
|
||||
}
|
||||
}
|
||||
|
||||
// Query columns for views
|
||||
for _, view := range schema.Views {
|
||||
columns, err := r.queryColumns(view.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query columns for view %s: %w", view.Name, err)
|
||||
}
|
||||
view.Columns = columns
|
||||
view.RefSchema = schema
|
||||
}
|
||||
|
||||
// Add schema to database
|
||||
db.Schemas = append(db.Schemas, schema)
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// ReadSchema reads a single schema (returns the main schema from the database)
|
||||
func (r *Reader) ReadSchema() (*models.Schema, error) {
|
||||
db, err := r.ReadDatabase()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(db.Schemas) == 0 {
|
||||
return nil, fmt.Errorf("no schemas found in database")
|
||||
}
|
||||
return db.Schemas[0], nil
|
||||
}
|
||||
|
||||
// ReadTable reads a single table (returns the first table from the schema)
|
||||
func (r *Reader) ReadTable() (*models.Table, error) {
|
||||
schema, err := r.ReadSchema()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(schema.Tables) == 0 {
|
||||
return nil, fmt.Errorf("no tables found in schema")
|
||||
}
|
||||
return schema.Tables[0], nil
|
||||
}
|
||||
|
||||
// connect establishes a connection to the SQLite database
|
||||
func (r *Reader) connect(dbPath string) error {
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.db = db
|
||||
return nil
|
||||
}
|
||||
|
||||
// close closes the database connection
|
||||
func (r *Reader) close() {
|
||||
if r.db != nil {
|
||||
r.db.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// mapDataType maps SQLite data types to canonical types.
|
||||
func (r *Reader) mapDataType(sqliteType string) string {
|
||||
return sqlitepkg.ConvertSQLiteToCanonical(sqliteType)
|
||||
}
|
||||
|
||||
// deriveRelationship creates a relationship from a foreign key constraint
|
||||
func (r *Reader) deriveRelationship(table *models.Table, fk *models.Constraint) {
|
||||
relationshipName := fmt.Sprintf("%s_to_%s", table.Name, fk.ReferencedTable)
|
||||
|
||||
relationship := models.InitRelationship(relationshipName, models.OneToMany)
|
||||
relationship.FromTable = table.Name
|
||||
relationship.FromSchema = table.Schema
|
||||
relationship.ToTable = fk.ReferencedTable
|
||||
relationship.ToSchema = fk.ReferencedSchema
|
||||
relationship.ForeignKey = fk.Name
|
||||
|
||||
// Store constraint actions in properties
|
||||
if fk.OnDelete != "" {
|
||||
relationship.Properties["on_delete"] = fk.OnDelete
|
||||
}
|
||||
if fk.OnUpdate != "" {
|
||||
relationship.Properties["on_update"] = fk.OnUpdate
|
||||
}
|
||||
|
||||
table.Relationships[relationshipName] = relationship
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
)
|
||||
|
||||
// setupTestDatabase creates a temporary SQLite database with test data
|
||||
func setupTestDatabase(t *testing.T) string {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
// Create test schema
|
||||
schema := `
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username VARCHAR(50) NOT NULL UNIQUE,
|
||||
email VARCHAR(100) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
content TEXT,
|
||||
published BOOLEAN DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE comments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
post_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
comment TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_posts_user_id ON posts(user_id);
|
||||
CREATE INDEX idx_comments_post_id ON comments(post_id);
|
||||
CREATE UNIQUE INDEX idx_users_email ON users(email);
|
||||
|
||||
CREATE VIEW user_post_count AS
|
||||
SELECT u.id, u.username, COUNT(p.id) as post_count
|
||||
FROM users u
|
||||
LEFT JOIN posts p ON u.id = p.user_id
|
||||
GROUP BY u.id, u.username;
|
||||
`
|
||||
|
||||
_, err = db.Exec(schema)
|
||||
require.NoError(t, err)
|
||||
|
||||
return dbPath
|
||||
}
|
||||
|
||||
func TestReader_ReadDatabase(t *testing.T) {
|
||||
dbPath := setupTestDatabase(t)
|
||||
defer os.Remove(dbPath)
|
||||
|
||||
options := &readers.ReaderOptions{
|
||||
FilePath: dbPath,
|
||||
}
|
||||
|
||||
reader := NewReader(options)
|
||||
db, err := reader.ReadDatabase()
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, db)
|
||||
|
||||
// Check database metadata
|
||||
assert.Equal(t, "test.db", db.Name)
|
||||
assert.Equal(t, models.SqlLiteDatabaseType, db.DatabaseType)
|
||||
assert.Equal(t, "sqlite", db.SourceFormat)
|
||||
assert.NotEmpty(t, db.DatabaseVersion)
|
||||
|
||||
// Check schemas (SQLite should have a single "main" schema)
|
||||
require.Len(t, db.Schemas, 1)
|
||||
schema := db.Schemas[0]
|
||||
assert.Equal(t, "main", schema.Name)
|
||||
|
||||
// Check tables
|
||||
assert.Len(t, schema.Tables, 3)
|
||||
tableNames := make([]string, len(schema.Tables))
|
||||
for i, table := range schema.Tables {
|
||||
tableNames[i] = table.Name
|
||||
}
|
||||
assert.Contains(t, tableNames, "users")
|
||||
assert.Contains(t, tableNames, "posts")
|
||||
assert.Contains(t, tableNames, "comments")
|
||||
|
||||
// Check views
|
||||
assert.Len(t, schema.Views, 1)
|
||||
assert.Equal(t, "user_post_count", schema.Views[0].Name)
|
||||
assert.NotEmpty(t, schema.Views[0].Definition)
|
||||
}
|
||||
|
||||
func TestReader_ReadTable_Users(t *testing.T) {
|
||||
dbPath := setupTestDatabase(t)
|
||||
defer os.Remove(dbPath)
|
||||
|
||||
options := &readers.ReaderOptions{
|
||||
FilePath: dbPath,
|
||||
}
|
||||
|
||||
reader := NewReader(options)
|
||||
db, err := reader.ReadDatabase()
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, db)
|
||||
|
||||
// Find users table
|
||||
var usersTable *models.Table
|
||||
for _, table := range db.Schemas[0].Tables {
|
||||
if table.Name == "users" {
|
||||
usersTable = table
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
require.NotNil(t, usersTable)
|
||||
assert.Equal(t, "users", usersTable.Name)
|
||||
assert.Equal(t, "main", usersTable.Schema)
|
||||
|
||||
// Check columns
|
||||
assert.Len(t, usersTable.Columns, 4)
|
||||
|
||||
// Check id column
|
||||
idCol, exists := usersTable.Columns["id"]
|
||||
require.True(t, exists)
|
||||
assert.Equal(t, "int", idCol.Type)
|
||||
assert.True(t, idCol.IsPrimaryKey)
|
||||
assert.True(t, idCol.AutoIncrement)
|
||||
assert.True(t, idCol.NotNull)
|
||||
|
||||
// Check username column
|
||||
usernameCol, exists := usersTable.Columns["username"]
|
||||
require.True(t, exists)
|
||||
assert.Equal(t, "string", usernameCol.Type)
|
||||
assert.True(t, usernameCol.NotNull)
|
||||
assert.False(t, usernameCol.IsPrimaryKey)
|
||||
|
||||
// Check email column
|
||||
emailCol, exists := usersTable.Columns["email"]
|
||||
require.True(t, exists)
|
||||
assert.Equal(t, "string", emailCol.Type)
|
||||
assert.True(t, emailCol.NotNull)
|
||||
|
||||
// Check primary key constraint
|
||||
assert.Len(t, usersTable.Constraints, 1)
|
||||
pkConstraint, exists := usersTable.Constraints["users_pkey"]
|
||||
require.True(t, exists)
|
||||
assert.Equal(t, models.PrimaryKeyConstraint, pkConstraint.Type)
|
||||
assert.Equal(t, []string{"id"}, pkConstraint.Columns)
|
||||
|
||||
// Check indexes (should have unique index on email and username)
|
||||
assert.GreaterOrEqual(t, len(usersTable.Indexes), 1)
|
||||
}
|
||||
|
||||
func TestReader_ReadTable_Posts(t *testing.T) {
|
||||
dbPath := setupTestDatabase(t)
|
||||
defer os.Remove(dbPath)
|
||||
|
||||
options := &readers.ReaderOptions{
|
||||
FilePath: dbPath,
|
||||
}
|
||||
|
||||
reader := NewReader(options)
|
||||
db, err := reader.ReadDatabase()
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, db)
|
||||
|
||||
// Find posts table
|
||||
var postsTable *models.Table
|
||||
for _, table := range db.Schemas[0].Tables {
|
||||
if table.Name == "posts" {
|
||||
postsTable = table
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
require.NotNil(t, postsTable)
|
||||
|
||||
// Check columns
|
||||
assert.Len(t, postsTable.Columns, 6)
|
||||
|
||||
// Check foreign key constraint
|
||||
hasForeignKey := false
|
||||
for _, constraint := range postsTable.Constraints {
|
||||
if constraint.Type == models.ForeignKeyConstraint {
|
||||
hasForeignKey = true
|
||||
assert.Equal(t, "users", constraint.ReferencedTable)
|
||||
assert.Equal(t, "CASCADE", constraint.OnDelete)
|
||||
}
|
||||
}
|
||||
assert.True(t, hasForeignKey, "Posts table should have a foreign key constraint")
|
||||
|
||||
// Check relationships
|
||||
assert.GreaterOrEqual(t, len(postsTable.Relationships), 1)
|
||||
|
||||
// Check indexes
|
||||
hasUserIdIndex := false
|
||||
for _, index := range postsTable.Indexes {
|
||||
if index.Name == "idx_posts_user_id" {
|
||||
hasUserIdIndex = true
|
||||
assert.Contains(t, index.Columns, "user_id")
|
||||
}
|
||||
}
|
||||
assert.True(t, hasUserIdIndex, "Posts table should have idx_posts_user_id index")
|
||||
}
|
||||
|
||||
func TestReader_ReadTable_Comments(t *testing.T) {
|
||||
dbPath := setupTestDatabase(t)
|
||||
defer os.Remove(dbPath)
|
||||
|
||||
options := &readers.ReaderOptions{
|
||||
FilePath: dbPath,
|
||||
}
|
||||
|
||||
reader := NewReader(options)
|
||||
db, err := reader.ReadDatabase()
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, db)
|
||||
|
||||
// Find comments table
|
||||
var commentsTable *models.Table
|
||||
for _, table := range db.Schemas[0].Tables {
|
||||
if table.Name == "comments" {
|
||||
commentsTable = table
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
require.NotNil(t, commentsTable)
|
||||
|
||||
// Check foreign key constraints (should have 2)
|
||||
fkCount := 0
|
||||
for _, constraint := range commentsTable.Constraints {
|
||||
if constraint.Type == models.ForeignKeyConstraint {
|
||||
fkCount++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 2, fkCount, "Comments table should have 2 foreign key constraints")
|
||||
}
|
||||
|
||||
func TestReader_ReadSchema(t *testing.T) {
|
||||
dbPath := setupTestDatabase(t)
|
||||
defer os.Remove(dbPath)
|
||||
|
||||
options := &readers.ReaderOptions{
|
||||
FilePath: dbPath,
|
||||
}
|
||||
|
||||
reader := NewReader(options)
|
||||
schema, err := reader.ReadSchema()
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, schema)
|
||||
assert.Equal(t, "main", schema.Name)
|
||||
assert.Len(t, schema.Tables, 3)
|
||||
assert.Len(t, schema.Views, 1)
|
||||
}
|
||||
|
||||
func TestReader_ReadTable(t *testing.T) {
|
||||
dbPath := setupTestDatabase(t)
|
||||
defer os.Remove(dbPath)
|
||||
|
||||
options := &readers.ReaderOptions{
|
||||
FilePath: dbPath,
|
||||
}
|
||||
|
||||
reader := NewReader(options)
|
||||
table, err := reader.ReadTable()
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, table)
|
||||
assert.NotEmpty(t, table.Name)
|
||||
assert.NotEmpty(t, table.Columns)
|
||||
}
|
||||
|
||||
func TestReader_ConnectionString(t *testing.T) {
|
||||
dbPath := setupTestDatabase(t)
|
||||
defer os.Remove(dbPath)
|
||||
|
||||
options := &readers.ReaderOptions{
|
||||
ConnectionString: dbPath,
|
||||
}
|
||||
|
||||
reader := NewReader(options)
|
||||
db, err := reader.ReadDatabase()
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, db)
|
||||
assert.Len(t, db.Schemas, 1)
|
||||
}
|
||||
|
||||
func TestReader_InvalidPath(t *testing.T) {
|
||||
options := &readers.ReaderOptions{
|
||||
FilePath: "/nonexistent/path/to/database.db",
|
||||
}
|
||||
|
||||
reader := NewReader(options)
|
||||
_, err := reader.ReadDatabase()
|
||||
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestReader_MissingPath(t *testing.T) {
|
||||
options := &readers.ReaderOptions{}
|
||||
|
||||
reader := NewReader(options)
|
||||
_, err := reader.ReadDatabase()
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "file path or connection string is required")
|
||||
}
|
||||
@@ -5,9 +5,11 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/pgsql"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
)
|
||||
|
||||
@@ -549,6 +551,41 @@ func (r *Reader) parseColumnOptions(decorator string, column *models.Column, tab
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve explicit type modifiers from options where present.
|
||||
// Example: @Column({ type: 'varchar', length: 255 }) -> varchar(255)
|
||||
if column.Type != "" && !strings.Contains(column.Type, "(") {
|
||||
lengthRegex := regexp.MustCompile(`length:\s*(\d+)`)
|
||||
precisionRegex := regexp.MustCompile(`precision:\s*(\d+)`)
|
||||
scaleRegex := regexp.MustCompile(`scale:\s*(\d+)`)
|
||||
|
||||
baseType := strings.ToLower(strings.TrimSpace(column.Type))
|
||||
|
||||
if pgsql.SupportsLength(baseType) {
|
||||
if matches := lengthRegex.FindStringSubmatch(content); len(matches) == 2 {
|
||||
if n, err := strconv.Atoi(matches[1]); err == nil && n > 0 {
|
||||
column.Length = n
|
||||
column.Type = fmt.Sprintf("%s(%d)", column.Type, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if pgsql.SupportsPrecision(baseType) {
|
||||
if matches := precisionRegex.FindStringSubmatch(content); len(matches) == 2 {
|
||||
if p, err := strconv.Atoi(matches[1]); err == nil && p > 0 {
|
||||
column.Precision = p
|
||||
if sm := scaleRegex.FindStringSubmatch(content); len(sm) == 2 {
|
||||
if s, err := strconv.Atoi(sm[1]); err == nil && s >= 0 {
|
||||
column.Scale = s
|
||||
column.Type = fmt.Sprintf("%s(%d,%d)", column.Type, p, s)
|
||||
}
|
||||
} else {
|
||||
column.Type = fmt.Sprintf("%s(%d)", column.Type, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(content, "nullable: true") || strings.Contains(content, "nullable:true") {
|
||||
column.NotNull = false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package typeorm
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
func TestParseColumnOptions_PreservesTypeModifiers(t *testing.T) {
|
||||
reader := &Reader{}
|
||||
table := models.InitTable("users", "public")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
decorator string
|
||||
wantType string
|
||||
wantLength int
|
||||
wantPrecision int
|
||||
wantScale int
|
||||
}{
|
||||
{
|
||||
name: "varchar with length",
|
||||
decorator: `@Column({ type: 'varchar', length: 255 })`,
|
||||
wantType: "varchar(255)",
|
||||
wantLength: 255,
|
||||
},
|
||||
{
|
||||
name: "numeric with precision and scale",
|
||||
decorator: `@Column({ type: 'numeric', precision: 10, scale: 2 })`,
|
||||
wantType: "numeric(10,2)",
|
||||
wantPrecision: 10,
|
||||
wantScale: 2,
|
||||
},
|
||||
{
|
||||
name: "custom type with explicit modifier is preserved",
|
||||
decorator: `@Column({ type: 'vector(1536)' })`,
|
||||
wantType: "vector(1536)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
col := models.InitColumn("sample", table.Name, table.Schema)
|
||||
reader.parseColumnOptions(tt.decorator, col, table)
|
||||
|
||||
if col.Type != tt.wantType {
|
||||
t.Fatalf("column type = %q, want %q", col.Type, tt.wantType)
|
||||
}
|
||||
if col.Length != tt.wantLength {
|
||||
t.Fatalf("column length = %d, want %d", col.Length, tt.wantLength)
|
||||
}
|
||||
if col.Precision != tt.wantPrecision {
|
||||
t.Fatalf("column precision = %d, want %d", col.Precision, tt.wantPrecision)
|
||||
}
|
||||
if col.Scale != tt.wantScale {
|
||||
t.Fatalf("column scale = %d, want %d", col.Scale, tt.wantScale)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Package reflectutil provides reflection utilities for analyzing Go code structures.
|
||||
//
|
||||
// # Overview
|
||||
//
|
||||
// The reflectutil package offers helper functions for working with Go's reflection
|
||||
// capabilities, particularly for parsing Go struct definitions and extracting type
|
||||
// information. This is used by readers that parse ORM model files.
|
||||
//
|
||||
// # Features
|
||||
//
|
||||
// - Struct tag parsing and extraction
|
||||
// - Type information analysis
|
||||
// - Field metadata extraction
|
||||
// - ORM tag interpretation (GORM, Bun, etc.)
|
||||
//
|
||||
// # Usage
|
||||
//
|
||||
// This package is primarily used internally by readers like GORM and Bun to parse
|
||||
// Go struct definitions and convert them to database schema models.
|
||||
//
|
||||
// // Example: Parse struct tags
|
||||
// tags := reflectutil.ParseStructTags(field)
|
||||
// columnName := tags.Get("db")
|
||||
//
|
||||
// # Supported ORM Tags
|
||||
//
|
||||
// The package understands tag conventions from:
|
||||
// - GORM (gorm tag)
|
||||
// - Bun (bun tag)
|
||||
// - Standard database/sql (db tag)
|
||||
//
|
||||
// # Purpose
|
||||
//
|
||||
// This package enables RelSpec to read existing ORM models and convert them to
|
||||
// a unified schema representation for transformation to other formats.
|
||||
package reflectutil
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
// Deref dereferences pointers until it reaches a non-pointer value
|
||||
// Returns the dereferenced value and true if successful, or the original value and false if nil
|
||||
func Deref(v reflect.Value) (reflect.Value, bool) {
|
||||
for v.Kind() == reflect.Ptr {
|
||||
for v.Kind() == reflect.Pointer {
|
||||
if v.IsNil() {
|
||||
return v, false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package sqlite
|
||||
|
||||
import "strings"
|
||||
|
||||
// SQLiteToCanonicalTypes maps SQLite type names to canonical types.
|
||||
// SQLite has type affinity rules; this maps common type names including
|
||||
// MySQL/PostgreSQL types that users write in SQLite schemas.
|
||||
var SQLiteToCanonicalTypes = map[string]string{
|
||||
// Integer affinity
|
||||
"integer": "int",
|
||||
"int": "int",
|
||||
"tinyint": "int8",
|
||||
"smallint": "int16",
|
||||
"mediumint": "int",
|
||||
"bigint": "int64",
|
||||
"unsigned big int": "uint64",
|
||||
"int2": "int16",
|
||||
"int8": "int64",
|
||||
// Real affinity
|
||||
"real": "float64",
|
||||
"double": "float64",
|
||||
"double precision": "float64",
|
||||
"float": "float32",
|
||||
// Numeric affinity
|
||||
"numeric": "decimal",
|
||||
"decimal": "decimal",
|
||||
// Boolean (stored as integer in SQLite)
|
||||
"boolean": "bool",
|
||||
"bool": "bool",
|
||||
// Date/time (stored as text in SQLite)
|
||||
"date": "date",
|
||||
"datetime": "timestamp",
|
||||
"timestamp": "timestamp",
|
||||
// Text affinity
|
||||
"text": "string",
|
||||
"varchar": "string",
|
||||
"char": "string",
|
||||
"character": "string",
|
||||
"varying character": "string",
|
||||
"nchar": "string",
|
||||
"nvarchar": "string",
|
||||
"clob": "text",
|
||||
// Blob affinity
|
||||
"blob": "bytea",
|
||||
}
|
||||
|
||||
// CanonicalToSQLiteAffinity maps type names to SQLite type affinity names.
|
||||
// Accepts both Go canonical names ("int", "string") and SQL canonical names
|
||||
// ("integer", "varchar") so the writer handles input from any reader.
|
||||
// The five SQLite type affinities are TEXT, INTEGER, REAL, NUMERIC, BLOB.
|
||||
var CanonicalToSQLiteAffinity = map[string]string{
|
||||
// INTEGER affinity — Go canonical
|
||||
"int": "INTEGER",
|
||||
"int8": "INTEGER",
|
||||
"int16": "INTEGER",
|
||||
"int32": "INTEGER",
|
||||
"int64": "INTEGER",
|
||||
"uint": "INTEGER",
|
||||
"uint8": "INTEGER",
|
||||
"uint16": "INTEGER",
|
||||
"uint32": "INTEGER",
|
||||
"uint64": "INTEGER",
|
||||
"bool": "INTEGER",
|
||||
// INTEGER affinity — SQL canonical
|
||||
"integer": "INTEGER",
|
||||
"smallint": "INTEGER",
|
||||
"bigint": "INTEGER",
|
||||
"serial": "INTEGER",
|
||||
"smallserial": "INTEGER",
|
||||
"bigserial": "INTEGER",
|
||||
"boolean": "INTEGER",
|
||||
"tinyint": "INTEGER",
|
||||
"mediumint": "INTEGER",
|
||||
// REAL affinity — Go canonical
|
||||
"float32": "REAL",
|
||||
"float64": "REAL",
|
||||
// REAL affinity — SQL canonical
|
||||
"real": "REAL",
|
||||
"float": "REAL",
|
||||
"double": "REAL",
|
||||
"double precision": "REAL",
|
||||
// NUMERIC affinity
|
||||
"decimal": "NUMERIC",
|
||||
"numeric": "NUMERIC",
|
||||
"money": "NUMERIC",
|
||||
"smallmoney": "NUMERIC",
|
||||
// BLOB affinity
|
||||
"bytea": "BLOB",
|
||||
"blob": "BLOB",
|
||||
// TEXT affinity — Go canonical
|
||||
"string": "TEXT",
|
||||
"text": "TEXT",
|
||||
// TEXT affinity — SQL canonical
|
||||
"varchar": "TEXT",
|
||||
"char": "TEXT",
|
||||
"nvarchar": "TEXT",
|
||||
"nchar": "TEXT",
|
||||
"citext": "TEXT",
|
||||
"date": "TEXT",
|
||||
"time": "TEXT",
|
||||
"timetz": "TEXT",
|
||||
"timestamp": "TEXT",
|
||||
"timestamptz": "TEXT",
|
||||
"datetime": "TEXT",
|
||||
"uuid": "TEXT",
|
||||
"json": "TEXT",
|
||||
"jsonb": "TEXT",
|
||||
"xml": "TEXT",
|
||||
"inet": "TEXT",
|
||||
"cidr": "TEXT",
|
||||
"macaddr": "TEXT",
|
||||
}
|
||||
|
||||
// ConvertSQLiteToCanonical converts a SQLite type name to the canonical type.
|
||||
// Strips dimension parameters (e.g. VARCHAR(255) → string) and handles
|
||||
// SQLite's flexible affinity rules. Defaults to "string" for unknown types.
|
||||
func ConvertSQLiteToCanonical(sqliteType string) string {
|
||||
base := strings.ToUpper(strings.TrimSpace(sqliteType))
|
||||
if idx := strings.Index(base, "("); idx >= 0 {
|
||||
base = strings.TrimSpace(base[:idx])
|
||||
}
|
||||
lower := strings.ToLower(base)
|
||||
|
||||
if canonical, ok := SQLiteToCanonicalTypes[lower]; ok {
|
||||
return canonical
|
||||
}
|
||||
|
||||
// Prefix match for types like "VARYING CHARACTER(255)"
|
||||
for key, canonical := range SQLiteToCanonicalTypes {
|
||||
if strings.HasPrefix(lower, key) {
|
||||
return canonical
|
||||
}
|
||||
}
|
||||
|
||||
return "string"
|
||||
}
|
||||
|
||||
// ConvertCanonicalToSQLite converts a canonical type (or any SQL type) to its
|
||||
// SQLite type affinity. Defaults to TEXT for unrecognised types.
|
||||
func ConvertCanonicalToSQLite(canonicalType string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(canonicalType))
|
||||
if idx := strings.Index(normalized, "("); idx >= 0 {
|
||||
normalized = strings.TrimSpace(normalized[:idx])
|
||||
}
|
||||
normalized = strings.TrimSuffix(normalized, "[]")
|
||||
|
||||
if affinity, ok := CanonicalToSQLiteAffinity[normalized]; ok {
|
||||
return affinity
|
||||
}
|
||||
|
||||
return "TEXT"
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
# sqltypes
|
||||
|
||||
Nullable SQL types for hand-written or generated Go models. Each type wraps a
|
||||
value with a `Valid` flag and implements `database/sql.Scanner`,
|
||||
`driver.Valuer`, `encoding/json`, `gopkg.in/yaml.v3`, and `encoding/xml`
|
||||
marshalling — so a single struct field can be scanned from a database row,
|
||||
round-tripped through JSON/YAML/XML, and written back to the database without
|
||||
any per-format glue code.
|
||||
|
||||
This package is what the `bun` and `gorm` writers emit when generating models
|
||||
with `--types sqltypes` (see [`pkg/writers/bun`](../writers/bun/README.md) and
|
||||
[`pkg/writers/gorm`](../writers/gorm/README.md)). It can also be imported
|
||||
directly in hand-written models.
|
||||
|
||||
## Import
|
||||
|
||||
```go
|
||||
import sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||
```
|
||||
|
||||
## Scalar types
|
||||
|
||||
All scalar types are instantiations of the generic `SqlNull[T]`:
|
||||
|
||||
| Type | Underlying | Typical SQL type |
|
||||
|---|---|---|
|
||||
| `SqlInt16` | `int16` | `smallint` |
|
||||
| `SqlInt32` | `int32` | `integer` |
|
||||
| `SqlInt64` | `int64` | `bigint` |
|
||||
| `SqlFloat32` | `float32` | `real`, `float4` |
|
||||
| `SqlFloat64` | `float64` | `double precision`, `numeric`, `decimal`, `money` |
|
||||
| `SqlBool` | `bool` | `boolean` |
|
||||
| `SqlString` | `string` | `text`, `varchar`, `char`, `citext`, `inet`, `cidr`, `macaddr` |
|
||||
| `SqlByteArray` | `[]byte` | `bytea` (base64-encoded in JSON/YAML/XML) |
|
||||
| `SqlUUID` | `uuid.UUID` (`github.com/google/uuid`) | `uuid` |
|
||||
|
||||
You can also instantiate `SqlNull[T]` directly for any type not covered
|
||||
above, e.g. `SqlNull[MyEnum]`.
|
||||
|
||||
### Date/time types
|
||||
|
||||
Plain `time.Time` doesn't distinguish date-only, time-only, and timestamp
|
||||
semantics, and its zero value marshals to a confusing `0001-01-01T00:00:00Z`.
|
||||
These wrapper types fix both problems:
|
||||
|
||||
| Type | Format | Notes |
|
||||
|---|---|---|
|
||||
| `SqlTimeStamp` | `2006-01-02T15:04:05` | Full timestamp |
|
||||
| `SqlDate` | `2006-01-02` | Date only |
|
||||
| `SqlTime` | `15:04:05` | Time only |
|
||||
|
||||
Zero/pre-epoch values (`time.Time{}` or anything before `0002-01-01`) marshal
|
||||
to `null` and `Value()` returns `nil`, instead of leaking Go's zero-time
|
||||
sentinel into the database or API responses.
|
||||
|
||||
### JSON types
|
||||
|
||||
| Type | Underlying | Notes |
|
||||
|---|---|---|
|
||||
| `SqlJSONB` | `[]byte` | Raw JSON bytes; `MarshalYAML` decodes to native YAML mappings/sequences instead of an embedded JSON string |
|
||||
| `SqlJSON` | `= SqlJSONB` | Alias — PostgreSQL's `json` and `jsonb` share the same Go representation |
|
||||
|
||||
`SqlJSONB` has `AsMap()` / `AsSlice()` helpers for pulling out
|
||||
`map[string]any` / `[]any` without a separate `json.Unmarshal` call.
|
||||
|
||||
### Vector type (pgvector)
|
||||
|
||||
`SqlVector` wraps `[]float32` for the `vector` column type ([pgvector](https://github.com/pgvector/pgvector)),
|
||||
scanning/writing the `[1,2,3]` literal format pgvector uses over the wire.
|
||||
|
||||
## Array types
|
||||
|
||||
PostgreSQL array columns (`text[]`, `integer[]`, …) map to `SqlXxxArray`
|
||||
types, each wrapping `Val []T` + `Valid bool` and handling PostgreSQL's
|
||||
`{a,b,c}` array literal format on `Scan`/`Value`:
|
||||
|
||||
`SqlStringArray`, `SqlInt16Array`, `SqlInt32Array`, `SqlInt64Array`,
|
||||
`SqlFloat32Array`, `SqlFloat64Array`, `SqlBoolArray`, `SqlUUIDArray`.
|
||||
|
||||
## Constructing values
|
||||
|
||||
Every type has a `NewSqlXxx(v)` constructor that sets `Valid: true`:
|
||||
|
||||
```go
|
||||
name := sql_types.NewSqlString("Ada Lovelace")
|
||||
age := sql_types.NewSqlInt32(36)
|
||||
tags := sql_types.NewSqlStringArray([]string{"engineer", "mathematician"})
|
||||
```
|
||||
|
||||
The zero value of any type (`sql_types.SqlString{}`) is null/invalid — use it
|
||||
directly for a `NULL` field instead of a separate constructor.
|
||||
|
||||
Generic helpers:
|
||||
|
||||
```go
|
||||
sql_types.Null(v, valid) // SqlNull[T]{Val: v, Valid: valid}
|
||||
sql_types.NewSql[T](anyValue) // best-effort conversion from any Go value
|
||||
```
|
||||
|
||||
## Reading values back
|
||||
|
||||
Each scalar type has typed accessors that return the zero value instead of
|
||||
panicking when `Valid` is false:
|
||||
|
||||
```go
|
||||
n.Int64() // SqlInt16/32/64, SqlFloat32/64, SqlBool, SqlString → int64
|
||||
n.Float64() // → float64
|
||||
n.Bool() // → bool
|
||||
n.Time() // SqlNull[time.Time]-based types → time.Time
|
||||
n.UUID() // SqlUUID → uuid.UUID
|
||||
n.String() // fmt.Stringer — empty string when invalid
|
||||
```
|
||||
|
||||
## Example
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
ID sql_types.SqlUUID `json:"id"`
|
||||
Name sql_types.SqlString `json:"name"`
|
||||
Tags sql_types.SqlStringArray `json:"tags"`
|
||||
Metadata sql_types.SqlJSONB `json:"metadata"`
|
||||
CreatedAt sql_types.SqlTimeStamp `json:"created_at"`
|
||||
}
|
||||
|
||||
u := User{
|
||||
ID: sql_types.NewSqlUUID(uuid.New()),
|
||||
Name: sql_types.NewSqlString("Ada Lovelace"),
|
||||
Tags: sql_types.NewSqlStringArray([]string{"engineer"}),
|
||||
CreatedAt: sql_types.SqlTimeStampNow(),
|
||||
}
|
||||
// Metadata left as the zero value → serializes as null, scans as NULL.
|
||||
```
|
||||
|
||||
Every type implements `sql.Scanner` and `driver.Valuer`, so these fields can
|
||||
be used directly as struct fields with `database/sql`, `bun`, or `gorm`
|
||||
without additional tags or hooks.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,485 @@
|
||||
package sqltypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestParsePostgresArrayElements(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want []string
|
||||
wantErr bool
|
||||
}{
|
||||
{"simple", "{a,b,c}", []string{"a", "b", "c"}, false},
|
||||
{"empty array", "{}", []string{}, false},
|
||||
{"null", "NULL", nil, false},
|
||||
{"lowercase null", "null", nil, false},
|
||||
{"empty string", "", nil, false},
|
||||
{"quoted with comma", `{a,"b,c",d}`, []string{"a", "b,c", "d"}, false},
|
||||
{"escaped quote", `{"a""b"}`, []string{`a"b`}, false},
|
||||
{"escaped backslash", `{"a\\b"}`, []string{`a\b`}, false},
|
||||
{"not an array", "abc", nil, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := parsePostgresArrayElements(tt.input)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("expected %v, got %v", tt.want, got)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Errorf("index %d: expected %q, got %q", i, tt.want[i], got[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPostgresStringArray(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input []string
|
||||
want string
|
||||
}{
|
||||
{"nil", nil, "NULL"},
|
||||
{"empty", []string{}, "{}"},
|
||||
{"simple", []string{"a", "b"}, "{a,b}"},
|
||||
{"needs quoting comma", []string{"a,b"}, `{"a,b"}`},
|
||||
{"needs quoting empty elem", []string{""}, `{""}`},
|
||||
{"needs quoting quote", []string{`a"b`}, `{"a""b"}`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := formatPostgresStringArray(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("expected %q, got %q", tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqlStringArray(t *testing.T) {
|
||||
t.Run("scan and value round-trip", func(t *testing.T) {
|
||||
var a SqlStringArray
|
||||
if err := a.Scan(`{a,"b,c",d}`); err != nil {
|
||||
t.Fatalf("Scan failed: %v", err)
|
||||
}
|
||||
want := []string{"a", "b,c", "d"}
|
||||
if len(a.Val) != len(want) {
|
||||
t.Fatalf("expected %v, got %v", want, a.Val)
|
||||
}
|
||||
for i := range want {
|
||||
if a.Val[i] != want[i] {
|
||||
t.Errorf("index %d: expected %q, got %q", i, want[i], a.Val[i])
|
||||
}
|
||||
}
|
||||
|
||||
val, err := a.Value()
|
||||
if err != nil {
|
||||
t.Fatalf("Value failed: %v", err)
|
||||
}
|
||||
var b SqlStringArray
|
||||
if err := b.Scan(val); err != nil {
|
||||
t.Fatalf("re-scan failed: %v", err)
|
||||
}
|
||||
for i := range want {
|
||||
if b.Val[i] != want[i] {
|
||||
t.Errorf("round-trip index %d: expected %q, got %q", i, want[i], b.Val[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("scan nil", func(t *testing.T) {
|
||||
var a SqlStringArray
|
||||
if err := a.Scan(nil); err != nil {
|
||||
t.Fatalf("Scan failed: %v", err)
|
||||
}
|
||||
if a.Valid {
|
||||
t.Error("expected invalid")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("value invalid", func(t *testing.T) {
|
||||
a := SqlStringArray{Valid: false}
|
||||
val, err := a.Value()
|
||||
if err != nil {
|
||||
t.Fatalf("Value failed: %v", err)
|
||||
}
|
||||
if val != nil {
|
||||
t.Errorf("expected nil, got %v", val)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("scan wrong type", func(t *testing.T) {
|
||||
var a SqlStringArray
|
||||
if err := a.Scan(42); err == nil {
|
||||
t.Error("expected error for unsupported scan type")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("json round-trip", func(t *testing.T) {
|
||||
a := NewSqlStringArray([]string{"x", "y", "z"})
|
||||
data, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
if string(data) != `["x","y","z"]` {
|
||||
t.Errorf("unexpected JSON: %s", data)
|
||||
}
|
||||
var a2 SqlStringArray
|
||||
if err := json.Unmarshal(data, &a2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if !a2.Valid || len(a2.Val) != 3 {
|
||||
t.Fatalf("expected 3 valid elements, got %v", a2)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("json null", func(t *testing.T) {
|
||||
var a SqlStringArray
|
||||
data, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
if string(data) != "null" {
|
||||
t.Errorf("expected null, got %s", data)
|
||||
}
|
||||
var a2 SqlStringArray
|
||||
if err := json.Unmarshal([]byte("null"), &a2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if a2.Valid {
|
||||
t.Error("expected invalid after unmarshaling null")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("json unmarshal invalid type errors", func(t *testing.T) {
|
||||
var a SqlStringArray
|
||||
if err := json.Unmarshal([]byte(`42`), &a); err == nil {
|
||||
t.Error("expected error unmarshaling non-array JSON")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSqlInt16Array(t *testing.T) {
|
||||
t.Run("scan and value", func(t *testing.T) {
|
||||
var a SqlInt16Array
|
||||
if err := a.Scan("{1,2,-3}"); err != nil {
|
||||
t.Fatalf("Scan failed: %v", err)
|
||||
}
|
||||
want := []int16{1, 2, -3}
|
||||
for i := range want {
|
||||
if a.Val[i] != want[i] {
|
||||
t.Errorf("index %d: expected %d, got %d", i, want[i], a.Val[i])
|
||||
}
|
||||
}
|
||||
val, err := a.Value()
|
||||
if err != nil {
|
||||
t.Fatalf("Value failed: %v", err)
|
||||
}
|
||||
if val != "{1,2,-3}" {
|
||||
t.Errorf("expected {1,2,-3}, got %v", val)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("scan invalid element", func(t *testing.T) {
|
||||
var a SqlInt16Array
|
||||
if err := a.Scan("{1,abc}"); err == nil {
|
||||
t.Error("expected error for non-numeric element")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("json round-trip", func(t *testing.T) {
|
||||
a := NewSqlInt16Array([]int16{5, 10, 15})
|
||||
data, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var a2 SqlInt16Array
|
||||
if err := json.Unmarshal(data, &a2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
for i, v := range a.Val {
|
||||
if a2.Val[i] != v {
|
||||
t.Errorf("index %d: expected %d, got %d", i, v, a2.Val[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSqlInt32Array(t *testing.T) {
|
||||
a := NewSqlInt32Array([]int32{100000, -200000})
|
||||
val, err := a.Value()
|
||||
if err != nil {
|
||||
t.Fatalf("Value failed: %v", err)
|
||||
}
|
||||
var b SqlInt32Array
|
||||
if err := b.Scan(val); err != nil {
|
||||
t.Fatalf("Scan failed: %v", err)
|
||||
}
|
||||
for i, v := range a.Val {
|
||||
if b.Val[i] != v {
|
||||
t.Errorf("index %d: expected %d, got %d", i, v, b.Val[i])
|
||||
}
|
||||
}
|
||||
|
||||
data, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var c SqlInt32Array
|
||||
if err := json.Unmarshal(data, &c); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
for i, v := range a.Val {
|
||||
if c.Val[i] != v {
|
||||
t.Errorf("index %d: expected %d, got %d", i, v, c.Val[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqlInt64Array(t *testing.T) {
|
||||
a := NewSqlInt64Array([]int64{9223372036854775807, -9223372036854775808})
|
||||
val, err := a.Value()
|
||||
if err != nil {
|
||||
t.Fatalf("Value failed: %v", err)
|
||||
}
|
||||
var b SqlInt64Array
|
||||
if err := b.Scan(val); err != nil {
|
||||
t.Fatalf("Scan failed: %v", err)
|
||||
}
|
||||
for i, v := range a.Val {
|
||||
if b.Val[i] != v {
|
||||
t.Errorf("index %d: expected %d, got %d", i, v, b.Val[i])
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("null json", func(t *testing.T) {
|
||||
var n SqlInt64Array
|
||||
data, _ := json.Marshal(n)
|
||||
if string(data) != "null" {
|
||||
t.Errorf("expected null, got %s", data)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSqlFloat32Array(t *testing.T) {
|
||||
a := NewSqlFloat32Array([]float32{1.5, -2.25, 0})
|
||||
val, err := a.Value()
|
||||
if err != nil {
|
||||
t.Fatalf("Value failed: %v", err)
|
||||
}
|
||||
var b SqlFloat32Array
|
||||
if err := b.Scan(val); err != nil {
|
||||
t.Fatalf("Scan failed: %v", err)
|
||||
}
|
||||
for i, v := range a.Val {
|
||||
if b.Val[i] != v {
|
||||
t.Errorf("index %d: expected %v, got %v", i, v, b.Val[i])
|
||||
}
|
||||
}
|
||||
|
||||
data, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var c SqlFloat32Array
|
||||
if err := json.Unmarshal(data, &c); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
for i, v := range a.Val {
|
||||
if c.Val[i] != v {
|
||||
t.Errorf("index %d: expected %v, got %v", i, v, c.Val[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqlFloat64Array(t *testing.T) {
|
||||
a := NewSqlFloat64Array([]float64{3.14159, -2.71828})
|
||||
val, err := a.Value()
|
||||
if err != nil {
|
||||
t.Fatalf("Value failed: %v", err)
|
||||
}
|
||||
var b SqlFloat64Array
|
||||
if err := b.Scan(val); err != nil {
|
||||
t.Fatalf("Scan failed: %v", err)
|
||||
}
|
||||
for i, v := range a.Val {
|
||||
if b.Val[i] != v {
|
||||
t.Errorf("index %d: expected %v, got %v", i, v, b.Val[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqlBoolArray(t *testing.T) {
|
||||
t.Run("scan various truthy forms", func(t *testing.T) {
|
||||
var a SqlBoolArray
|
||||
if err := a.Scan("{t,f,true,false,1,0,yes}"); err != nil {
|
||||
t.Fatalf("Scan failed: %v", err)
|
||||
}
|
||||
want := []bool{true, false, true, false, true, false, true}
|
||||
for i := range want {
|
||||
if a.Val[i] != want[i] {
|
||||
t.Errorf("index %d: expected %v, got %v", i, want[i], a.Val[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("value formatting", func(t *testing.T) {
|
||||
a := NewSqlBoolArray([]bool{true, false})
|
||||
val, err := a.Value()
|
||||
if err != nil {
|
||||
t.Fatalf("Value failed: %v", err)
|
||||
}
|
||||
if val != "{t,f}" {
|
||||
t.Errorf("expected {t,f}, got %v", val)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("json round-trip", func(t *testing.T) {
|
||||
a := NewSqlBoolArray([]bool{true, false, true})
|
||||
data, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var a2 SqlBoolArray
|
||||
if err := json.Unmarshal(data, &a2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
for i, v := range a.Val {
|
||||
if a2.Val[i] != v {
|
||||
t.Errorf("index %d: expected %v, got %v", i, v, a2.Val[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSqlUUIDArray(t *testing.T) {
|
||||
u1, u2 := uuid.New(), uuid.New()
|
||||
|
||||
t.Run("scan and value round-trip", func(t *testing.T) {
|
||||
a := NewSqlUUIDArray([]uuid.UUID{u1, u2})
|
||||
val, err := a.Value()
|
||||
if err != nil {
|
||||
t.Fatalf("Value failed: %v", err)
|
||||
}
|
||||
var b SqlUUIDArray
|
||||
if err := b.Scan(val); err != nil {
|
||||
t.Fatalf("Scan failed: %v", err)
|
||||
}
|
||||
if b.Val[0] != u1 || b.Val[1] != u2 {
|
||||
t.Errorf("expected [%v %v], got %v", u1, u2, b.Val)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("scan invalid uuid element", func(t *testing.T) {
|
||||
var a SqlUUIDArray
|
||||
if err := a.Scan("{not-a-uuid}"); err == nil {
|
||||
t.Error("expected error for invalid uuid element")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("json round-trip", func(t *testing.T) {
|
||||
a := NewSqlUUIDArray([]uuid.UUID{u1, u2})
|
||||
data, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var a2 SqlUUIDArray
|
||||
if err := json.Unmarshal(data, &a2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if a2.Val[0] != u1 || a2.Val[1] != u2 {
|
||||
t.Errorf("expected [%v %v], got %v", u1, u2, a2.Val)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSqlVector(t *testing.T) {
|
||||
t.Run("scan and value round-trip", func(t *testing.T) {
|
||||
var v SqlVector
|
||||
if err := v.Scan("[1,2.5,-3]"); err != nil {
|
||||
t.Fatalf("Scan failed: %v", err)
|
||||
}
|
||||
want := []float32{1, 2.5, -3}
|
||||
for i := range want {
|
||||
if v.Val[i] != want[i] {
|
||||
t.Errorf("index %d: expected %v, got %v", i, want[i], v.Val[i])
|
||||
}
|
||||
}
|
||||
val, err := v.Value()
|
||||
if err != nil {
|
||||
t.Fatalf("Value failed: %v", err)
|
||||
}
|
||||
if val != "[1,2.5,-3]" {
|
||||
t.Errorf("expected [1,2.5,-3], got %v", val)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("scan empty vector", func(t *testing.T) {
|
||||
var v SqlVector
|
||||
if err := v.Scan("[]"); err != nil {
|
||||
t.Fatalf("Scan failed: %v", err)
|
||||
}
|
||||
if !v.Valid || len(v.Val) != 0 {
|
||||
t.Errorf("expected valid empty vector, got %v", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("scan invalid literal", func(t *testing.T) {
|
||||
var v SqlVector
|
||||
if err := v.Scan("not-a-vector"); err == nil {
|
||||
t.Error("expected error for invalid vector literal")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("json round-trip", func(t *testing.T) {
|
||||
v := NewSqlVector([]float32{0.1, 0.2, 0.3})
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var v2 SqlVector
|
||||
if err := json.Unmarshal(data, &v2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
for i, f := range v.Val {
|
||||
if v2.Val[i] != f {
|
||||
t.Errorf("index %d: expected %v, got %v", i, f, v2.Val[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("json null", func(t *testing.T) {
|
||||
var v SqlVector
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
if string(data) != "null" {
|
||||
t.Errorf("expected null, got %s", data)
|
||||
}
|
||||
var v2 SqlVector
|
||||
if err := json.Unmarshal([]byte("null"), &v2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if v2.Valid {
|
||||
t.Error("expected invalid after unmarshaling null")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,981 @@
|
||||
// Package sqltypes provides nullable SQL types with automatic casting and conversion methods.
|
||||
package sqltypes
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// tryParseDT attempts to parse a string into a time.Time using various formats.
|
||||
func tryParseDT(str string) (time.Time, error) {
|
||||
var lasterror error
|
||||
tryFormats := []string{
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05.000-0700",
|
||||
"2006-01-02T15:04:05.000",
|
||||
"06-01-02T15:04:05.000",
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02 15:04:05",
|
||||
"02/01/2006",
|
||||
"02-01-2006",
|
||||
"2006-01-02",
|
||||
"15:04:05.000",
|
||||
"15:04:05",
|
||||
"15:04",
|
||||
}
|
||||
for _, f := range tryFormats {
|
||||
tx, err := time.Parse(f, str)
|
||||
if err == nil {
|
||||
return tx, nil
|
||||
}
|
||||
lasterror = err
|
||||
}
|
||||
return time.Time{}, lasterror // Return zero time on failure
|
||||
}
|
||||
|
||||
// ToJSONDT formats a time.Time to RFC3339 string.
|
||||
func ToJSONDT(dt time.Time) string {
|
||||
return dt.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// SqlNull is a generic nullable type that behaves like sql.NullXXX with auto-casting.
|
||||
type SqlNull[T any] struct {
|
||||
Val T
|
||||
Valid bool
|
||||
}
|
||||
|
||||
// Scan implements sql.Scanner.
|
||||
func (n *SqlNull[T]) Scan(value any) error {
|
||||
if value == nil {
|
||||
n.Valid = false
|
||||
n.Val = *new(T)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if T is []byte, and decode base64 if applicable
|
||||
// Do this BEFORE trying sql.Null to ensure base64 is handled
|
||||
var zero T
|
||||
if _, ok := any(zero).([]byte); ok {
|
||||
// For []byte types, try to decode from base64
|
||||
var strVal string
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
strVal = v
|
||||
case []byte:
|
||||
strVal = string(v)
|
||||
default:
|
||||
strVal = fmt.Sprintf("%v", value)
|
||||
}
|
||||
// Try base64 decode
|
||||
if decoded, err := base64.StdEncoding.DecodeString(strVal); err == nil {
|
||||
n.Val = any(decoded).(T)
|
||||
n.Valid = true
|
||||
return nil
|
||||
}
|
||||
// Fallback to raw bytes
|
||||
n.Val = any([]byte(strVal)).(T)
|
||||
n.Valid = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try standard sql.Null[T] for other types.
|
||||
var sqlNull sql.Null[T]
|
||||
if err := sqlNull.Scan(value); err == nil {
|
||||
n.Val = sqlNull.V
|
||||
n.Valid = sqlNull.Valid
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fallback: parse from string/bytes.
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return n.FromString(v)
|
||||
case []byte:
|
||||
return n.FromString(string(v))
|
||||
case float32, float64:
|
||||
return n.FromString(fmt.Sprintf("%f", value))
|
||||
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
|
||||
return n.FromString(fmt.Sprintf("%d", value))
|
||||
default:
|
||||
return n.FromString(fmt.Sprintf("%v", value))
|
||||
}
|
||||
}
|
||||
func (n *SqlNull[T]) FromString(s string) error {
|
||||
s = strings.TrimSpace(s)
|
||||
n.Valid = false
|
||||
n.Val = *new(T)
|
||||
|
||||
if s == "" || strings.EqualFold(s, "null") {
|
||||
return nil
|
||||
}
|
||||
|
||||
var zero T
|
||||
switch any(zero).(type) {
|
||||
case int, int8, int16, int32, int64:
|
||||
if i, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||
reflect.ValueOf(&n.Val).Elem().SetInt(i)
|
||||
n.Valid = true
|
||||
} else if f, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
reflect.ValueOf(&n.Val).Elem().SetInt(int64(f))
|
||||
n.Valid = true
|
||||
}
|
||||
case uint, uint8, uint16, uint32, uint64:
|
||||
if u, err := strconv.ParseUint(s, 10, 64); err == nil {
|
||||
reflect.ValueOf(&n.Val).Elem().SetUint(u)
|
||||
n.Valid = true
|
||||
} else if f, err := strconv.ParseFloat(s, 64); err == nil && f >= 0 {
|
||||
reflect.ValueOf(&n.Val).Elem().SetUint(uint64(f))
|
||||
n.Valid = true
|
||||
}
|
||||
case float32, float64:
|
||||
if f, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
reflect.ValueOf(&n.Val).Elem().SetFloat(f)
|
||||
n.Valid = true
|
||||
}
|
||||
case bool:
|
||||
if b, err := strconv.ParseBool(s); err == nil {
|
||||
n.Val = any(b).(T)
|
||||
n.Valid = true
|
||||
}
|
||||
case time.Time:
|
||||
if t, err := tryParseDT(s); err == nil && !t.IsZero() {
|
||||
n.Val = any(t).(T)
|
||||
n.Valid = true
|
||||
}
|
||||
case uuid.UUID:
|
||||
if u, err := uuid.Parse(s); err == nil {
|
||||
n.Val = any(u).(T)
|
||||
n.Valid = true
|
||||
}
|
||||
case []byte:
|
||||
n.Val = any([]byte(s)).(T)
|
||||
n.Valid = true
|
||||
case string:
|
||||
n.Val = any(s).(T)
|
||||
n.Valid = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Value implements driver.Valuer.
|
||||
func (n SqlNull[T]) Value() (driver.Value, error) {
|
||||
if !n.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Check if the type implements fmt.Stringer (e.g., uuid.UUID, custom types)
|
||||
// Convert to string for driver compatibility
|
||||
if stringer, ok := any(n.Val).(fmt.Stringer); ok {
|
||||
return stringer.String(), nil
|
||||
}
|
||||
|
||||
return any(n.Val), nil
|
||||
}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
func (n SqlNull[T]) MarshalJSON() ([]byte, error) {
|
||||
if !n.Valid {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
|
||||
// Check if T is []byte, and encode to base64
|
||||
if _, ok := any(n.Val).([]byte); ok {
|
||||
// Encode []byte as base64
|
||||
encoded := base64.StdEncoding.EncodeToString(any(n.Val).([]byte))
|
||||
return json.Marshal(encoded)
|
||||
}
|
||||
|
||||
return json.Marshal(n.Val)
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler.
|
||||
func (n *SqlNull[T]) UnmarshalJSON(b []byte) error {
|
||||
if len(b) == 0 || string(b) == "null" || strings.TrimSpace(string(b)) == "" {
|
||||
n.Valid = false
|
||||
n.Val = *new(T)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if T is []byte, and decode from base64
|
||||
var val T
|
||||
if _, ok := any(val).([]byte); ok {
|
||||
// Unmarshal as string first (JSON representation)
|
||||
var s string
|
||||
if err := json.Unmarshal(b, &s); err == nil {
|
||||
// Decode from base64
|
||||
if decoded, err := base64.StdEncoding.DecodeString(s); err == nil {
|
||||
n.Val = any(decoded).(T)
|
||||
n.Valid = true
|
||||
return nil
|
||||
}
|
||||
// Fallback to raw string as bytes
|
||||
n.Val = any([]byte(s)).(T)
|
||||
n.Valid = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(b, &val); err == nil {
|
||||
n.Val = val
|
||||
n.Valid = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fallback: unmarshal as string and parse.
|
||||
var s string
|
||||
if err := json.Unmarshal(b, &s); err == nil {
|
||||
return n.FromString(s)
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot unmarshal %s into SqlNull[%T]", b, n.Val)
|
||||
}
|
||||
|
||||
// MarshalYAML implements yaml.Marshaler.
|
||||
func (n SqlNull[T]) MarshalYAML() (any, error) {
|
||||
if !n.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Check if T is []byte, and encode to base64 (mirrors MarshalJSON).
|
||||
if b, ok := any(n.Val).([]byte); ok {
|
||||
return base64.StdEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
return n.Val, nil
|
||||
}
|
||||
|
||||
// UnmarshalYAML implements yaml.Unmarshaler.
|
||||
func (n *SqlNull[T]) UnmarshalYAML(value *yaml.Node) error {
|
||||
if value == nil || value.Tag == "!!null" {
|
||||
n.Valid = false
|
||||
n.Val = *new(T)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if T is []byte, and decode from base64.
|
||||
var zero T
|
||||
if _, ok := any(zero).([]byte); ok {
|
||||
var s string
|
||||
if err := value.Decode(&s); err == nil {
|
||||
if decoded, err := base64.StdEncoding.DecodeString(s); err == nil {
|
||||
n.Val = any(decoded).(T)
|
||||
n.Valid = true
|
||||
return nil
|
||||
}
|
||||
n.Val = any([]byte(s)).(T)
|
||||
n.Valid = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var val T
|
||||
if err := value.Decode(&val); err == nil {
|
||||
n.Val = val
|
||||
n.Valid = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fallback: decode as string and parse.
|
||||
var s string
|
||||
if err := value.Decode(&s); err == nil {
|
||||
return n.FromString(s)
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot unmarshal %q into SqlNull[%T]", value.Value, n.Val)
|
||||
}
|
||||
|
||||
// MarshalXML implements xml.Marshaler.
|
||||
func (n SqlNull[T]) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
||||
if !n.Valid {
|
||||
return e.EncodeElement("", start)
|
||||
}
|
||||
|
||||
// Check if T is []byte, and encode to base64 (mirrors MarshalJSON).
|
||||
if b, ok := any(n.Val).([]byte); ok {
|
||||
return e.EncodeElement(base64.StdEncoding.EncodeToString(b), start)
|
||||
}
|
||||
|
||||
return e.EncodeElement(n.Val, start)
|
||||
}
|
||||
|
||||
// UnmarshalXML implements xml.Unmarshaler.
|
||||
//
|
||||
// XML has no native null representation, so an empty element unmarshals to
|
||||
// an invalid (null) value rather than a zero-value-but-valid one.
|
||||
func (n *SqlNull[T]) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||
var s string
|
||||
if err := d.DecodeElement(&s, &start); err != nil {
|
||||
return err
|
||||
}
|
||||
if s == "" {
|
||||
n.Valid = false
|
||||
n.Val = *new(T)
|
||||
return nil
|
||||
}
|
||||
|
||||
var zero T
|
||||
if _, ok := any(zero).([]byte); ok {
|
||||
if decoded, err := base64.StdEncoding.DecodeString(s); err == nil {
|
||||
n.Val = any(decoded).(T)
|
||||
n.Valid = true
|
||||
return nil
|
||||
}
|
||||
n.Val = any([]byte(s)).(T)
|
||||
n.Valid = true
|
||||
return nil
|
||||
}
|
||||
|
||||
return n.FromString(s)
|
||||
}
|
||||
|
||||
// String implements fmt.Stringer.
|
||||
func (n SqlNull[T]) String() string {
|
||||
if !n.Valid {
|
||||
return ""
|
||||
}
|
||||
// Check if the type implements fmt.Stringer for better string representation
|
||||
if stringer, ok := any(n.Val).(fmt.Stringer); ok {
|
||||
return stringer.String()
|
||||
}
|
||||
return fmt.Sprintf("%v", n.Val)
|
||||
}
|
||||
|
||||
// Int64 converts to int64 or 0 if invalid.
|
||||
func (n SqlNull[T]) Int64() int64 {
|
||||
if !n.Valid {
|
||||
return 0
|
||||
}
|
||||
v := reflect.ValueOf(any(n.Val))
|
||||
switch v.Kind() {
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return v.Int()
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
return int64(v.Uint())
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return int64(v.Float())
|
||||
case reflect.String:
|
||||
i, _ := strconv.ParseInt(v.String(), 10, 64)
|
||||
return i
|
||||
case reflect.Bool:
|
||||
if v.Bool() {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Float64 converts to float64 or 0.0 if invalid.
|
||||
func (n SqlNull[T]) Float64() float64 {
|
||||
if !n.Valid {
|
||||
return 0.0
|
||||
}
|
||||
v := reflect.ValueOf(any(n.Val))
|
||||
switch v.Kind() {
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return v.Float()
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return float64(v.Int())
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
return float64(v.Uint())
|
||||
case reflect.String:
|
||||
f, _ := strconv.ParseFloat(v.String(), 64)
|
||||
return f
|
||||
}
|
||||
return 0.0
|
||||
}
|
||||
|
||||
// Bool converts to bool or false if invalid.
|
||||
func (n SqlNull[T]) Bool() bool {
|
||||
if !n.Valid {
|
||||
return false
|
||||
}
|
||||
v := reflect.ValueOf(any(n.Val))
|
||||
if v.Kind() == reflect.Bool {
|
||||
return v.Bool()
|
||||
}
|
||||
s := strings.ToLower(strings.TrimSpace(fmt.Sprint(n.Val)))
|
||||
return s == "true" || s == "t" || s == "1" || s == "yes" || s == "on"
|
||||
}
|
||||
|
||||
// Time converts to time.Time or zero if invalid.
|
||||
func (n SqlNull[T]) Time() time.Time {
|
||||
if !n.Valid {
|
||||
return time.Time{}
|
||||
}
|
||||
if t, ok := any(n.Val).(time.Time); ok {
|
||||
return t
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// UUID converts to uuid.UUID or Nil if invalid.
|
||||
func (n SqlNull[T]) UUID() uuid.UUID {
|
||||
if !n.Valid {
|
||||
return uuid.Nil
|
||||
}
|
||||
if u, ok := any(n.Val).(uuid.UUID); ok {
|
||||
return u
|
||||
}
|
||||
return uuid.Nil
|
||||
}
|
||||
|
||||
// Type aliases for common types.
|
||||
type (
|
||||
SqlInt16 = SqlNull[int16]
|
||||
SqlInt32 = SqlNull[int32]
|
||||
SqlInt64 = SqlNull[int64]
|
||||
SqlFloat32 = SqlNull[float32]
|
||||
SqlFloat64 = SqlNull[float64]
|
||||
SqlBool = SqlNull[bool]
|
||||
SqlString = SqlNull[string]
|
||||
SqlByteArray = SqlNull[[]byte]
|
||||
SqlUUID = SqlNull[uuid.UUID]
|
||||
)
|
||||
|
||||
// SqlTimeStamp - Timestamp with custom formatting (YYYY-MM-DDTHH:MM:SS).
|
||||
type SqlTimeStamp struct{ SqlNull[time.Time] }
|
||||
|
||||
func (t SqlTimeStamp) MarshalJSON() ([]byte, error) {
|
||||
if !t.Valid || t.Val.IsZero() || t.Val.Before(time.Date(0002, 1, 1, 0, 0, 0, 0, time.UTC)) {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
return fmt.Appendf(nil, `"%s"`, t.Val.Format("2006-01-02T15:04:05")), nil
|
||||
}
|
||||
|
||||
func (t *SqlTimeStamp) UnmarshalJSON(b []byte) error {
|
||||
if err := t.SqlNull.UnmarshalJSON(b); err != nil {
|
||||
return err
|
||||
}
|
||||
if t.Valid && (t.Val.IsZero() || t.Val.Format("2006-01-02T15:04:05") == "0001-01-01T00:00:00") {
|
||||
t.Valid = false
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t SqlTimeStamp) Value() (driver.Value, error) {
|
||||
if !t.Valid || t.Val.IsZero() || t.Val.Before(time.Date(0002, 1, 1, 0, 0, 0, 0, time.UTC)) {
|
||||
return nil, nil
|
||||
}
|
||||
return t.Val.Format("2006-01-02T15:04:05"), nil
|
||||
}
|
||||
|
||||
func (t SqlTimeStamp) MarshalYAML() (any, error) {
|
||||
if !t.Valid || t.Val.IsZero() || t.Val.Before(time.Date(0002, 1, 1, 0, 0, 0, 0, time.UTC)) {
|
||||
return nil, nil
|
||||
}
|
||||
return t.Val.Format("2006-01-02T15:04:05"), nil
|
||||
}
|
||||
|
||||
func (t *SqlTimeStamp) UnmarshalYAML(value *yaml.Node) error {
|
||||
if err := t.SqlNull.UnmarshalYAML(value); err != nil {
|
||||
return err
|
||||
}
|
||||
if t.Valid && (t.Val.IsZero() || t.Val.Format("2006-01-02T15:04:05") == "0001-01-01T00:00:00") {
|
||||
t.Valid = false
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t SqlTimeStamp) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
||||
if !t.Valid || t.Val.IsZero() || t.Val.Before(time.Date(0002, 1, 1, 0, 0, 0, 0, time.UTC)) {
|
||||
return e.EncodeElement("", start)
|
||||
}
|
||||
return e.EncodeElement(t.Val.Format("2006-01-02T15:04:05"), start)
|
||||
}
|
||||
|
||||
func (t *SqlTimeStamp) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||
var s string
|
||||
if err := d.DecodeElement(&s, &start); err != nil {
|
||||
return err
|
||||
}
|
||||
if s == "" {
|
||||
t.Valid = false
|
||||
t.Val = time.Time{}
|
||||
return nil
|
||||
}
|
||||
tm, err := tryParseDT(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.Val = tm
|
||||
t.Valid = !tm.IsZero() && tm.Format("2006-01-02T15:04:05") != "0001-01-01T00:00:00"
|
||||
return nil
|
||||
}
|
||||
|
||||
func SqlTimeStampNow() SqlTimeStamp {
|
||||
return SqlTimeStamp{SqlNull: SqlNull[time.Time]{Val: time.Now(), Valid: true}}
|
||||
}
|
||||
|
||||
// SqlDate - Date only (YYYY-MM-DD).
|
||||
type SqlDate struct{ SqlNull[time.Time] }
|
||||
|
||||
func (d SqlDate) MarshalJSON() ([]byte, error) {
|
||||
if !d.Valid || d.Val.IsZero() {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
s := d.Val.Format("2006-01-02")
|
||||
if strings.HasPrefix(s, "0001-01-01") {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
return fmt.Appendf(nil, `"%s"`, s), nil
|
||||
}
|
||||
|
||||
func (d *SqlDate) UnmarshalJSON(b []byte) error {
|
||||
if err := d.SqlNull.UnmarshalJSON(b); err != nil {
|
||||
return err
|
||||
}
|
||||
if d.Valid && d.Val.Format("2006-01-02") <= "0001-01-01" {
|
||||
d.Valid = false
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d SqlDate) Value() (driver.Value, error) {
|
||||
if !d.Valid || d.Val.IsZero() {
|
||||
return nil, nil
|
||||
}
|
||||
s := d.Val.Format("2006-01-02")
|
||||
if s <= "0001-01-01" {
|
||||
return nil, nil
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (d SqlDate) String() string {
|
||||
if !d.Valid {
|
||||
return ""
|
||||
}
|
||||
s := d.Val.Format("2006-01-02")
|
||||
if strings.HasPrefix(s, "0001-01-01") || strings.HasPrefix(s, "1800-12-31") {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (d SqlDate) MarshalYAML() (any, error) {
|
||||
if !d.Valid || d.Val.IsZero() {
|
||||
return nil, nil
|
||||
}
|
||||
s := d.Val.Format("2006-01-02")
|
||||
if strings.HasPrefix(s, "0001-01-01") {
|
||||
return nil, nil
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (d *SqlDate) UnmarshalYAML(value *yaml.Node) error {
|
||||
if err := d.SqlNull.UnmarshalYAML(value); err != nil {
|
||||
return err
|
||||
}
|
||||
if d.Valid && d.Val.Format("2006-01-02") <= "0001-01-01" {
|
||||
d.Valid = false
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d SqlDate) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
||||
if !d.Valid || d.Val.IsZero() {
|
||||
return e.EncodeElement("", start)
|
||||
}
|
||||
s := d.Val.Format("2006-01-02")
|
||||
if strings.HasPrefix(s, "0001-01-01") {
|
||||
return e.EncodeElement("", start)
|
||||
}
|
||||
return e.EncodeElement(s, start)
|
||||
}
|
||||
|
||||
func (d *SqlDate) UnmarshalXML(dec *xml.Decoder, start xml.StartElement) error {
|
||||
var s string
|
||||
if err := dec.DecodeElement(&s, &start); err != nil {
|
||||
return err
|
||||
}
|
||||
if s == "" {
|
||||
d.Valid = false
|
||||
d.Val = time.Time{}
|
||||
return nil
|
||||
}
|
||||
tm, err := tryParseDT(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.Val = tm
|
||||
d.Valid = !tm.IsZero() && tm.Format("2006-01-02") > "0001-01-01"
|
||||
return nil
|
||||
}
|
||||
|
||||
func SqlDateNow() SqlDate {
|
||||
return SqlDate{SqlNull: SqlNull[time.Time]{Val: time.Now(), Valid: true}}
|
||||
}
|
||||
|
||||
// SqlTime - Time only (HH:MM:SS).
|
||||
type SqlTime struct{ SqlNull[time.Time] }
|
||||
|
||||
func (t SqlTime) MarshalJSON() ([]byte, error) {
|
||||
if !t.Valid || t.Val.IsZero() {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
s := t.Val.Format("15:04:05")
|
||||
if s == "00:00:00" {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
return fmt.Appendf(nil, `"%s"`, s), nil
|
||||
}
|
||||
|
||||
func (t *SqlTime) UnmarshalJSON(b []byte) error {
|
||||
if err := t.SqlNull.UnmarshalJSON(b); err != nil {
|
||||
return err
|
||||
}
|
||||
if t.Valid && t.Val.Format("15:04:05") == "00:00:00" {
|
||||
t.Valid = false
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t SqlTime) Value() (driver.Value, error) {
|
||||
if !t.Valid || t.Val.IsZero() {
|
||||
return nil, nil
|
||||
}
|
||||
return t.Val.Format("15:04:05"), nil
|
||||
}
|
||||
|
||||
func (t SqlTime) String() string {
|
||||
if !t.Valid {
|
||||
return ""
|
||||
}
|
||||
return t.Val.Format("15:04:05")
|
||||
}
|
||||
|
||||
func (t SqlTime) MarshalYAML() (any, error) {
|
||||
if !t.Valid || t.Val.IsZero() {
|
||||
return nil, nil
|
||||
}
|
||||
s := t.Val.Format("15:04:05")
|
||||
if s == "00:00:00" {
|
||||
return nil, nil
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (t *SqlTime) UnmarshalYAML(value *yaml.Node) error {
|
||||
if err := t.SqlNull.UnmarshalYAML(value); err != nil {
|
||||
return err
|
||||
}
|
||||
if t.Valid && t.Val.Format("15:04:05") == "00:00:00" {
|
||||
t.Valid = false
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t SqlTime) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
||||
if !t.Valid || t.Val.IsZero() {
|
||||
return e.EncodeElement("", start)
|
||||
}
|
||||
s := t.Val.Format("15:04:05")
|
||||
if s == "00:00:00" {
|
||||
return e.EncodeElement("", start)
|
||||
}
|
||||
return e.EncodeElement(s, start)
|
||||
}
|
||||
|
||||
func (t *SqlTime) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||
var s string
|
||||
if err := d.DecodeElement(&s, &start); err != nil {
|
||||
return err
|
||||
}
|
||||
if s == "" {
|
||||
t.Valid = false
|
||||
t.Val = time.Time{}
|
||||
return nil
|
||||
}
|
||||
tm, err := tryParseDT(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.Val = tm
|
||||
t.Valid = !tm.IsZero() && tm.Format("15:04:05") != "00:00:00"
|
||||
return nil
|
||||
}
|
||||
|
||||
func SqlTimeNow() SqlTime {
|
||||
return SqlTime{SqlNull: SqlNull[time.Time]{Val: time.Now(), Valid: true}}
|
||||
}
|
||||
|
||||
// SqlJSONB - Nullable JSONB as []byte.
|
||||
type SqlJSONB []byte
|
||||
|
||||
// SqlJSON - Nullable JSON as []byte. PostgreSQL's json and jsonb types share
|
||||
// the same textual representation and Go marshalling behavior, differing only
|
||||
// in server-side storage, so SqlJSON is an alias of SqlJSONB.
|
||||
type SqlJSON = SqlJSONB
|
||||
|
||||
// Scan implements sql.Scanner.
|
||||
func (n *SqlJSONB) Scan(value any) error {
|
||||
if value == nil {
|
||||
*n = nil
|
||||
return nil
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
*n = []byte(v)
|
||||
case []byte:
|
||||
*n = v
|
||||
default:
|
||||
dat, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal value to JSON: %v", err)
|
||||
}
|
||||
*n = dat
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Value implements driver.Valuer.
|
||||
func (n SqlJSONB) Value() (driver.Value, error) {
|
||||
if len(n) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var js any
|
||||
if err := json.Unmarshal(n, &js); err != nil {
|
||||
return nil, fmt.Errorf("invalid JSON: %v", err)
|
||||
}
|
||||
return string(n), nil
|
||||
}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
func (n SqlJSONB) MarshalJSON() ([]byte, error) {
|
||||
if len(n) == 0 {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
var obj any
|
||||
if err := json.Unmarshal(n, &obj); err != nil {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler.
|
||||
func (n *SqlJSONB) UnmarshalJSON(b []byte) error {
|
||||
s := strings.TrimSpace(string(b))
|
||||
if s == "null" || s == "" || (!strings.HasPrefix(s, "{") && !strings.HasPrefix(s, "[")) {
|
||||
*n = nil
|
||||
return nil
|
||||
}
|
||||
*n = b
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalYAML implements yaml.Marshaler. The underlying JSON is decoded into
|
||||
// a generic value first so it renders as native YAML mappings/sequences
|
||||
// rather than an embedded JSON string.
|
||||
func (n SqlJSONB) MarshalYAML() (any, error) {
|
||||
if len(n) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var v any
|
||||
if err := json.Unmarshal(n, &v); err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// UnmarshalYAML implements yaml.Unmarshaler.
|
||||
func (n *SqlJSONB) UnmarshalYAML(value *yaml.Node) error {
|
||||
if value == nil || value.Tag == "!!null" {
|
||||
*n = nil
|
||||
return nil
|
||||
}
|
||||
var v any
|
||||
if err := value.Decode(&v); err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*n = b
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalXML implements xml.Marshaler. JSON has no clean structural mapping
|
||||
// to XML, so the raw JSON text is emitted as the element's text content.
|
||||
func (n SqlJSONB) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
||||
if len(n) == 0 {
|
||||
return e.EncodeElement("", start)
|
||||
}
|
||||
var obj any
|
||||
if err := json.Unmarshal(n, &obj); err != nil {
|
||||
return e.EncodeElement("", start)
|
||||
}
|
||||
return e.EncodeElement(string(n), start)
|
||||
}
|
||||
|
||||
// UnmarshalXML implements xml.Unmarshaler, reading back the raw JSON text
|
||||
// written by MarshalXML.
|
||||
func (n *SqlJSONB) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||
var s string
|
||||
if err := d.DecodeElement(&s, &start); err != nil {
|
||||
return err
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
*n = nil
|
||||
return nil
|
||||
}
|
||||
*n = []byte(s)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n SqlJSONB) AsMap() (map[string]any, error) {
|
||||
if len(n) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
js := make(map[string]any)
|
||||
if err := json.Unmarshal(n, &js); err != nil {
|
||||
return nil, fmt.Errorf("invalid JSON: %v", err)
|
||||
}
|
||||
return js, nil
|
||||
}
|
||||
|
||||
func (n SqlJSONB) AsSlice() ([]any, error) {
|
||||
if len(n) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
js := make([]any, 0)
|
||||
if err := json.Unmarshal(n, &js); err != nil {
|
||||
return nil, fmt.Errorf("invalid JSON: %v", err)
|
||||
}
|
||||
return js, nil
|
||||
}
|
||||
|
||||
// TryIfInt64 tries to parse any value to int64 with default.
|
||||
func TryIfInt64(v any, def int64) int64 {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
i, err := strconv.ParseInt(val, 10, 64)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return i
|
||||
case int:
|
||||
return int64(val)
|
||||
case int8:
|
||||
return int64(val)
|
||||
case int16:
|
||||
return int64(val)
|
||||
case int32:
|
||||
return int64(val)
|
||||
case int64:
|
||||
return val
|
||||
case uint:
|
||||
return int64(val)
|
||||
case uint8:
|
||||
return int64(val)
|
||||
case uint16:
|
||||
return int64(val)
|
||||
case uint32:
|
||||
return int64(val)
|
||||
case uint64:
|
||||
return int64(val)
|
||||
case float32:
|
||||
return int64(val)
|
||||
case float64:
|
||||
return int64(val)
|
||||
case []byte:
|
||||
i, err := strconv.ParseInt(string(val), 10, 64)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return i
|
||||
default:
|
||||
return def
|
||||
}
|
||||
}
|
||||
|
||||
// Constructor helpers - clean and fast value creation
|
||||
func Null[T any](v T, valid bool) SqlNull[T] {
|
||||
return SqlNull[T]{Val: v, Valid: valid}
|
||||
}
|
||||
|
||||
func NewSql[T any](value any) SqlNull[T] {
|
||||
n := SqlNull[T]{}
|
||||
|
||||
if value == nil {
|
||||
return n
|
||||
}
|
||||
|
||||
// Fast path: exact match
|
||||
if v, ok := value.(T); ok {
|
||||
n.Val = v
|
||||
n.Valid = true
|
||||
return n
|
||||
}
|
||||
|
||||
// Try from another SqlNull
|
||||
if sn, ok := value.(SqlNull[T]); ok {
|
||||
return sn
|
||||
}
|
||||
|
||||
// Convert via string
|
||||
_ = n.FromString(fmt.Sprintf("%v", value))
|
||||
return n
|
||||
}
|
||||
|
||||
func NewSqlInt16(v int16) SqlInt16 {
|
||||
return SqlInt16{Val: v, Valid: true}
|
||||
}
|
||||
|
||||
func NewSqlInt32(v int32) SqlInt32 {
|
||||
return SqlInt32{Val: v, Valid: true}
|
||||
}
|
||||
|
||||
func NewSqlInt64(v int64) SqlInt64 {
|
||||
return SqlInt64{Val: v, Valid: true}
|
||||
}
|
||||
|
||||
func NewSqlFloat32(v float32) SqlFloat32 {
|
||||
return SqlFloat32{Val: v, Valid: true}
|
||||
}
|
||||
|
||||
func NewSqlFloat64(v float64) SqlFloat64 {
|
||||
return SqlFloat64{Val: v, Valid: true}
|
||||
}
|
||||
|
||||
func NewSqlBool(v bool) SqlBool {
|
||||
return SqlBool{Val: v, Valid: true}
|
||||
}
|
||||
|
||||
func NewSqlString(v string) SqlString {
|
||||
return SqlString{Val: v, Valid: true}
|
||||
}
|
||||
|
||||
func NewSqlByteArray(v []byte) SqlByteArray {
|
||||
return SqlByteArray{Val: v, Valid: true}
|
||||
}
|
||||
|
||||
func NewSqlUUID(v uuid.UUID) SqlUUID {
|
||||
return SqlUUID{Val: v, Valid: true}
|
||||
}
|
||||
|
||||
func NewSqlTimeStamp(v time.Time) SqlTimeStamp {
|
||||
return SqlTimeStamp{SqlNull: SqlNull[time.Time]{Val: v, Valid: true}}
|
||||
}
|
||||
|
||||
func NewSqlDate(v time.Time) SqlDate {
|
||||
return SqlDate{SqlNull: SqlNull[time.Time]{Val: v, Valid: true}}
|
||||
}
|
||||
|
||||
func NewSqlTime(v time.Time) SqlTime {
|
||||
return SqlTime{SqlNull: SqlNull[time.Time]{Val: v, Valid: true}}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package sqltypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSqlNull_FromString_UnsignedInt guards against a regression where
|
||||
// FromString called reflect.Value.SetInt on unsigned-kind fields, which
|
||||
// panics since SetInt only accepts Int/Int8/.../Int64 kinds.
|
||||
func TestSqlNull_FromString_UnsignedInt(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected uint32
|
||||
}{
|
||||
{"simple", "123", 123},
|
||||
{"zero", "0", 0},
|
||||
{"large", "4000000000", 4000000000},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var n SqlNull[uint32]
|
||||
if err := n.FromString(tt.input); err != nil {
|
||||
t.Fatalf("FromString failed: %v", err)
|
||||
}
|
||||
if !n.Valid {
|
||||
t.Fatalf("expected valid=true")
|
||||
}
|
||||
if n.Val != tt.expected {
|
||||
t.Errorf("expected %d, got %d", tt.expected, n.Val)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSqlNull_FromString_Int64Precision guards against a regression where
|
||||
// FromString unconditionally re-parsed the string as float64 after a
|
||||
// successful ParseInt, corrupting large int64 values due to float rounding
|
||||
// (e.g. math.MaxInt64 became negative).
|
||||
func TestSqlNull_FromString_Int64Precision(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected int64
|
||||
}{
|
||||
{"max int64", "9223372036854775807", 9223372036854775807},
|
||||
{"min int64", "-9223372036854775808", -9223372036854775808},
|
||||
{"large safe value", "1234567890123456", 1234567890123456},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var n SqlNull[int64]
|
||||
if err := n.FromString(tt.input); err != nil {
|
||||
t.Fatalf("FromString failed: %v", err)
|
||||
}
|
||||
if !n.Valid {
|
||||
t.Fatalf("expected valid=true")
|
||||
}
|
||||
if n.Val != tt.expected {
|
||||
t.Errorf("expected %d, got %d", tt.expected, n.Val)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSqlNull_FromString_FloatFallback verifies that non-integral strings
|
||||
// still parse via the float fallback path for integer-kind fields.
|
||||
func TestSqlNull_FromString_FloatFallback(t *testing.T) {
|
||||
var n SqlNull[int32]
|
||||
if err := n.FromString("42.9"); err != nil {
|
||||
t.Fatalf("FromString failed: %v", err)
|
||||
}
|
||||
if !n.Valid || n.Val != 42 {
|
||||
t.Errorf("expected valid int32=42, got valid=%v val=%d", n.Valid, n.Val)
|
||||
}
|
||||
|
||||
var u SqlNull[uint32]
|
||||
if err := u.FromString("42.9"); err != nil {
|
||||
t.Fatalf("FromString failed: %v", err)
|
||||
}
|
||||
if !u.Valid || u.Val != 42 {
|
||||
t.Errorf("expected valid uint32=42, got valid=%v val=%d", u.Valid, u.Val)
|
||||
}
|
||||
|
||||
var neg SqlNull[uint32]
|
||||
if err := neg.FromString("-1.5"); err != nil {
|
||||
t.Fatalf("FromString failed: %v", err)
|
||||
}
|
||||
if neg.Valid {
|
||||
t.Errorf("expected invalid for negative value into unsigned type, got %v", neg.Val)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSqlNull_UnsignedTypes_ScanAndJSON exercises Scan and JSON round-trip
|
||||
// for every unsigned alias to make sure none of them panic.
|
||||
func TestSqlNull_UnsignedTypes_ScanAndJSON(t *testing.T) {
|
||||
t.Run("uint8 scan from string", func(t *testing.T) {
|
||||
var n SqlNull[uint8]
|
||||
if err := n.Scan("200"); err != nil {
|
||||
t.Fatalf("Scan failed: %v", err)
|
||||
}
|
||||
if !n.Valid || n.Val != 200 {
|
||||
t.Errorf("expected valid uint8=200, got valid=%v val=%d", n.Valid, n.Val)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("uint64 json round-trip", func(t *testing.T) {
|
||||
n := Null(uint64(18446744073709551615), true)
|
||||
data, err := json.Marshal(n)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var n2 SqlNull[uint64]
|
||||
if err := json.Unmarshal(data, &n2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if n2.Val != n.Val {
|
||||
t.Errorf("expected %d, got %d", n.Val, n2.Val)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("uint scan from numeric string fallback", func(t *testing.T) {
|
||||
var n SqlNull[uint]
|
||||
if err := n.Scan([]byte("77")); err != nil {
|
||||
t.Fatalf("Scan failed: %v", err)
|
||||
}
|
||||
if !n.Valid || n.Val != 77 {
|
||||
t.Errorf("expected valid uint=77, got valid=%v val=%d", n.Valid, n.Val)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,958 @@
|
||||
package sqltypes
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// TestNewSqlInt16 tests NewSqlInt16 type
|
||||
func TestNewSqlInt16(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input interface{}
|
||||
expected SqlInt16
|
||||
}{
|
||||
{"int", 42, Null(int16(42), true)},
|
||||
{"int32", int32(100), NewSqlInt16(100)},
|
||||
{"int64", int64(200), NewSqlInt16(200)},
|
||||
{"string", "123", NewSqlInt16(123)},
|
||||
{"nil", nil, Null(int16(0), false)},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var n SqlInt16
|
||||
if err := n.Scan(tt.input); err != nil {
|
||||
t.Fatalf("Scan failed: %v", err)
|
||||
}
|
||||
if n != tt.expected {
|
||||
t.Errorf("expected %v, got %v", tt.expected, n)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSqlInt16_Value(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input SqlInt16
|
||||
expected driver.Value
|
||||
}{
|
||||
{"zero", Null(int16(0), false), nil},
|
||||
{"positive", NewSqlInt16(42), int16(42)},
|
||||
{"negative", NewSqlInt16(-10), int16(-10)},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
val, err := tt.input.Value()
|
||||
if err != nil {
|
||||
t.Fatalf("Value failed: %v", err)
|
||||
}
|
||||
if val != tt.expected {
|
||||
t.Errorf("expected %v, got %v", tt.expected, val)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSqlInt16_JSON(t *testing.T) {
|
||||
n := NewSqlInt16(42)
|
||||
|
||||
// Marshal
|
||||
data, err := json.Marshal(n)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
expected := "42"
|
||||
if string(data) != expected {
|
||||
t.Errorf("expected %s, got %s", expected, string(data))
|
||||
}
|
||||
|
||||
// Unmarshal
|
||||
var n2 SqlInt16
|
||||
if err := json.Unmarshal([]byte("123"), &n2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if n2.Int64() != 123 {
|
||||
t.Errorf("expected 123, got %d", n2.Int64())
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewSqlInt64 tests NewSqlInt64 type
|
||||
func TestNewSqlInt64(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input interface{}
|
||||
expected SqlInt64
|
||||
}{
|
||||
{"int", 42, NewSqlInt64(42)},
|
||||
{"int32", int32(100), NewSqlInt64(100)},
|
||||
{"int64", int64(9223372036854775807), NewSqlInt64(9223372036854775807)},
|
||||
{"uint32", uint32(100), NewSqlInt64(100)},
|
||||
{"uint64", uint64(200), NewSqlInt64(200)},
|
||||
{"nil", nil, SqlInt64{}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var n SqlInt64
|
||||
if err := n.Scan(tt.input); err != nil {
|
||||
t.Fatalf("Scan failed: %v", err)
|
||||
}
|
||||
if n != tt.expected {
|
||||
t.Errorf("expected %v, got %v", tt.expected, n)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSqlFloat64 tests SqlFloat64 type
|
||||
func TestSqlFloat64(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input interface{}
|
||||
expected float64
|
||||
valid bool
|
||||
}{
|
||||
{"float64", float64(3.14), 3.14, true},
|
||||
{"float32", float32(2.5), 2.5, true},
|
||||
{"int", 42, 42.0, true},
|
||||
{"int64", int64(100), 100.0, true},
|
||||
{"nil", nil, 0, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var n SqlFloat64
|
||||
if err := n.Scan(tt.input); err != nil {
|
||||
t.Fatalf("Scan failed: %v", err)
|
||||
}
|
||||
if n.Valid != tt.valid {
|
||||
t.Errorf("expected valid=%v, got valid=%v", tt.valid, n.Valid)
|
||||
}
|
||||
if tt.valid && n.Float64() != tt.expected {
|
||||
t.Errorf("expected %v, got %v", tt.expected, n.Float64())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSqlTimeStamp tests SqlTimeStamp type
|
||||
func TestSqlTimeStamp(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input interface{}
|
||||
}{
|
||||
{"time.Time", now},
|
||||
{"string RFC3339", now.Format(time.RFC3339)},
|
||||
{"string date", "2024-01-15"},
|
||||
{"string datetime", "2024-01-15T10:30:00"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var ts SqlTimeStamp
|
||||
if err := ts.Scan(tt.input); err != nil {
|
||||
t.Fatalf("Scan failed: %v", err)
|
||||
}
|
||||
if ts.Time().IsZero() {
|
||||
t.Error("expected non-zero time")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqlTimeStamp_JSON(t *testing.T) {
|
||||
now := time.Date(2024, 1, 15, 10, 30, 45, 0, time.UTC)
|
||||
ts := NewSqlTimeStamp(now)
|
||||
|
||||
// Marshal
|
||||
data, err := json.Marshal(ts)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
expected := `"2024-01-15T10:30:45"`
|
||||
if string(data) != expected {
|
||||
t.Errorf("expected %s, got %s", expected, string(data))
|
||||
}
|
||||
|
||||
// Unmarshal
|
||||
var ts2 SqlTimeStamp
|
||||
if err := json.Unmarshal([]byte(`"2024-01-15T10:30:45"`), &ts2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if ts2.Time().Year() != 2024 {
|
||||
t.Errorf("expected year 2024, got %d", ts2.Time().Year())
|
||||
}
|
||||
|
||||
// Test null
|
||||
var ts3 SqlTimeStamp
|
||||
if err := json.Unmarshal([]byte("null"), &ts3); err != nil {
|
||||
t.Fatalf("Unmarshal null failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSqlDate tests SqlDate type
|
||||
func TestSqlDate(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input interface{}
|
||||
}{
|
||||
{"time.Time", now},
|
||||
{"string date", "2024-01-15"},
|
||||
{"string UK format", "15/01/2024"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var d SqlDate
|
||||
if err := d.Scan(tt.input); err != nil {
|
||||
t.Fatalf("Scan failed: %v", err)
|
||||
}
|
||||
if d.String() == "0" {
|
||||
t.Error("expected non-zero date")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqlDate_JSON(t *testing.T) {
|
||||
date := NewSqlDate(time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC))
|
||||
|
||||
// Marshal
|
||||
data, err := json.Marshal(date)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
expected := `"2024-01-15"`
|
||||
if string(data) != expected {
|
||||
t.Errorf("expected %s, got %s", expected, string(data))
|
||||
}
|
||||
|
||||
// Unmarshal
|
||||
var d2 SqlDate
|
||||
if err := json.Unmarshal([]byte(`"2024-01-15"`), &d2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSqlTime tests SqlTime type
|
||||
func TestSqlTime(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input interface{}
|
||||
expected string
|
||||
}{
|
||||
{"time.Time", now, now.Format("15:04:05")},
|
||||
{"string time", "10:30:45", "10:30:45"},
|
||||
{"string short time", "10:30", "10:30:00"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var tm SqlTime
|
||||
if err := tm.Scan(tt.input); err != nil {
|
||||
t.Fatalf("Scan failed: %v", err)
|
||||
}
|
||||
if tm.String() != tt.expected {
|
||||
t.Errorf("expected %s, got %s", tt.expected, tm.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSqlJSONB tests SqlJSONB type
|
||||
func TestSqlJSONB_Scan(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input interface{}
|
||||
expected string
|
||||
}{
|
||||
{"string JSON object", `{"key":"value"}`, `{"key":"value"}`},
|
||||
{"string JSON array", `[1,2,3]`, `[1,2,3]`},
|
||||
{"bytes", []byte(`{"test":true}`), `{"test":true}`},
|
||||
{"nil", nil, ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var j SqlJSONB
|
||||
if err := j.Scan(tt.input); err != nil {
|
||||
t.Fatalf("Scan failed: %v", err)
|
||||
}
|
||||
if tt.expected == "" && j == nil {
|
||||
return // nil case
|
||||
}
|
||||
if string(j) != tt.expected {
|
||||
t.Errorf("expected %s, got %s", tt.expected, string(j))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqlJSONB_Value(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input SqlJSONB
|
||||
expected string
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid object", SqlJSONB(`{"key":"value"}`), `{"key":"value"}`, false},
|
||||
{"valid array", SqlJSONB(`[1,2,3]`), `[1,2,3]`, false},
|
||||
{"empty", SqlJSONB{}, "", false},
|
||||
{"nil", nil, "", false},
|
||||
{"invalid JSON", SqlJSONB(`{invalid`), "", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
val, err := tt.input.Value()
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Value failed: %v", err)
|
||||
}
|
||||
if tt.expected == "" && val == nil {
|
||||
return // nil case
|
||||
}
|
||||
if val.(string) != tt.expected {
|
||||
t.Errorf("expected %s, got %s", tt.expected, val)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqlJSONB_JSON(t *testing.T) {
|
||||
// Marshal
|
||||
j := SqlJSONB(`{"name":"test","count":42}`)
|
||||
data, err := json.Marshal(j)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
t.Fatalf("Unmarshal result failed: %v", err)
|
||||
}
|
||||
if result["name"] != "test" {
|
||||
t.Errorf("expected name=test, got %v", result["name"])
|
||||
}
|
||||
|
||||
// Unmarshal
|
||||
var j2 SqlJSONB
|
||||
if err := json.Unmarshal([]byte(`{"key":"value"}`), &j2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if string(j2) != `{"key":"value"}` {
|
||||
t.Errorf("expected {\"key\":\"value\"}, got %s", string(j2))
|
||||
}
|
||||
|
||||
// Test null
|
||||
var j3 SqlJSONB
|
||||
if err := json.Unmarshal([]byte("null"), &j3); err != nil {
|
||||
t.Fatalf("Unmarshal null failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqlJSONB_AsMap(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input SqlJSONB
|
||||
wantErr bool
|
||||
wantNil bool
|
||||
}{
|
||||
{"valid object", SqlJSONB(`{"name":"test","age":30}`), false, false},
|
||||
{"empty", SqlJSONB{}, false, true},
|
||||
{"nil", nil, false, true},
|
||||
{"invalid JSON", SqlJSONB(`{invalid`), true, false},
|
||||
{"array not object", SqlJSONB(`[1,2,3]`), true, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
m, err := tt.input.AsMap()
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("AsMap failed: %v", err)
|
||||
}
|
||||
if tt.wantNil {
|
||||
if m != nil {
|
||||
t.Errorf("expected nil, got %v", m)
|
||||
}
|
||||
return
|
||||
}
|
||||
if m == nil {
|
||||
t.Error("expected non-nil map")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqlJSONB_AsSlice(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input SqlJSONB
|
||||
wantErr bool
|
||||
wantNil bool
|
||||
}{
|
||||
{"valid array", SqlJSONB(`[1,2,3]`), false, false},
|
||||
{"empty", SqlJSONB{}, false, true},
|
||||
{"nil", nil, false, true},
|
||||
{"invalid JSON", SqlJSONB(`[invalid`), true, false},
|
||||
{"object not array", SqlJSONB(`{"key":"value"}`), true, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s, err := tt.input.AsSlice()
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("AsSlice failed: %v", err)
|
||||
}
|
||||
if tt.wantNil {
|
||||
if s != nil {
|
||||
t.Errorf("expected nil, got %v", s)
|
||||
}
|
||||
return
|
||||
}
|
||||
if s == nil {
|
||||
t.Error("expected non-nil slice")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSqlUUID tests SqlUUID type
|
||||
func TestSqlUUID_Scan(t *testing.T) {
|
||||
testUUID := uuid.New()
|
||||
testUUIDStr := testUUID.String()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input interface{}
|
||||
expected string
|
||||
valid bool
|
||||
}{
|
||||
{"string UUID", testUUIDStr, testUUIDStr, true},
|
||||
{"bytes UUID", []byte(testUUIDStr), testUUIDStr, true},
|
||||
{"nil", nil, "", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var u SqlUUID
|
||||
if err := u.Scan(tt.input); err != nil {
|
||||
t.Fatalf("Scan failed: %v", err)
|
||||
}
|
||||
if u.Valid != tt.valid {
|
||||
t.Errorf("expected valid=%v, got valid=%v", tt.valid, u.Valid)
|
||||
}
|
||||
if tt.valid && u.String() != tt.expected {
|
||||
t.Errorf("expected %s, got %s", tt.expected, u.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqlUUID_Value(t *testing.T) {
|
||||
testUUID := uuid.New()
|
||||
u := NewSqlUUID(testUUID)
|
||||
|
||||
val, err := u.Value()
|
||||
if err != nil {
|
||||
t.Fatalf("Value failed: %v", err)
|
||||
}
|
||||
// Value() should return a string for driver compatibility
|
||||
if val != testUUID.String() {
|
||||
t.Errorf("expected %s, got %s", testUUID.String(), val)
|
||||
}
|
||||
|
||||
// Test invalid UUID
|
||||
u2 := SqlUUID{Valid: false}
|
||||
val2, err := u2.Value()
|
||||
if err != nil {
|
||||
t.Fatalf("Value failed: %v", err)
|
||||
}
|
||||
if val2 != nil {
|
||||
t.Errorf("expected nil, got %v", val2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqlUUID_JSON(t *testing.T) {
|
||||
testUUID := uuid.New()
|
||||
u := NewSqlUUID(testUUID)
|
||||
|
||||
// Marshal
|
||||
data, err := json.Marshal(u)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
expected := `"` + testUUID.String() + `"`
|
||||
if string(data) != expected {
|
||||
t.Errorf("expected %s, got %s", expected, string(data))
|
||||
}
|
||||
|
||||
// Unmarshal
|
||||
var u2 SqlUUID
|
||||
if err := json.Unmarshal([]byte(`"`+testUUID.String()+`"`), &u2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if u2.String() != testUUID.String() {
|
||||
t.Errorf("expected %s, got %s", testUUID.String(), u2.String())
|
||||
}
|
||||
|
||||
// Test null
|
||||
var u3 SqlUUID
|
||||
if err := json.Unmarshal([]byte("null"), &u3); err != nil {
|
||||
t.Fatalf("Unmarshal null failed: %v", err)
|
||||
}
|
||||
if u3.Valid {
|
||||
t.Error("expected invalid UUID")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTryIfInt64 tests the TryIfInt64 helper function
|
||||
func TestTryIfInt64(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input interface{}
|
||||
def int64
|
||||
expected int64
|
||||
}{
|
||||
{"string valid", "123", 0, 123},
|
||||
{"string invalid", "abc", 99, 99},
|
||||
{"int", 42, 0, 42},
|
||||
{"int32", int32(100), 0, 100},
|
||||
{"int64", int64(200), 0, 200},
|
||||
{"uint32", uint32(50), 0, 50},
|
||||
{"uint64", uint64(75), 0, 75},
|
||||
{"float32", float32(3.14), 0, 3},
|
||||
{"float64", float64(2.71), 0, 2},
|
||||
{"bytes", []byte("456"), 0, 456},
|
||||
{"unknown type", struct{}{}, 999, 999},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := TryIfInt64(tt.input, tt.def)
|
||||
if result != tt.expected {
|
||||
t.Errorf("expected %d, got %d", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSqlString tests SqlString without base64 (plain text)
|
||||
func TestSqlString_Scan(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input interface{}
|
||||
expected string
|
||||
valid bool
|
||||
}{
|
||||
{
|
||||
name: "plain string",
|
||||
input: "hello world",
|
||||
expected: "hello world",
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "plain text",
|
||||
input: "plain text",
|
||||
expected: "plain text",
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "bytes as string",
|
||||
input: []byte("raw bytes"),
|
||||
expected: "raw bytes",
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "nil value",
|
||||
input: nil,
|
||||
expected: "",
|
||||
valid: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var s SqlString
|
||||
if err := s.Scan(tt.input); err != nil {
|
||||
t.Fatalf("Scan failed: %v", err)
|
||||
}
|
||||
if s.Valid != tt.valid {
|
||||
t.Errorf("expected valid=%v, got valid=%v", tt.valid, s.Valid)
|
||||
}
|
||||
if tt.valid && s.String() != tt.expected {
|
||||
t.Errorf("expected %q, got %q", tt.expected, s.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqlString_JSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
inputValue string
|
||||
expectedJSON string
|
||||
expectedDecode string
|
||||
}{
|
||||
{
|
||||
name: "simple string",
|
||||
inputValue: "hello world",
|
||||
expectedJSON: `"hello world"`, // plain text, not base64
|
||||
expectedDecode: "hello world",
|
||||
},
|
||||
{
|
||||
name: "special characters",
|
||||
inputValue: "test@#$%",
|
||||
expectedJSON: `"test@#$%"`, // plain text, not base64
|
||||
expectedDecode: "test@#$%",
|
||||
},
|
||||
{
|
||||
name: "unicode string",
|
||||
inputValue: "Hello 世界",
|
||||
expectedJSON: `"Hello 世界"`, // plain text, not base64
|
||||
expectedDecode: "Hello 世界",
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
inputValue: "",
|
||||
expectedJSON: `""`,
|
||||
expectedDecode: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Test MarshalJSON
|
||||
s := NewSqlString(tt.inputValue)
|
||||
data, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
if string(data) != tt.expectedJSON {
|
||||
t.Errorf("Marshal: expected %s, got %s", tt.expectedJSON, string(data))
|
||||
}
|
||||
|
||||
// Test UnmarshalJSON
|
||||
var s2 SqlString
|
||||
if err := json.Unmarshal(data, &s2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if !s2.Valid {
|
||||
t.Error("expected valid=true after unmarshal")
|
||||
}
|
||||
if s2.String() != tt.expectedDecode {
|
||||
t.Errorf("Unmarshal: expected %q, got %q", tt.expectedDecode, s2.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqlString_JSON_Null(t *testing.T) {
|
||||
// Test null handling
|
||||
var s SqlString
|
||||
if err := json.Unmarshal([]byte("null"), &s); err != nil {
|
||||
t.Fatalf("Unmarshal null failed: %v", err)
|
||||
}
|
||||
if s.Valid {
|
||||
t.Error("expected invalid after unmarshaling null")
|
||||
}
|
||||
|
||||
// Test marshal null
|
||||
data, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
if string(data) != "null" {
|
||||
t.Errorf("expected null, got %s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSqlByteArray_Base64 tests SqlByteArray with base64 encoding/decoding
|
||||
func TestSqlByteArray_Base64_Scan(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input interface{}
|
||||
expected []byte
|
||||
valid bool
|
||||
}{
|
||||
{
|
||||
name: "base64 encoded bytes from SQL",
|
||||
input: "aGVsbG8gd29ybGQ=", // "hello world" in base64
|
||||
expected: []byte("hello world"),
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "plain bytes fallback",
|
||||
input: "plain text",
|
||||
expected: []byte("plain text"),
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "bytes base64 encoded",
|
||||
input: []byte("SGVsbG8gR29waGVy"), // "Hello Gopher" in base64
|
||||
expected: []byte("Hello Gopher"),
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "bytes plain fallback",
|
||||
input: []byte("raw bytes"),
|
||||
expected: []byte("raw bytes"),
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "binary data",
|
||||
input: "AQIDBA==", // []byte{1, 2, 3, 4} in base64
|
||||
expected: []byte{1, 2, 3, 4},
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "nil value",
|
||||
input: nil,
|
||||
expected: nil,
|
||||
valid: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var b SqlByteArray
|
||||
if err := b.Scan(tt.input); err != nil {
|
||||
t.Fatalf("Scan failed: %v", err)
|
||||
}
|
||||
if b.Valid != tt.valid {
|
||||
t.Errorf("expected valid=%v, got valid=%v", tt.valid, b.Valid)
|
||||
}
|
||||
if tt.valid {
|
||||
if string(b.Val) != string(tt.expected) {
|
||||
t.Errorf("expected %q, got %q", tt.expected, b.Val)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqlByteArray_Base64_JSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
inputValue []byte
|
||||
expectedJSON string
|
||||
expectedDecode []byte
|
||||
}{
|
||||
{
|
||||
name: "text bytes",
|
||||
inputValue: []byte("hello world"),
|
||||
expectedJSON: `"aGVsbG8gd29ybGQ="`, // base64 encoded
|
||||
expectedDecode: []byte("hello world"),
|
||||
},
|
||||
{
|
||||
name: "binary data",
|
||||
inputValue: []byte{0x01, 0x02, 0x03, 0x04, 0xFF},
|
||||
expectedJSON: `"AQIDBP8="`, // base64 encoded
|
||||
expectedDecode: []byte{0x01, 0x02, 0x03, 0x04, 0xFF},
|
||||
},
|
||||
{
|
||||
name: "empty bytes",
|
||||
inputValue: []byte{},
|
||||
expectedJSON: `""`, // base64 of empty bytes
|
||||
expectedDecode: []byte{},
|
||||
},
|
||||
{
|
||||
name: "unicode bytes",
|
||||
inputValue: []byte("Hello 世界"),
|
||||
expectedJSON: `"SGVsbG8g5LiW55WM"`, // base64 encoded
|
||||
expectedDecode: []byte("Hello 世界"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Test MarshalJSON
|
||||
b := NewSqlByteArray(tt.inputValue)
|
||||
data, err := json.Marshal(b)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
if string(data) != tt.expectedJSON {
|
||||
t.Errorf("Marshal: expected %s, got %s", tt.expectedJSON, string(data))
|
||||
}
|
||||
|
||||
// Test UnmarshalJSON
|
||||
var b2 SqlByteArray
|
||||
if err := json.Unmarshal(data, &b2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if !b2.Valid {
|
||||
t.Error("expected valid=true after unmarshal")
|
||||
}
|
||||
if string(b2.Val) != string(tt.expectedDecode) {
|
||||
t.Errorf("Unmarshal: expected %v, got %v", tt.expectedDecode, b2.Val)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqlByteArray_Base64_JSON_Null(t *testing.T) {
|
||||
// Test null handling
|
||||
var b SqlByteArray
|
||||
if err := json.Unmarshal([]byte("null"), &b); err != nil {
|
||||
t.Fatalf("Unmarshal null failed: %v", err)
|
||||
}
|
||||
if b.Valid {
|
||||
t.Error("expected invalid after unmarshaling null")
|
||||
}
|
||||
|
||||
// Test marshal null
|
||||
data, err := json.Marshal(b)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
if string(data) != "null" {
|
||||
t.Errorf("expected null, got %s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqlByteArray_Value(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input SqlByteArray
|
||||
expected interface{}
|
||||
}{
|
||||
{
|
||||
name: "valid bytes",
|
||||
input: NewSqlByteArray([]byte("test data")),
|
||||
expected: []byte("test data"),
|
||||
},
|
||||
{
|
||||
name: "empty bytes",
|
||||
input: NewSqlByteArray([]byte{}),
|
||||
expected: []byte{},
|
||||
},
|
||||
{
|
||||
name: "invalid",
|
||||
input: SqlByteArray{Valid: false},
|
||||
expected: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
val, err := tt.input.Value()
|
||||
if err != nil {
|
||||
t.Fatalf("Value failed: %v", err)
|
||||
}
|
||||
if tt.expected == nil && val != nil {
|
||||
t.Errorf("expected nil, got %v", val)
|
||||
}
|
||||
if tt.expected != nil && val == nil {
|
||||
t.Errorf("expected %v, got nil", tt.expected)
|
||||
}
|
||||
if tt.expected != nil && val != nil {
|
||||
if string(val.([]byte)) != string(tt.expected.([]byte)) {
|
||||
t.Errorf("expected %v, got %v", tt.expected, val)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSqlString_RoundTrip tests complete round-trip: Go -> JSON -> Go -> SQL -> Go
|
||||
func TestSqlString_RoundTrip(t *testing.T) {
|
||||
original := "Test String with Special Chars: @#$%^&*()"
|
||||
|
||||
// Go -> JSON
|
||||
s1 := NewSqlString(original)
|
||||
jsonData, err := json.Marshal(s1)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
|
||||
// JSON -> Go
|
||||
var s2 SqlString
|
||||
if err := json.Unmarshal(jsonData, &s2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
|
||||
// Go -> SQL (Value)
|
||||
_, err = s2.Value()
|
||||
if err != nil {
|
||||
t.Fatalf("Value failed: %v", err)
|
||||
}
|
||||
|
||||
// SQL -> Go (Scan plain text)
|
||||
var s3 SqlString
|
||||
// Simulate SQL driver returning plain text value
|
||||
if err := s3.Scan(original); err != nil {
|
||||
t.Fatalf("Scan failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify round-trip
|
||||
if s3.String() != original {
|
||||
t.Errorf("Round-trip failed: expected %q, got %q", original, s3.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSqlByteArray_Base64_RoundTrip tests complete round-trip: Go -> JSON -> Go -> SQL -> Go
|
||||
func TestSqlByteArray_Base64_RoundTrip(t *testing.T) {
|
||||
original := []byte{0x48, 0x65, 0x6C, 0x6C, 0x6F, 0x20, 0xFF, 0xFE} // "Hello " + binary data
|
||||
|
||||
// Go -> JSON
|
||||
b1 := NewSqlByteArray(original)
|
||||
jsonData, err := json.Marshal(b1)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
|
||||
// JSON -> Go
|
||||
var b2 SqlByteArray
|
||||
if err := json.Unmarshal(jsonData, &b2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
|
||||
// Go -> SQL (Value)
|
||||
_, err = b2.Value()
|
||||
if err != nil {
|
||||
t.Fatalf("Value failed: %v", err)
|
||||
}
|
||||
|
||||
// SQL -> Go (Scan with base64)
|
||||
var b3 SqlByteArray
|
||||
// Simulate SQL driver returning base64 encoded value
|
||||
if err := b3.Scan("SGVsbG8g//4="); err != nil {
|
||||
t.Fatalf("Scan failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify round-trip
|
||||
if string(b3.Val) != string(original) {
|
||||
t.Errorf("Round-trip failed: expected %v, got %v", original, b3.Val)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,678 @@
|
||||
package sqltypes
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ── SqlNull: YAML ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestSqlNull_YAML_Int(t *testing.T) {
|
||||
n := NewSqlInt32(42)
|
||||
data, err := yaml.Marshal(n)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
if string(data) != "42\n" {
|
||||
t.Errorf("expected \"42\\n\", got %q", string(data))
|
||||
}
|
||||
|
||||
var n2 SqlInt32
|
||||
if err := yaml.Unmarshal(data, &n2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if n2.Int64() != 42 {
|
||||
t.Errorf("expected 42, got %d", n2.Int64())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqlNull_YAML_Null(t *testing.T) {
|
||||
var n SqlInt32
|
||||
data, err := yaml.Marshal(n)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
if string(data) != "null\n" {
|
||||
t.Errorf("expected \"null\\n\", got %q", string(data))
|
||||
}
|
||||
|
||||
var n2 SqlInt32
|
||||
if err := yaml.Unmarshal([]byte("null\n"), &n2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if n2.Valid {
|
||||
t.Error("expected invalid after unmarshaling null")
|
||||
}
|
||||
|
||||
// ~ is also a YAML null.
|
||||
var n3 SqlInt32
|
||||
if err := yaml.Unmarshal([]byte("~\n"), &n3); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if n3.Valid {
|
||||
t.Error("expected invalid after unmarshaling ~")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqlNull_YAML_String(t *testing.T) {
|
||||
s := NewSqlString("hello world")
|
||||
data, err := yaml.Marshal(s)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var s2 SqlString
|
||||
if err := yaml.Unmarshal(data, &s2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if s2.String() != "hello world" {
|
||||
t.Errorf("expected %q, got %q", "hello world", s2.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqlNull_YAML_UUID(t *testing.T) {
|
||||
id := uuid.New()
|
||||
u := NewSqlUUID(id)
|
||||
data, err := yaml.Marshal(u)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
if string(data) != id.String()+"\n" {
|
||||
t.Errorf("expected %q, got %q", id.String()+"\n", string(data))
|
||||
}
|
||||
var u2 SqlUUID
|
||||
if err := yaml.Unmarshal(data, &u2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if u2.UUID() != id {
|
||||
t.Errorf("expected %v, got %v", id, u2.UUID())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqlNull_YAML_ByteArray_Base64(t *testing.T) {
|
||||
orig := []byte{0x01, 0x02, 0xFF}
|
||||
b := NewSqlByteArray(orig)
|
||||
data, err := yaml.Marshal(b)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var b2 SqlByteArray
|
||||
if err := yaml.Unmarshal(data, &b2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if string(b2.Val) != string(orig) {
|
||||
t.Errorf("expected %v, got %v", orig, b2.Val)
|
||||
}
|
||||
}
|
||||
|
||||
// ── SqlNull: XML ─────────────────────────────────────────────────────────────
|
||||
|
||||
type xmlIntWrapper struct {
|
||||
XMLName xml.Name `xml:"root"`
|
||||
Value SqlInt32 `xml:"value"`
|
||||
}
|
||||
|
||||
func TestSqlNull_XML_Int(t *testing.T) {
|
||||
w := xmlIntWrapper{Value: NewSqlInt32(99)}
|
||||
data, err := xml.Marshal(w)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var w2 xmlIntWrapper
|
||||
if err := xml.Unmarshal(data, &w2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if w2.Value.Int64() != 99 {
|
||||
t.Errorf("expected 99, got %d", w2.Value.Int64())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqlNull_XML_Null(t *testing.T) {
|
||||
w := xmlIntWrapper{}
|
||||
data, err := xml.Marshal(w)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var w2 xmlIntWrapper
|
||||
if err := xml.Unmarshal(data, &w2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if w2.Value.Valid {
|
||||
t.Errorf("expected invalid, got %v", w2.Value)
|
||||
}
|
||||
}
|
||||
|
||||
type xmlUUIDWrapper struct {
|
||||
XMLName xml.Name `xml:"root"`
|
||||
ID SqlUUID `xml:"id"`
|
||||
}
|
||||
|
||||
func TestSqlNull_XML_UUID(t *testing.T) {
|
||||
id := uuid.New()
|
||||
w := xmlUUIDWrapper{ID: NewSqlUUID(id)}
|
||||
data, err := xml.Marshal(w)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var w2 xmlUUIDWrapper
|
||||
if err := xml.Unmarshal(data, &w2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if w2.ID.UUID() != id {
|
||||
t.Errorf("expected %v, got %v", id, w2.ID.UUID())
|
||||
}
|
||||
}
|
||||
|
||||
type xmlByteWrapper struct {
|
||||
XMLName xml.Name `xml:"root"`
|
||||
Data SqlByteArray `xml:"data"`
|
||||
}
|
||||
|
||||
func TestSqlNull_XML_ByteArray_Base64(t *testing.T) {
|
||||
orig := []byte{0xDE, 0xAD, 0xBE, 0xEF}
|
||||
w := xmlByteWrapper{Data: NewSqlByteArray(orig)}
|
||||
data, err := xml.Marshal(w)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var w2 xmlByteWrapper
|
||||
if err := xml.Unmarshal(data, &w2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if string(w2.Data.Val) != string(orig) {
|
||||
t.Errorf("expected %v, got %v", orig, w2.Data.Val)
|
||||
}
|
||||
}
|
||||
|
||||
// ── SqlTimeStamp / SqlDate / SqlTime: YAML + XML ────────────────────────────
|
||||
|
||||
func TestSqlTimeStamp_YAML(t *testing.T) {
|
||||
ts := NewSqlTimeStamp(time.Date(2024, 6, 15, 9, 30, 0, 0, time.UTC))
|
||||
data, err := yaml.Marshal(ts)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
if string(data) != "2024-06-15T09:30:00\n" {
|
||||
t.Errorf("unexpected YAML: %q", string(data))
|
||||
}
|
||||
var ts2 SqlTimeStamp
|
||||
if err := yaml.Unmarshal(data, &ts2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if ts2.Time().Format("2006-01-02T15:04:05") != "2024-06-15T09:30:00" {
|
||||
t.Errorf("expected 2024-06-15T09:30:00, got %v", ts2.Time())
|
||||
}
|
||||
|
||||
var zero SqlTimeStamp
|
||||
zdata, err := yaml.Marshal(zero)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
if string(zdata) != "null\n" {
|
||||
t.Errorf("expected null, got %q", zdata)
|
||||
}
|
||||
}
|
||||
|
||||
type xmlTimeStampWrapper struct {
|
||||
XMLName xml.Name `xml:"root"`
|
||||
At SqlTimeStamp `xml:"at"`
|
||||
}
|
||||
|
||||
func TestSqlTimeStamp_XML(t *testing.T) {
|
||||
w := xmlTimeStampWrapper{At: NewSqlTimeStamp(time.Date(2024, 6, 15, 9, 30, 0, 0, time.UTC))}
|
||||
data, err := xml.Marshal(w)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var w2 xmlTimeStampWrapper
|
||||
if err := xml.Unmarshal(data, &w2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if w2.At.Time().Format("2006-01-02T15:04:05") != "2024-06-15T09:30:00" {
|
||||
t.Errorf("expected 2024-06-15T09:30:00, got %v", w2.At.Time())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqlDate_YAML(t *testing.T) {
|
||||
d := NewSqlDate(time.Date(2024, 6, 15, 0, 0, 0, 0, time.UTC))
|
||||
data, err := yaml.Marshal(d)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
if string(data) != "\"2024-06-15\"\n" {
|
||||
t.Errorf("unexpected YAML: %q", string(data))
|
||||
}
|
||||
var d2 SqlDate
|
||||
if err := yaml.Unmarshal(data, &d2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if d2.String() != "2024-06-15" {
|
||||
t.Errorf("expected 2024-06-15, got %q", d2.String())
|
||||
}
|
||||
}
|
||||
|
||||
type xmlDateWrapper struct {
|
||||
XMLName xml.Name `xml:"root"`
|
||||
Day SqlDate `xml:"day"`
|
||||
}
|
||||
|
||||
func TestSqlDate_XML(t *testing.T) {
|
||||
w := xmlDateWrapper{Day: NewSqlDate(time.Date(2024, 6, 15, 0, 0, 0, 0, time.UTC))}
|
||||
data, err := xml.Marshal(w)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var w2 xmlDateWrapper
|
||||
if err := xml.Unmarshal(data, &w2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if w2.Day.String() != "2024-06-15" {
|
||||
t.Errorf("expected 2024-06-15, got %q", w2.Day.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqlTime_YAML(t *testing.T) {
|
||||
tm := NewSqlTime(time.Date(0, 1, 1, 14, 5, 9, 0, time.UTC))
|
||||
data, err := yaml.Marshal(tm)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
if string(data) != "\"14:05:09\"\n" {
|
||||
t.Errorf("unexpected YAML: %q", string(data))
|
||||
}
|
||||
var tm2 SqlTime
|
||||
if err := yaml.Unmarshal(data, &tm2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if tm2.String() != "14:05:09" {
|
||||
t.Errorf("expected 14:05:09, got %q", tm2.String())
|
||||
}
|
||||
}
|
||||
|
||||
type xmlTimeWrapper struct {
|
||||
XMLName xml.Name `xml:"root"`
|
||||
At SqlTime `xml:"at"`
|
||||
}
|
||||
|
||||
func TestSqlTime_XML(t *testing.T) {
|
||||
w := xmlTimeWrapper{At: NewSqlTime(time.Date(0, 1, 1, 14, 5, 9, 0, time.UTC))}
|
||||
data, err := xml.Marshal(w)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var w2 xmlTimeWrapper
|
||||
if err := xml.Unmarshal(data, &w2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if w2.At.String() != "14:05:09" {
|
||||
t.Errorf("expected 14:05:09, got %q", w2.At.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── SqlJSONB: YAML + XML ─────────────────────────────────────────────────────
|
||||
|
||||
func TestSqlJSONB_YAML(t *testing.T) {
|
||||
j := SqlJSONB(`{"name":"test","count":42}`)
|
||||
data, err := yaml.Marshal(j)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
|
||||
var decoded map[string]any
|
||||
if err := yaml.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("failed decoding produced YAML: %v", err)
|
||||
}
|
||||
if decoded["name"] != "test" {
|
||||
t.Errorf("expected name=test, got %v", decoded["name"])
|
||||
}
|
||||
|
||||
var j2 SqlJSONB
|
||||
if err := yaml.Unmarshal(data, &j2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
m, err := j2.AsMap()
|
||||
if err != nil {
|
||||
t.Fatalf("AsMap failed: %v", err)
|
||||
}
|
||||
if m["name"] != "test" {
|
||||
t.Errorf("expected name=test, got %v", m["name"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqlJSONB_YAML_Null(t *testing.T) {
|
||||
var j SqlJSONB
|
||||
data, err := yaml.Marshal(j)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
if string(data) != "null\n" {
|
||||
t.Errorf("expected null, got %q", data)
|
||||
}
|
||||
var j2 SqlJSONB
|
||||
if err := yaml.Unmarshal([]byte("null\n"), &j2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if j2 != nil {
|
||||
t.Errorf("expected nil, got %v", j2)
|
||||
}
|
||||
}
|
||||
|
||||
type xmlJSONBWrapper struct {
|
||||
XMLName xml.Name `xml:"root"`
|
||||
Meta SqlJSONB `xml:"meta"`
|
||||
}
|
||||
|
||||
func TestSqlJSONB_XML(t *testing.T) {
|
||||
w := xmlJSONBWrapper{Meta: SqlJSONB(`{"key":"value"}`)}
|
||||
data, err := xml.Marshal(w)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var w2 xmlJSONBWrapper
|
||||
if err := xml.Unmarshal(data, &w2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
m, err := w2.Meta.AsMap()
|
||||
if err != nil {
|
||||
t.Fatalf("AsMap failed: %v", err)
|
||||
}
|
||||
if m["key"] != "value" {
|
||||
t.Errorf("expected key=value, got %v", m)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Array types: YAML ────────────────────────────────────────────────────────
|
||||
|
||||
func TestSqlStringArray_YAML(t *testing.T) {
|
||||
a := NewSqlStringArray([]string{"a", "b", "c"})
|
||||
data, err := yaml.Marshal(a)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var a2 SqlStringArray
|
||||
if err := yaml.Unmarshal(data, &a2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if len(a2.Val) != 3 || a2.Val[1] != "b" {
|
||||
t.Errorf("unexpected value %v", a2.Val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqlStringArray_YAML_Null(t *testing.T) {
|
||||
var a SqlStringArray
|
||||
data, err := yaml.Marshal(a)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
if string(data) != "null\n" {
|
||||
t.Errorf("expected null, got %q", data)
|
||||
}
|
||||
var a2 SqlStringArray
|
||||
if err := yaml.Unmarshal([]byte("null\n"), &a2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if a2.Valid {
|
||||
t.Error("expected invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqlInt32Array_YAML(t *testing.T) {
|
||||
a := NewSqlInt32Array([]int32{1, 2, 3})
|
||||
data, err := yaml.Marshal(a)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var a2 SqlInt32Array
|
||||
if err := yaml.Unmarshal(data, &a2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
for i, v := range a.Val {
|
||||
if a2.Val[i] != v {
|
||||
t.Errorf("index %d: expected %d, got %d", i, v, a2.Val[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqlUUIDArray_YAML(t *testing.T) {
|
||||
ids := []uuid.UUID{uuid.New(), uuid.New()}
|
||||
a := NewSqlUUIDArray(ids)
|
||||
data, err := yaml.Marshal(a)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var a2 SqlUUIDArray
|
||||
if err := yaml.Unmarshal(data, &a2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
for i, v := range ids {
|
||||
if a2.Val[i] != v {
|
||||
t.Errorf("index %d: expected %v, got %v", i, v, a2.Val[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqlVector_YAML(t *testing.T) {
|
||||
v := NewSqlVector([]float32{0.1, 0.2, 0.3})
|
||||
data, err := yaml.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var v2 SqlVector
|
||||
if err := yaml.Unmarshal(data, &v2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
for i, f := range v.Val {
|
||||
if v2.Val[i] != f {
|
||||
t.Errorf("index %d: expected %v, got %v", i, f, v2.Val[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Array types: XML ─────────────────────────────────────────────────────────
|
||||
|
||||
type xmlStringArrayWrapper struct {
|
||||
XMLName xml.Name `xml:"root"`
|
||||
Tags SqlStringArray `xml:"tags"`
|
||||
}
|
||||
|
||||
func TestSqlStringArray_XML(t *testing.T) {
|
||||
w := xmlStringArrayWrapper{Tags: NewSqlStringArray([]string{"x", "y", "z"})}
|
||||
data, err := xml.Marshal(w)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var w2 xmlStringArrayWrapper
|
||||
if err := xml.Unmarshal(data, &w2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
want := []string{"x", "y", "z"}
|
||||
if len(w2.Tags.Val) != len(want) {
|
||||
t.Fatalf("expected %v, got %v", want, w2.Tags.Val)
|
||||
}
|
||||
for i := range want {
|
||||
if w2.Tags.Val[i] != want[i] {
|
||||
t.Errorf("index %d: expected %q, got %q", i, want[i], w2.Tags.Val[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqlStringArray_XML_Empty(t *testing.T) {
|
||||
w := xmlStringArrayWrapper{}
|
||||
data, err := xml.Marshal(w)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var w2 xmlStringArrayWrapper
|
||||
if err := xml.Unmarshal(data, &w2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if len(w2.Tags.Val) != 0 {
|
||||
t.Errorf("expected empty slice, got %v", w2.Tags.Val)
|
||||
}
|
||||
}
|
||||
|
||||
type xmlInt64ArrayWrapper struct {
|
||||
XMLName xml.Name `xml:"root"`
|
||||
Scores SqlInt64Array `xml:"scores"`
|
||||
}
|
||||
|
||||
func TestSqlInt64Array_XML(t *testing.T) {
|
||||
w := xmlInt64ArrayWrapper{Scores: NewSqlInt64Array([]int64{10, 20, 30})}
|
||||
data, err := xml.Marshal(w)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var w2 xmlInt64ArrayWrapper
|
||||
if err := xml.Unmarshal(data, &w2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
want := []int64{10, 20, 30}
|
||||
for i := range want {
|
||||
if w2.Scores.Val[i] != want[i] {
|
||||
t.Errorf("index %d: expected %d, got %d", i, want[i], w2.Scores.Val[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type xmlUUIDArrayWrapper struct {
|
||||
XMLName xml.Name `xml:"root"`
|
||||
IDs SqlUUIDArray `xml:"ids"`
|
||||
}
|
||||
|
||||
func TestSqlUUIDArray_XML(t *testing.T) {
|
||||
ids := []uuid.UUID{uuid.New(), uuid.New()}
|
||||
w := xmlUUIDArrayWrapper{IDs: NewSqlUUIDArray(ids)}
|
||||
data, err := xml.Marshal(w)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var w2 xmlUUIDArrayWrapper
|
||||
if err := xml.Unmarshal(data, &w2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
for i, id := range ids {
|
||||
if w2.IDs.Val[i] != id {
|
||||
t.Errorf("index %d: expected %v, got %v", i, id, w2.IDs.Val[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type xmlVectorWrapper struct {
|
||||
XMLName xml.Name `xml:"root"`
|
||||
Embedding SqlVector `xml:"embedding"`
|
||||
}
|
||||
|
||||
func TestSqlVector_XML(t *testing.T) {
|
||||
w := xmlVectorWrapper{Embedding: NewSqlVector([]float32{1.5, -2.5})}
|
||||
data, err := xml.Marshal(w)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var w2 xmlVectorWrapper
|
||||
if err := xml.Unmarshal(data, &w2); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
want := []float32{1.5, -2.5}
|
||||
for i := range want {
|
||||
if w2.Embedding.Val[i] != want[i] {
|
||||
t.Errorf("index %d: expected %v, got %v", i, want[i], w2.Embedding.Val[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Combined struct round-trip ───────────────────────────────────────────────
|
||||
|
||||
type yamlXMLRecord struct {
|
||||
XMLName xml.Name `xml:"record" yaml:"-" json:"-"`
|
||||
ID SqlUUID `xml:"id" yaml:"id"`
|
||||
Name SqlString `xml:"name" yaml:"name"`
|
||||
Age SqlInt32 `xml:"age" yaml:"age"`
|
||||
Active SqlBool `xml:"active" yaml:"active"`
|
||||
Created SqlTimeStamp `xml:"created" yaml:"created"`
|
||||
Tags SqlStringArray `xml:"tags" yaml:"tags"`
|
||||
}
|
||||
|
||||
func TestCombinedRecord_YAML_RoundTrip(t *testing.T) {
|
||||
id := uuid.New()
|
||||
original := yamlXMLRecord{
|
||||
ID: NewSqlUUID(id),
|
||||
Name: NewSqlString("Ada"),
|
||||
Age: NewSqlInt32(36),
|
||||
Active: NewSqlBool(true),
|
||||
Created: NewSqlTimeStamp(time.Date(2024, 3, 10, 12, 30, 0, 0, time.UTC)),
|
||||
Tags: NewSqlStringArray([]string{"engineer", "mathematician"}),
|
||||
}
|
||||
|
||||
data, err := yaml.Marshal(original)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
|
||||
var decoded yamlXMLRecord
|
||||
if err := yaml.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
|
||||
if decoded.ID.UUID() != id {
|
||||
t.Errorf("ID: expected %v, got %v", id, decoded.ID.UUID())
|
||||
}
|
||||
if decoded.Name.String() != "Ada" {
|
||||
t.Errorf("Name: expected Ada, got %q", decoded.Name.String())
|
||||
}
|
||||
if decoded.Age.Int64() != 36 {
|
||||
t.Errorf("Age: expected 36, got %d", decoded.Age.Int64())
|
||||
}
|
||||
if !decoded.Active.Bool() {
|
||||
t.Error("Active: expected true")
|
||||
}
|
||||
if decoded.Created.Time().Format("2006-01-02T15:04:05") != "2024-03-10T12:30:00" {
|
||||
t.Errorf("Created: expected 2024-03-10T12:30:00, got %v", decoded.Created.Time())
|
||||
}
|
||||
if len(decoded.Tags.Val) != 2 || decoded.Tags.Val[0] != "engineer" {
|
||||
t.Errorf("Tags: unexpected value %v", decoded.Tags.Val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCombinedRecord_XML_RoundTrip(t *testing.T) {
|
||||
id := uuid.New()
|
||||
original := yamlXMLRecord{
|
||||
ID: NewSqlUUID(id),
|
||||
Name: NewSqlString("Ada"),
|
||||
Age: NewSqlInt32(36),
|
||||
Active: NewSqlBool(true),
|
||||
Created: NewSqlTimeStamp(time.Date(2024, 3, 10, 12, 30, 0, 0, time.UTC)),
|
||||
Tags: NewSqlStringArray([]string{"engineer", "mathematician"}),
|
||||
}
|
||||
|
||||
data, err := xml.Marshal(original)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
|
||||
var decoded yamlXMLRecord
|
||||
if err := xml.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
|
||||
if decoded.ID.UUID() != id {
|
||||
t.Errorf("ID: expected %v, got %v", id, decoded.ID.UUID())
|
||||
}
|
||||
if decoded.Name.String() != "Ada" {
|
||||
t.Errorf("Name: expected Ada, got %q", decoded.Name.String())
|
||||
}
|
||||
if decoded.Age.Int64() != 36 {
|
||||
t.Errorf("Age: expected 36, got %d", decoded.Age.Int64())
|
||||
}
|
||||
if !decoded.Active.Bool() {
|
||||
t.Error("Active: expected true")
|
||||
}
|
||||
if decoded.Created.Time().Format("2006-01-02T15:04:05") != "2024-03-10T12:30:00" {
|
||||
t.Errorf("Created: expected 2024-03-10T12:30:00, got %v", decoded.Created.Time())
|
||||
}
|
||||
if len(decoded.Tags.Val) != 2 || decoded.Tags.Val[0] != "engineer" {
|
||||
t.Errorf("Tags: unexpected value %v", decoded.Tags.Val)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package sqltypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// record mimics a typical DB model composed of sqltypes fields, exercising
|
||||
// marshalling/unmarshalling of the whole set together as encoding/json would
|
||||
// when used on a real struct (not just the individual types in isolation).
|
||||
type record struct {
|
||||
ID SqlUUID `json:"id"`
|
||||
Name SqlString `json:"name"`
|
||||
Bio SqlString `json:"bio"`
|
||||
Age SqlInt32 `json:"age"`
|
||||
Score SqlFloat64 `json:"score"`
|
||||
Active SqlBool `json:"active"`
|
||||
CreatedAt SqlTimeStamp `json:"created_at"`
|
||||
BirthDate SqlDate `json:"birth_date"`
|
||||
Avatar SqlByteArray `json:"avatar"`
|
||||
Tags SqlStringArray `json:"tags"`
|
||||
Scores SqlInt32Array `json:"scores"`
|
||||
Metadata SqlJSONB `json:"metadata"`
|
||||
Embedding SqlVector `json:"embedding"`
|
||||
Extras []uuid.UUID `json:"-"`
|
||||
}
|
||||
|
||||
func TestStruct_JSON_RoundTrip_AllFieldsPresent(t *testing.T) {
|
||||
id := uuid.New()
|
||||
createdAt := time.Date(2024, 3, 10, 12, 30, 0, 0, time.UTC)
|
||||
birthDate := time.Date(1990, 5, 20, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
original := record{
|
||||
ID: NewSqlUUID(id),
|
||||
Name: NewSqlString("Ada Lovelace"),
|
||||
Bio: SqlString{}, // intentionally null
|
||||
Age: NewSqlInt32(36),
|
||||
Score: NewSqlFloat64(98.6),
|
||||
Active: NewSqlBool(true),
|
||||
CreatedAt: NewSqlTimeStamp(createdAt),
|
||||
BirthDate: NewSqlDate(birthDate),
|
||||
Avatar: NewSqlByteArray([]byte{0xDE, 0xAD, 0xBE, 0xEF}),
|
||||
Tags: NewSqlStringArray([]string{"engineer", "mathematician"}),
|
||||
Scores: NewSqlInt32Array([]int32{10, 20, 30}),
|
||||
Metadata: SqlJSONB(`{"role":"admin"}`),
|
||||
Embedding: NewSqlVector([]float32{0.1, 0.2, 0.3}),
|
||||
}
|
||||
|
||||
data, err := json.Marshal(original)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
|
||||
var decoded record
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
|
||||
if decoded.ID.UUID() != id {
|
||||
t.Errorf("ID: expected %v, got %v", id, decoded.ID.UUID())
|
||||
}
|
||||
if decoded.Name.String() != "Ada Lovelace" {
|
||||
t.Errorf("Name: expected Ada Lovelace, got %q", decoded.Name.String())
|
||||
}
|
||||
if decoded.Bio.Valid {
|
||||
t.Errorf("Bio: expected invalid/null, got %v", decoded.Bio)
|
||||
}
|
||||
if decoded.Age.Int64() != 36 {
|
||||
t.Errorf("Age: expected 36, got %d", decoded.Age.Int64())
|
||||
}
|
||||
if decoded.Score.Float64() != 98.6 {
|
||||
t.Errorf("Score: expected 98.6, got %v", decoded.Score.Float64())
|
||||
}
|
||||
if !decoded.Active.Bool() {
|
||||
t.Errorf("Active: expected true")
|
||||
}
|
||||
if decoded.CreatedAt.Time().Format("2006-01-02T15:04:05") != createdAt.Format("2006-01-02T15:04:05") {
|
||||
t.Errorf("CreatedAt: expected %v, got %v", createdAt, decoded.CreatedAt.Time())
|
||||
}
|
||||
if decoded.BirthDate.String() != "1990-05-20" {
|
||||
t.Errorf("BirthDate: expected 1990-05-20, got %q", decoded.BirthDate.String())
|
||||
}
|
||||
if string(decoded.Avatar.Val) != string(original.Avatar.Val) {
|
||||
t.Errorf("Avatar: expected %v, got %v", original.Avatar.Val, decoded.Avatar.Val)
|
||||
}
|
||||
if len(decoded.Tags.Val) != 2 || decoded.Tags.Val[0] != "engineer" {
|
||||
t.Errorf("Tags: unexpected value %v", decoded.Tags.Val)
|
||||
}
|
||||
if len(decoded.Scores.Val) != 3 || decoded.Scores.Val[2] != 30 {
|
||||
t.Errorf("Scores: unexpected value %v", decoded.Scores.Val)
|
||||
}
|
||||
m, err := decoded.Metadata.AsMap()
|
||||
if err != nil || m["role"] != "admin" {
|
||||
t.Errorf("Metadata: expected role=admin, got %v (err=%v)", m, err)
|
||||
}
|
||||
if len(decoded.Embedding.Val) != 3 || decoded.Embedding.Val[1] != 0.2 {
|
||||
t.Errorf("Embedding: unexpected value %v", decoded.Embedding.Val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStruct_JSON_RoundTrip_AllNull(t *testing.T) {
|
||||
var original record
|
||||
|
||||
data, err := json.Marshal(original)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
|
||||
var decoded record
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
|
||||
if decoded.ID.Valid || decoded.Name.Valid || decoded.Age.Valid || decoded.Score.Valid ||
|
||||
decoded.Active.Valid || decoded.CreatedAt.Valid || decoded.BirthDate.Valid ||
|
||||
decoded.Avatar.Valid || decoded.Tags.Valid || decoded.Scores.Valid || decoded.Embedding.Valid {
|
||||
t.Errorf("expected all fields invalid/null after round-trip, got %+v", decoded)
|
||||
}
|
||||
if decoded.Metadata != nil {
|
||||
t.Errorf("expected nil Metadata, got %v", decoded.Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStruct_JSON_UnmarshalFromRawJSON(t *testing.T) {
|
||||
raw := `{
|
||||
"id": "3e843d4e-6b3c-4f2e-9a1e-6f0b2f3c9d10",
|
||||
"name": "Grace Hopper",
|
||||
"bio": null,
|
||||
"age": 85,
|
||||
"score": 100,
|
||||
"active": false,
|
||||
"created_at": "2023-12-01T08:00:00",
|
||||
"birth_date": "1906-12-09",
|
||||
"avatar": "AQIDBA==",
|
||||
"tags": ["navy", "compiler"],
|
||||
"scores": [1, 2, 3],
|
||||
"metadata": {"key": "value"},
|
||||
"embedding": [1.0, 2.0]
|
||||
}`
|
||||
|
||||
var decoded record
|
||||
if err := json.Unmarshal([]byte(raw), &decoded); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
|
||||
if decoded.Name.String() != "Grace Hopper" {
|
||||
t.Errorf("expected Grace Hopper, got %q", decoded.Name.String())
|
||||
}
|
||||
if decoded.Age.Int64() != 85 {
|
||||
t.Errorf("expected age 85, got %d", decoded.Age.Int64())
|
||||
}
|
||||
if decoded.Active.Valid && decoded.Active.Bool() {
|
||||
t.Errorf("expected active=false")
|
||||
}
|
||||
if string(decoded.Avatar.Val) != string([]byte{1, 2, 3, 4}) {
|
||||
t.Errorf("expected avatar bytes [1 2 3 4], got %v", decoded.Avatar.Val)
|
||||
}
|
||||
if len(decoded.Tags.Val) != 2 || decoded.Tags.Val[1] != "compiler" {
|
||||
t.Errorf("unexpected tags %v", decoded.Tags.Val)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package sqltypes
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// TestUUIDWithRealDatabase tests that SqlUUID works with actual database operations
|
||||
func TestUUIDWithRealDatabase(t *testing.T) {
|
||||
// Open an in-memory SQLite database
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to open database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Create a test table with UUID column
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE test_users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id TEXT,
|
||||
name TEXT
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create table: %v", err)
|
||||
}
|
||||
|
||||
// Test 1: Insert with UUID
|
||||
testUUID1 := uuid.New()
|
||||
sqlUUID1 := NewSqlUUID(testUUID1)
|
||||
|
||||
_, err = db.Exec("INSERT INTO test_users (id, user_id, name) VALUES (?, ?, ?)",
|
||||
1, sqlUUID1, "Alice")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to insert record: %v", err)
|
||||
}
|
||||
|
||||
// Test 2: Update with UUID
|
||||
testUUID2 := uuid.New()
|
||||
sqlUUID2 := NewSqlUUID(testUUID2)
|
||||
|
||||
_, err = db.Exec("UPDATE test_users SET user_id = ? WHERE id = ?",
|
||||
sqlUUID2, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to update record: %v", err)
|
||||
}
|
||||
|
||||
// Test 3: Read back and verify
|
||||
var retrievedID string
|
||||
var name string
|
||||
err = db.QueryRow("SELECT user_id, name FROM test_users WHERE id = ?", 1).Scan(&retrievedID, &name)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to query record: %v", err)
|
||||
}
|
||||
|
||||
if retrievedID != testUUID2.String() {
|
||||
t.Errorf("Expected UUID %s, got %s", testUUID2.String(), retrievedID)
|
||||
}
|
||||
|
||||
if name != "Alice" {
|
||||
t.Errorf("Expected name 'Alice', got '%s'", name)
|
||||
}
|
||||
|
||||
// Test 4: Insert with NULL UUID
|
||||
nullUUID := SqlUUID{Valid: false}
|
||||
_, err = db.Exec("INSERT INTO test_users (id, user_id, name) VALUES (?, ?, ?)",
|
||||
2, nullUUID, "Bob")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to insert record with NULL UUID: %v", err)
|
||||
}
|
||||
|
||||
// Test 5: Read NULL UUID back
|
||||
var retrievedNullID sql.NullString
|
||||
err = db.QueryRow("SELECT user_id FROM test_users WHERE id = ?", 2).Scan(&retrievedNullID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to query NULL UUID record: %v", err)
|
||||
}
|
||||
|
||||
if retrievedNullID.Valid {
|
||||
t.Errorf("Expected NULL UUID, got %s", retrievedNullID.String)
|
||||
}
|
||||
|
||||
t.Logf("All database operations with UUID succeeded!")
|
||||
}
|
||||
|
||||
// TestUUIDValueReturnsString verifies that Value() returns string, not uuid.UUID
|
||||
func TestUUIDValueReturnsString(t *testing.T) {
|
||||
testUUID := uuid.New()
|
||||
sqlUUID := NewSqlUUID(testUUID)
|
||||
|
||||
val, err := sqlUUID.Value()
|
||||
if err != nil {
|
||||
t.Fatalf("Value() failed: %v", err)
|
||||
}
|
||||
|
||||
// The value should be a string, not a uuid.UUID
|
||||
strVal, ok := val.(string)
|
||||
if !ok {
|
||||
t.Fatalf("Expected Value() to return string, got %T", val)
|
||||
}
|
||||
|
||||
if strVal != testUUID.String() {
|
||||
t.Errorf("Expected %s, got %s", testUUID.String(), strVal)
|
||||
}
|
||||
|
||||
t.Logf("✓ Value() correctly returns string: %s", strVal)
|
||||
}
|
||||
|
||||
// CustomStringableType is a custom type that implements fmt.Stringer
|
||||
type CustomStringableType string
|
||||
|
||||
func (c CustomStringableType) String() string {
|
||||
return "custom:" + string(c)
|
||||
}
|
||||
|
||||
// TestCustomStringableType verifies that any type implementing fmt.Stringer works
|
||||
func TestCustomStringableType(t *testing.T) {
|
||||
customVal := CustomStringableType("test-value")
|
||||
sqlCustom := SqlNull[CustomStringableType]{
|
||||
Val: customVal,
|
||||
Valid: true,
|
||||
}
|
||||
|
||||
val, err := sqlCustom.Value()
|
||||
if err != nil {
|
||||
t.Fatalf("Value() failed: %v", err)
|
||||
}
|
||||
|
||||
// Should return the result of String() method
|
||||
strVal, ok := val.(string)
|
||||
if !ok {
|
||||
t.Fatalf("Expected Value() to return string, got %T", val)
|
||||
}
|
||||
|
||||
expected := "custom:test-value"
|
||||
if strVal != expected {
|
||||
t.Errorf("Expected %s, got %s", expected, strVal)
|
||||
}
|
||||
|
||||
t.Logf("✓ Custom Stringer type correctly converted to string: %s", strVal)
|
||||
}
|
||||
|
||||
// TestStringMethodUsesStringer verifies that String() method also uses fmt.Stringer
|
||||
func TestStringMethodUsesStringer(t *testing.T) {
|
||||
// Test with UUID
|
||||
testUUID := uuid.New()
|
||||
sqlUUID := NewSqlUUID(testUUID)
|
||||
|
||||
strResult := sqlUUID.String()
|
||||
if strResult != testUUID.String() {
|
||||
t.Errorf("Expected UUID String() to return %s, got %s", testUUID.String(), strResult)
|
||||
}
|
||||
t.Logf("✓ UUID String() method: %s", strResult)
|
||||
|
||||
// Test with custom Stringer type
|
||||
customVal := CustomStringableType("test-value")
|
||||
sqlCustom := SqlNull[CustomStringableType]{
|
||||
Val: customVal,
|
||||
Valid: true,
|
||||
}
|
||||
|
||||
customStr := sqlCustom.String()
|
||||
expected := "custom:test-value"
|
||||
if customStr != expected {
|
||||
t.Errorf("Expected custom String() to return %s, got %s", expected, customStr)
|
||||
}
|
||||
t.Logf("✓ Custom Stringer String() method: %s", customStr)
|
||||
|
||||
// Test with regular type (should use fmt.Sprintf)
|
||||
sqlInt := NewSqlInt64(42)
|
||||
intStr := sqlInt.String()
|
||||
if intStr != "42" {
|
||||
t.Errorf("Expected int String() to return '42', got '%s'", intStr)
|
||||
}
|
||||
t.Logf("✓ Regular type String() method: %s", intStr)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Package transform provides validation and transformation utilities for database models.
|
||||
//
|
||||
// # Overview
|
||||
//
|
||||
// The transform package contains a Transformer type that provides methods for validating
|
||||
// and normalizing database schemas. It ensures schema correctness and consistency across
|
||||
// different format conversions.
|
||||
//
|
||||
// # Features
|
||||
//
|
||||
// - Database validation (structure and naming conventions)
|
||||
// - Schema validation (completeness and integrity)
|
||||
// - Table validation (column definitions and constraints)
|
||||
// - Data type normalization
|
||||
//
|
||||
// # Usage
|
||||
//
|
||||
// transformer := transform.NewTransformer()
|
||||
// err := transformer.ValidateDatabase(db)
|
||||
// if err != nil {
|
||||
// log.Fatal("Invalid database schema:", err)
|
||||
// }
|
||||
//
|
||||
// # Validation Scope
|
||||
//
|
||||
// The transformer validates:
|
||||
// - Required fields presence
|
||||
// - Naming convention adherence
|
||||
// - Data type compatibility
|
||||
// - Constraint consistency
|
||||
// - Relationship integrity
|
||||
//
|
||||
// Note: Some validation methods are currently stubs and will be implemented as needed.
|
||||
package transform
|
||||
@@ -0,0 +1,57 @@
|
||||
// Package ui provides an interactive terminal user interface (TUI) for editing database schemas.
|
||||
//
|
||||
// # Overview
|
||||
//
|
||||
// The ui package implements a full-featured terminal-based schema editor using tview,
|
||||
// allowing users to visually create, modify, and manage database schemas without writing
|
||||
// code or SQL.
|
||||
//
|
||||
// # Features
|
||||
//
|
||||
// The schema editor supports:
|
||||
// - Database management: Edit name, description, and properties
|
||||
// - Schema management: Create, edit, delete schemas
|
||||
// - Table management: Create, edit, delete tables
|
||||
// - Column management: Add, modify, delete columns with full property support
|
||||
// - Relationship management: Define and edit table relationships
|
||||
// - Domain management: Organize tables into logical domains
|
||||
// - Import & merge: Combine schemas from multiple sources
|
||||
// - Save: Export to any supported format
|
||||
//
|
||||
// # Architecture
|
||||
//
|
||||
// The package is organized into several components:
|
||||
// - editor.go: Main editor and application lifecycle
|
||||
// - *_screens.go: UI screens for each entity type
|
||||
// - *_dataops.go: Business logic and data operations
|
||||
// - dialogs.go: Reusable dialog components
|
||||
// - load_save_screens.go: File I/O and format selection
|
||||
// - main_menu.go: Primary navigation menu
|
||||
//
|
||||
// # Usage
|
||||
//
|
||||
// editor := ui.NewSchemaEditor(database)
|
||||
// if err := editor.Run(); err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
//
|
||||
// Or with pre-configured load/save options:
|
||||
//
|
||||
// editor := ui.NewSchemaEditorWithConfigs(database, loadConfig, saveConfig)
|
||||
// if err := editor.Run(); err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
//
|
||||
// # Navigation
|
||||
//
|
||||
// - Arrow keys: Navigate between items
|
||||
// - Enter: Select/edit item
|
||||
// - Tab/Shift+Tab: Navigate between buttons
|
||||
// - Escape: Go back/cancel
|
||||
// - Letter shortcuts: Quick actions (e.g., 'n' for new, 'e' for edit, 'd' for delete)
|
||||
//
|
||||
// # Integration
|
||||
//
|
||||
// The editor integrates with all readers and writers, supporting load/save operations
|
||||
// for any format supported by RelSpec (DBML, PostgreSQL, GORM, Prisma, etc.).
|
||||
package ui
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user