Compare commits

...

51 Commits

Author SHA1 Message Date
Hein 7805d9b6f0 chore(release): update package version to 1.0.62
Release / test (push) Successful in 2m34s
Release / release (push) Successful in 1m43s
Release / pkg-aur (push) Successful in 1m2s
Release / pkg-deb (push) Successful in 2m57s
Release / pkg-rpm (push) Successful in 3m2s
2026-07-07 15:29:55 +02:00
Hein 40a0e6a0aa refactor: ♻️ change resolvspec types to build in sqltypes 2026-07-07 15:28:01 +02:00
Hein 99d63aa5f4 test(uuid): add integration tests for SqlUUID with database
* Implement tests for inserting, updating, and retrieving UUIDs in a SQLite database.
* Verify that Value() returns a string representation of the UUID.
* Ensure compatibility with custom types implementing fmt.Stringer.
* Update type mapper imports to reflect new package path.
2026-07-02 17:06:41 +02:00
warkanum ee94ddc133 chore(release): update package version to 1.0.61
Release / test (push) Successful in 1m12s
Release / release (push) Successful in 1m40s
Release / pkg-aur (push) Successful in 1m2s
Release / pkg-deb (push) Successful in 45s
Release / pkg-rpm (push) Successful in 1m18s
2026-06-26 00:27:33 +02:00
warkanum 651c7aa3f4 fix(reflectutil): correct pointer type check in dereference logic 2026-06-26 00:27:25 +02:00
warkanum 1cd9cd8803 chore(release): update package version to 1.0.60
Release / test (push) Failing after 0s
Release / release (push) Has been skipped
Release / pkg-aur (push) Has been skipped
Release / pkg-deb (push) Has been skipped
Release / pkg-rpm (push) Has been skipped
2026-06-25 23:26:08 +02:00
warkanum ab735d1f3a fix(writer): update nullable type handling to use baselib
* change default nullable type from resolvespec to baselib
* update type mappings in tests to reflect new nullable types
* adjust comments for clarity on nullable type options
2026-06-25 23:25:36 +02:00
warkanum e29c7e31c1 chore(release): update package version to 1.0.59
Release / test (push) Failing after -35m9s
Release / release (push) Has been skipped
Release / pkg-aur (push) Has been skipped
Release / pkg-deb (push) Has been skipped
Release / pkg-rpm (push) Has been skipped
2026-05-20 22:52:29 +02:00
warkanum 43f4680176 chore: ⬆️ updated deps 2026-05-20 22:52:20 +02:00
warkanum d9f27c1775 feat(pgsql): enhance SQL statement execution logging
* add context information for executed SQL statements
* implement extractStatementContext function for context retrieval
2026-05-19 19:42:57 +02:00
warkanum bb7ceb37fe chore(release): update package version to 1.0.58
Release / test (push) Successful in -32m53s
Release / release (push) Successful in -20m53s
Release / pkg-deb (push) Successful in -31m34s
Release / pkg-rpm (push) Successful in -31m3s
Release / pkg-aur (push) Successful in -11m7s
2026-05-19 19:26:45 +02:00
warkanum 6a759ef3d1 fix(mssql): correct order of MSSQL type mappings 2026-05-19 19:26:30 +02:00
warkanum cb735f0754 feat(sqlite): add SQLite type mapping and conversion functions
* Implement SQLiteToCanonicalTypes for type mapping
* Add ConvertSQLiteToCanonical and ConvertCanonicalToSQLite functions
* Update mapDataType to utilize new conversion logic
2026-05-19 19:26:09 +02:00
warkanum 80fb49bc5e refactor(datatypes): remove redundant normalization function 2026-05-19 19:12:54 +02:00
warkanum 9190df81dd feat(merge): enhance type conflict detection for columns
* Introduced extractTypeParts function to handle embedded dimensions in type strings.
* Updated columnTypeConflict to utilize new type extraction logic.
* Improved PostgreSQL type normalization and handling in various components.
2026-05-19 19:12:27 +02:00
Hein 9235ef5e08 chore(release): update package version to 1.0.57
Release / test (push) Successful in -32m7s
Release / release (push) Successful in -27m58s
Release / pkg-deb (push) Successful in -30m52s
Release / pkg-aur (push) Successful in -29m5s
Release / pkg-rpm (push) Successful in -29m58s
2026-05-07 14:45:02 +02:00
Hein b91d6b33b5 feat(writer): add continue-on-error option for SQL writers
* Introduce ContinueOnError option to WriterOptions
* Update writer functions to support continue-on-error behavior
* Modify migration and database writing to handle continue-on-error
2026-05-07 14:44:36 +02:00
warkanum 30ef1db010 chore(release): update package version to 1.0.56
Release / test (push) Successful in -32m19s
Release / release (push) Successful in -31m39s
Release / pkg-deb (push) Successful in -31m57s
Release / pkg-aur (push) Successful in -31m46s
Release / pkg-rpm (push) Successful in -4m7s
2026-05-05 14:51:10 +02:00
warkanum 2d97a47ee1 feat: Enhance PostgreSQL type handling and migration scripts
- Introduced equivalent base types and variants for PostgreSQL types to normalize type comparisons.
- Added functions for normalizing SQL types and retrieving equivalent type variants.
- Updated migration writer to handle type alterations with checks for existing types.
- Implemented logic to create necessary extensions (e.g., pg_trgm) based on schema requirements.
- Enhanced tests to cover new functionality for type normalization and migration handling.
- Improved handling of GIN indexes to use appropriate operator classes based on column types.
2026-05-05 14:50:34 +02:00
warkanum 72200ea72e chore(release): update package version to 1.0.55
Release / test (push) Successful in -32m1s
Release / release (push) Successful in -31m13s
Release / pkg-aur (push) Successful in -32m13s
Release / pkg-deb (push) Successful in -31m12s
Release / pkg-rpm (push) Successful in -29m45s
2026-05-05 11:36:29 +02:00
warkanum 608893a3d6 feat(index): implement GIN index support for quoted text columns and enhance index column resolution 2026-05-05 11:32:15 +02:00
warkanum 53ff745d5d chore(release): update package version to 1.0.54
Release / test (push) Successful in -31m47s
Release / release (push) Successful in -31m9s
Release / pkg-aur (push) Successful in -31m57s
Release / pkg-deb (push) Successful in -31m1s
Release / pkg-rpm (push) Successful in -29m27s
2026-05-05 11:12:49 +02:00
warkanum 17bc8ed395 feat(migration): enhance primary key handling and add GIN index support in migration writer 2026-05-05 11:12:23 +02:00
warkanum a447b68b22 chore(release): update package version to 1.0.53
Release / test (push) Successful in -31m55s
Release / release (push) Successful in -31m19s
Release / pkg-aur (push) Successful in -32m3s
Release / pkg-deb (push) Successful in -31m21s
Release / pkg-rpm (push) Successful in -28m4s
2026-05-05 10:48:27 +02:00
warkanum 4303dcf59b Support typed primary key helpers in gorm and bun writers 2026-05-05 10:32:33 +02:00
warkanum e828d48798 chore(release): update package version to 1.0.52
Release / test (push) Successful in -32m39s
Release / release (push) Successful in -32m1s
Release / pkg-deb (push) Successful in -32m9s
Release / pkg-aur (push) Successful in -31m37s
Release / pkg-rpm (push) Successful in -27m28s
2026-05-03 17:19:22 +02:00
warkanum 6e470a9239 fix(type_mapper): adjust array tag handling in BuildBunTag 2026-05-03 17:18:58 +02:00
warkanum 096815fe49 chore(release): update package version to 1.0.51
Release / test (push) Successful in -32m30s
Release / release (push) Successful in -31m54s
Release / pkg-aur (push) Successful in -32m31s
Release / pkg-deb (push) Successful in -32m7s
Release / pkg-rpm (push) Successful in -30m36s
2026-05-03 16:11:13 +02:00
warkanum b8f60203cb fix(type_mapper): handle PostgreSQL array types in tags
* Update BuildBunTag to append "array" for array types
* Add tests for handling array types in TypeMapper
* Adjust regex in SanitizeStructTagValue to preserve array suffix
2026-05-03 16:11:01 +02:00
Hein 15763f60cc Fix GIN opclass handling for array columns 2026-04-30 20:35:06 +02:00
Hein 6d2884f5cf chore(release): update package version to 1.0.50
Release / test (push) Successful in -32m41s
Release / release (push) Successful in -28m56s
Release / pkg-deb (push) Successful in -31m19s
Release / pkg-aur (push) Successful in -27m21s
Release / pkg-rpm (push) Failing after -26m24s
2026-04-30 20:23:29 +02:00
Hein f192decff8 Add Prisma 7 flag support 2026-04-30 20:22:57 +02:00
Hein 8b906cf4a3 chore(release): update package version to 1.0.49
Release / test (push) Successful in -32m39s
Release / release (push) Successful in -31m40s
Release / pkg-aur (push) Successful in -32m46s
Release / pkg-deb (push) Successful in -32m9s
Release / pkg-rpm (push) Successful in -29m53s
2026-04-30 18:16:28 +02:00
Hein 0a3966e6fc fix(pgsql): handle default values for array types in migrations
* update default value quoting logic for PostgreSQL
* add tests for array default value handling
2026-04-30 18:16:21 +02:00
Hein d30fc24f55 chore(release): update package version to 1.0.48
Release / test (push) Successful in -32m44s
Release / release (push) Successful in -32m5s
Release / pkg-aur (push) Successful in -32m38s
Release / pkg-deb (push) Successful in -32m6s
Release / pkg-rpm (push) Successful in -30m46s
2026-04-30 16:07:33 +02:00
Hein 16a489d0b8 style(pkg): align json and numeric type mappings 2026-04-30 16:07:16 +02:00
Hein 3524e86282 feat: add --types flag and stdlib nullable type support for bun/gorm writers
* Fix pgsql reader double-quoting defaults: normalizePostgresDefault strips
  surrounding SQL string literal quotes from column_default before storing,
  matching the convention used by every other reader.

* Add NullableTypes field to WriterOptions with NullableTypeResolveSpec
  (default) and NullableTypeStdlib constants.

* Both bun and gorm TypeMappers now accept a typeStyle parameter. stdlib
  mode produces sql.NullString/NullInt32/NullTime etc. for nullable scalars,
  plain Go slices for arrays, and time.Time for NOT NULL timestamps. Default
  resolvespec behaviour is unchanged.

* Add --types flag to convert and split commands.

* Update bun/README.md and gorm/README.md with side-by-side generated code
  examples, updated type mapping tables, and Writer Options documentation.
2026-04-30 16:00:54 +02:00
Hein 1e54fdcd7f Merge branch 'master' of git.warky.dev:wdevs/relspecgo 2026-04-30 15:15:34 +02:00
warkanum fb104ea084 feat: PostgreSQL connections opened by relspec set application_name by default to relspecgo/<version>
Release / test (push) Successful in -31m41s
Release / release (push) Successful in -28m47s
Release / pkg-aur (push) Successful in -32m40s
Release / pkg-deb (push) Successful in -32m25s
Release / pkg-rpm (push) Successful in -28m30s
2026-04-26 17:48:26 +02:00
warkanum 837160b77a feat(pgsql): implement application_name handling in connection 2026-04-26 17:45:25 +02:00
warkanum ed7130bba8 refactor(pkg): canonicalize base types and adjust length handling
* Update base types to keep explicit modifier forms
* Modify length handling for vector types in tests
2026-04-26 17:35:15 +02:00
warkanum 4ca1810d07 refactor(dctx): sort table columns and indexes for deterministic output
Release / test (push) Failing after -31m18s
Release / release (push) Has been skipped
Release / pkg-aur (push) Has been skipped
Release / pkg-deb (push) Has been skipped
Release / pkg-rpm (push) Has been skipped
2026-04-26 12:50:39 +02:00
warkanum c0880cb076 feat(pkg): preserve PostgreSQL types in mapDataType function
Release / test (push) Failing after -31m27s
Release / release (push) Has been skipped
Release / pkg-aur (push) Has been skipped
Release / pkg-deb (push) Has been skipped
Release / pkg-rpm (push) Has been skipped
* Add support for known PostgreSQL types and modifiers
* Implement canonicalization for PostgreSQL types
* Introduce unit tests for PostgreSQL type handling
2026-04-26 12:43:44 +02:00
warkanum 988798998d test(drawdb): add test for converting column types with modifiers
* Implement tests to ensure explicit type modifiers are preserved during conversion.
* Validate behavior for varchar, numeric, and custom vector types.
2026-04-26 12:35:54 +02:00
warkanum 535a91d4be feat(docs): add comprehensive story of RelSpecGo's development journey 2026-04-08 22:21:24 +02:00
warkanum bd54e85727 chore(release): update package version to 1.0.44
Release / test (push) Successful in -30m26s
Release / release (push) Successful in -29m48s
Release / pkg-deb (push) Successful in -29m54s
Release / pkg-aur (push) Successful in -28m28s
Release / pkg-rpm (push) Successful in -27m4s
2026-04-08 21:34:28 +02:00
warkanum b042b2d508 docs: 📝 Update documentation 2026-04-08 21:34:00 +02:00
warkanum af1733dc9a feat(pkg): update package description for clarity and consistency 2026-04-08 21:21:33 +02:00
warkanum 389fff2b44 chore(release): update package version to 1.0.43
Release / test (push) Successful in -29m19s
Release / release (push) Successful in -26m35s
Release / pkg-aur (push) Failing after -30m58s
Release / pkg-deb (push) Successful in -29m54s
Release / pkg-rpm (push) Successful in -28m15s
2026-04-08 20:59:23 +02:00
warkanum f331ba2b61 chore(release): update package version and add packaging files for AUR, Debian, and RPM 2026-04-08 20:59:11 +02:00
Hein 3d9cc7ec58 .
Release / Build and Release (push) Successful in -25m33s
2026-02-20 16:32:19 +02:00
476 changed files with 306210 additions and 302818 deletions
-5
View File
@@ -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.
-9
View File
@@ -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.
-10
View File
@@ -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.
-5
View File
@@ -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.
View File
+327
View File
@@ -0,0 +1,327 @@
name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: 'Tag to release (e.g. v1.2.3)'
required: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Test
run: go test ./...
- name: Lint
run: go vet ./...
release:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build release binaries
run: |
VERSION="${{ github.event.inputs.tag || github.ref_name }}"
for target in "linux/amd64" "linux/arm64" "darwin/amd64" "darwin/arm64" "windows/amd64"; do
GOOS="${target%/*}"
GOARCH="${target#*/}"
EXT=""
[ "$GOOS" = "windows" ] && EXT=".exe"
NAME="relspec-${GOOS}-${GOARCH}${EXT}"
GOOS="$GOOS" GOARCH="$GOARCH" go build \
-trimpath \
-ldflags "-X git.warky.dev/wdevs/relspecgo/cmd/relspec.version=${VERSION}" \
-o "$NAME" ./cmd/relspec
echo "Built $NAME"
done
- name: Create release and upload assets
run: |
TAG="${{ github.event.inputs.tag || github.ref_name }}"
API="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases"
# Collect commits since the previous tag (or last 20 if no prior tag)
PREV_TAG=$(git tag --sort=-version:refname | grep -v "^${TAG}$" | head -1)
if [ -n "$PREV_TAG" ]; then
RANGE="${PREV_TAG}..${TAG}"
else
RANGE="HEAD~20..HEAD"
fi
NOTES=$(git log "$RANGE" --pretty=format:"- %s" --no-merges)
BODY="## What's changed"$'\n'"${NOTES}"
# Escape for JSON
BODY_JSON=$(printf '%s' "$BODY" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')
RELEASE=$(curl -s -X POST "$API" \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"body\":${BODY_JSON}}")
UPLOAD_URL=$(echo "$RELEASE" | grep -o '"upload_url":"[^"]*"' | cut -d'"' -f4 | sed 's/{[^}]*}//')
if [ -z "$UPLOAD_URL" ]; then
echo "Failed to create release: $RELEASE"
exit 1
fi
for f in relspec-*; do
echo "Uploading $f..."
curl -s -X POST "${UPLOAD_URL}?name=${f}" \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${f}" > /dev/null
done
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
pkg-aur:
needs: release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Publish to AUR
env:
AUR_SSH_KEY: ${{ secrets.AUR_SSH_KEY }}
run: |
set -euo pipefail
VERSION="${{ github.event.inputs.tag || github.ref_name }}"
PKGVER="${VERSION#v}"
AUR_KEY_PATH="$HOME/.ssh/aur"
AUR_KNOWN_HOSTS="$HOME/.ssh/known_hosts"
# Setup SSH for AUR
mkdir -p ~/.ssh
chmod 700 ~/.ssh
if [ -z "${AUR_SSH_KEY:-}" ]; then
echo "AUR_SSH_KEY is empty"
exit 1
fi
# Support raw multiline keys, escaped \\n secrets, or base64-encoded keys.
CLEAN_AUR_SSH_KEY="$(printf '%s' "$AUR_SSH_KEY" | tr -d '\r')"
if printf '%s' "$CLEAN_AUR_SSH_KEY" | grep -q "^-----BEGIN .*PRIVATE KEY-----$"; then
printf '%s\n' "$CLEAN_AUR_SSH_KEY" > "$AUR_KEY_PATH"
elif printf '%s' "$CLEAN_AUR_SSH_KEY" | grep -q '\\n'; then
printf '%b\n' "$CLEAN_AUR_SSH_KEY" > "$AUR_KEY_PATH"
else
if printf '%s' "$CLEAN_AUR_SSH_KEY" | tr -d '[:space:]' | base64 --decode > "$AUR_KEY_PATH" 2>/dev/null; then
:
else
printf '%s\n' "$CLEAN_AUR_SSH_KEY" > "$AUR_KEY_PATH"
fi
fi
chmod 600 "$AUR_KEY_PATH"
if ! ssh-keygen -y -f "$AUR_KEY_PATH" >/dev/null 2>&1; then
echo "AUR_SSH_KEY is not a valid private key."
echo "Store it as a raw private key, an escaped private key with \\n, or a base64-encoded private key."
exit 1
fi
ssh-keyscan -t rsa,ed25519 aur.archlinux.org >> "$AUR_KNOWN_HOSTS"
chmod 644 "$AUR_KNOWN_HOSTS"
# Clone AUR repo
GIT_SSH_COMMAND="ssh -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$AUR_KNOWN_HOSTS -i $AUR_KEY_PATH" \
git clone ssh://aur@aur.archlinux.org/relspec.git aur-repo
CURRENT_PKGVER=$(awk -F= '/^pkgver=/ {print $2; exit}' aur-repo/PKGBUILD | tr -d "[:space:]")
CURRENT_PKGREL=$(awk -F= '/^pkgrel=/ {print $2; exit}' aur-repo/PKGBUILD | tr -d "[:space:]")
if [ "$CURRENT_PKGVER" = "$PKGVER" ]; then
case "$CURRENT_PKGREL" in
''|*[!0-9]*)
echo "Unsupported pkgrel in AUR repo: ${CURRENT_PKGREL}"
exit 1
;;
*)
PKGREL=$((CURRENT_PKGREL + 1))
;;
esac
else
PKGREL=1
fi
echo "Publishing AUR package version ${PKGVER}-${PKGREL}"
# Compute SHA256 of the source archive from the same URL the PKGBUILD will download.
SHA=$(curl -fsSL "https://git.warky.dev/wdevs/relspecgo/archive/v${PKGVER}.zip" | sha256sum | cut -d' ' -f1)
# Update PKGBUILD — keep remote source URL, bump version/checksum, and increment pkgrel for same-version rebuilds.
sed -e "s/^pkgver=.*/pkgver=${PKGVER}/" \
-e "s/^pkgrel=.*/pkgrel=${PKGREL}/" \
-e "s/^sha256sums=.*/sha256sums=('${SHA}')/" \
linux/arch/PKGBUILD > aur-repo/PKGBUILD
# Generate .SRCINFO inside an Arch container (docker cp avoids DinD volume mount issues)
CID=$(docker run -d archlinux:latest sleep infinity)
docker cp aur-repo/PKGBUILD $CID:/build/PKGBUILD || (docker exec $CID mkdir -p /build && docker cp aur-repo/PKGBUILD $CID:/build/PKGBUILD)
docker exec $CID bash -c "
pacman -Sy --noconfirm base-devel &&
useradd -m builder &&
chown -R builder:builder /build &&
runuser -u builder -- bash -c 'cd /build && makepkg --printsrcinfo > .SRCINFO'
"
docker cp $CID:/build/.SRCINFO aur-repo/.SRCINFO
docker rm -f $CID
# Commit and push to AUR master
cd aur-repo
git config user.email "hein@warky.dev"
git config user.name "Hein"
git add PKGBUILD .SRCINFO
git commit -m "Update to v${PKGVER}-${PKGREL}"
GIT_SSH_COMMAND="ssh -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$AUR_KNOWN_HOSTS -i $AUR_KEY_PATH" \
git push origin HEAD:master
pkg-deb:
needs: release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build Debian packages
run: |
VERSION="${{ github.event.inputs.tag || github.ref_name }}"
PKGVER="${VERSION#v}"
for GOARCH in amd64 arm64; do
GOOS=linux GOARCH=$GOARCH go build \
-trimpath \
-ldflags "-X git.warky.dev/wdevs/relspecgo/cmd/relspec.version=${PKGVER}" \
-o relspec ./cmd/relspec
PKGDIR="relspec_${PKGVER}_${GOARCH}"
mkdir -p "${PKGDIR}/DEBIAN"
mkdir -p "${PKGDIR}/usr/bin"
install -m755 relspec "${PKGDIR}/usr/bin/relspec"
sed -e "s/VERSION/${PKGVER}/" \
-e "s/ARCH/${GOARCH}/" \
linux/debian/control > "${PKGDIR}/DEBIAN/control"
dpkg-deb --build --root-owner-group "${PKGDIR}"
echo "Built ${PKGDIR}.deb"
done
- name: Upload to release
run: |
TAG="${{ github.event.inputs.tag || github.ref_name }}"
RELEASE=$(curl -s "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" \
-H "Authorization: token ${GITHUB_TOKEN}")
UPLOAD_URL=$(echo "$RELEASE" | grep -o '"upload_url":"[^"]*"' | cut -d'"' -f4 | sed 's/{[^}]*}//')
for f in *.deb; do
FNAME=$(basename "$f")
echo "Uploading $FNAME..."
curl -s -X POST "${UPLOAD_URL}?name=${FNAME}" \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${f}" > /dev/null
done
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
pkg-rpm:
needs: release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build RPM
run: |
set -euo pipefail
VERSION="${{ github.event.inputs.tag || github.ref_name }}"
PKGVER="${VERSION#v}"
GO_VER="$(awk '/^go / { print $2; exit }' go.mod)"
if [ -z "${GO_VER}" ]; then
echo "Failed to determine Go version from go.mod"
exit 1
fi
# Source tarball — prefix=relspec-VERSION/ matches RPM %autosetup convention
git archive --format=tar.gz --prefix=relspec-${PKGVER}/ HEAD \
> relspec-${PKGVER}.tar.gz
# Patch spec version
sed -i "s/^Version:.*/Version: ${PKGVER}/" linux/centos/relspec.spec
mkdir -p linux/centos/out
CID=$(docker create \
-e GO_VER="${GO_VER}" \
-e PKGVER="${PKGVER}" \
-w /build \
rockylinux:9 \
bash -lc "
set -euo pipefail
dnf install -y rpm-build git &&
curl -fsSL https://go.dev/dl/go\${GO_VER}.linux-amd64.tar.gz | tar -C /usr/local -xz &&
export PATH=\$PATH:/usr/local/go/bin &&
mkdir -p ~/rpmbuild/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS} &&
cp relspec-${PKGVER}.tar.gz ~/rpmbuild/SOURCES/ &&
cp linux/centos/relspec.spec ~/rpmbuild/SPECS/ &&
rpmbuild --nodeps -ba ~/rpmbuild/SPECS/relspec.spec
")
cleanup() {
docker rm -f "$CID" >/dev/null 2>&1 || true
}
trap cleanup EXIT
docker cp relspec-${PKGVER}.tar.gz "$CID:/build/relspec-${PKGVER}.tar.gz"
docker cp linux "$CID:/build/linux"
docker start -a "$CID"
docker cp "$CID:/root/rpmbuild/RPMS/." linux/centos/out/
trap - EXIT
cleanup
- name: Upload to release
run: |
TAG="${{ github.event.inputs.tag || github.ref_name }}"
RELEASE=$(curl -s "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" \
-H "Authorization: token ${GITHUB_TOKEN}")
UPLOAD_URL=$(echo "$RELEASE" | grep -o '"upload_url":"[^"]*"' | cut -d'"' -f4 | sed 's/{[^}]*}//')
while IFS= read -r f; do
FNAME=$(basename "$f")
echo "Uploading $FNAME..."
curl -s -X POST "${UPLOAD_URL}?name=${FNAME}" \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${f}" > /dev/null
done < <(find linux/centos/out -name "*.rpm")
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-117
View File
@@ -1,117 +0,0 @@
name: Release
run-name: "Making Release"
on:
push:
tags:
- 'v*.*.*'
jobs:
build-and-release:
name: Build and Release
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25'
- name: Get version from tag
id: get_version
run: |
echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
echo "BUILD_DATE=$(date -u '+%Y-%m-%d %H:%M:%S UTC')" >> $GITHUB_OUTPUT
echo "Version: ${GITHUB_REF#refs/tags/}"
- name: Build binaries for multiple platforms
run: |
mkdir -p dist
# Linux AMD64
GOOS=linux GOARCH=amd64 go build -o dist/relspec-linux-amd64 -ldflags "-X 'main.version=${{ steps.get_version.outputs.VERSION }}' -X 'main.buildDate=${{ steps.get_version.outputs.BUILD_DATE }}'" ./cmd/relspec
# Linux ARM64
GOOS=linux GOARCH=arm64 go build -o dist/relspec-linux-arm64 -ldflags "-X 'main.version=${{ steps.get_version.outputs.VERSION }}' -X 'main.buildDate=${{ steps.get_version.outputs.BUILD_DATE }}'" ./cmd/relspec
# macOS AMD64
GOOS=darwin GOARCH=amd64 go build -o dist/relspec-darwin-amd64 -ldflags "-X 'main.version=${{ steps.get_version.outputs.VERSION }}' -X 'main.buildDate=${{ steps.get_version.outputs.BUILD_DATE }}'" ./cmd/relspec
# macOS ARM64 (Apple Silicon)
GOOS=darwin GOARCH=arm64 go build -o dist/relspec-darwin-arm64 -ldflags "-X 'main.version=${{ steps.get_version.outputs.VERSION }}' -X 'main.buildDate=${{ steps.get_version.outputs.BUILD_DATE }}'" ./cmd/relspec
# Windows AMD64
GOOS=windows GOARCH=amd64 go build -o dist/relspec-windows-amd64.exe -ldflags "-X 'main.version=${{ steps.get_version.outputs.VERSION }}' -X 'main.buildDate=${{ steps.get_version.outputs.BUILD_DATE }}'" ./cmd/relspec
# Create checksums
cd dist
sha256sum * > checksums.txt
cd ..
- name: Generate release notes
id: release_notes
run: |
# Get the previous tag
previous_tag=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
if [ -z "$previous_tag" ]; then
# No previous tag, get all commits
commits=$(git log --pretty=format:"- %s (%h)" --no-merges)
else
# Get commits since the previous tag
commits=$(git log "${previous_tag}..HEAD" --pretty=format:"- %s (%h)" --no-merges)
fi
# Create release notes
cat > release_notes.md << EOF
# Release ${{ steps.get_version.outputs.VERSION }}
## Changes
${commits}
## Installation
Download the appropriate binary for your platform:
- **Linux (AMD64)**: \`relspec-linux-amd64\`
- **Linux (ARM64)**: \`relspec-linux-arm64\`
- **macOS (Intel)**: \`relspec-darwin-amd64\`
- **macOS (Apple Silicon)**: \`relspec-darwin-arm64\`
- **Windows (AMD64)**: \`relspec-windows-amd64.exe\`
Make the binary executable (Linux/macOS):
\`\`\`bash
chmod +x relspec-*
\`\`\`
Verify the download with the provided checksums.
EOF
- name: Create Release
uses: softprops/action-gh-release@v1
with:
body_path: release_notes.md
files: |
dist/relspec-linux-amd64
dist/relspec-linux-arm64
dist/relspec-darwin-amd64
dist/relspec-darwin-arm64
dist/relspec-windows-amd64.exe
dist/checksums.txt
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Summary
run: |
echo "Release ${{ steps.get_version.outputs.VERSION }} created successfully!"
echo "Binaries built for:"
echo " - Linux (amd64, arm64)"
echo " - macOS (amd64, arm64)"
echo " - Windows (amd64)"
+15 -24
View File
@@ -204,30 +204,21 @@ release: ## Create and push a new release tag (auto-increments patch version)
git push origin "$$version"; \
echo "Tag $$version created and pushed to remote repository."
release-version: ## Create and push a release with specific version (use: make release-version VERSION=v1.2.3)
@if [ -z "$(VERSION)" ]; then \
echo "Error: VERSION is required. Usage: make release-version VERSION=v1.2.3"; \
exit 1; \
fi
@version="$(VERSION)"; \
if ! echo "$$version" | grep -q "^v"; then \
version="v$$version"; \
fi; \
echo "Creating release: $$version"; \
latest_tag=$$(git describe --tags --abbrev=0 2>/dev/null || echo ""); \
if [ -z "$$latest_tag" ]; then \
commit_logs=$$(git log --pretty=format:"- %s" --no-merges); \
else \
commit_logs=$$(git log "$${latest_tag}..HEAD" --pretty=format:"- %s" --no-merges); \
fi; \
if [ -z "$$commit_logs" ]; then \
tag_message="Release $$version"; \
else \
tag_message="Release $$version\n\n$$commit_logs"; \
fi; \
git tag -a "$$version" -m "$$tag_message"; \
git push origin "$$version"; \
echo "Tag $$version created and pushed to remote repository."
release-version: ## Auto-increment patch version, update package files, commit, tag, and push
@CURRENT=$$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0"); \
MAJOR=$$(echo $$CURRENT | sed 's/v\([0-9]*\)\.\([0-9]*\)\.\([0-9]*\).*/\1/'); \
MINOR=$$(echo $$CURRENT | sed 's/v\([0-9]*\)\.\([0-9]*\)\.\([0-9]*\).*/\2/'); \
PATCH=$$(echo $$CURRENT | sed 's/v\([0-9]*\)\.\([0-9]*\)\.\([0-9]*\).*/\3/'); \
NEXT="v$$MAJOR.$$MINOR.$$((PATCH + 1))"; \
PKGVER="$$MAJOR.$$MINOR.$$((PATCH + 1))"; \
echo "Current: $$CURRENT → Next: $$NEXT"; \
sed -i "s/^pkgver=.*/pkgver=$$PKGVER/" linux/arch/PKGBUILD; \
sed -i "s/^Version:.*/Version: $$PKGVER/" linux/centos/relspec.spec; \
git add linux/arch/PKGBUILD linux/centos/relspec.spec; \
git commit -m "chore(release): update package version to $$PKGVER"; \
git tag -a "$$NEXT" -m "Release $$NEXT"; \
git push origin HEAD "$$NEXT"; \
echo "Pushed $$NEXT — release workflow triggered"
help: ## Display this help screen
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
+123 -209
View File
@@ -6,264 +6,178 @@
[![Go Version](https://img.shields.io/badge/go-1.24.0-blue.svg)](https://go.dev/dl/)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](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.
![RelSpec](./assets/image/relspec1_512.jpg)
## 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
![1.00](./assets/image/relspec1_512.jpg)
## 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
- [SQLite](pkg/readers/sqlite/README.md) - Direct SQLite 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.)
- [SQLite](pkg/writers/sqlite/README.md) - SQLite DDL with automatic schema flattening
#### 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
# Convert PostgreSQL database to SQLite (with automatic schema flattening)
relspec convert --from pgsql --from-conn "postgres://..." \
--to sqlite --to-path sqlite_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
+219
View File
@@ -0,0 +1,219 @@
# From Scripts to RelSpec: What Years of Database Pain Taught Me
It started as a need.
A problem Ive 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 wasnt just another generator anymore.
It became a system for managing *database truth*.
---
## Lessons Learned (The Hard Way)
This journey wasnt 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. Its Not Migration—Its Adaptation
Youre not just moving data. Youre 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 cant track changes, you cant trust your system.
### 11. Manage Database Functions Properly
They are part of your system—not an afterthought.
### 12. If Its Hard to Understand, Its 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 wasnt 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*.
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

+30 -30
View File
@@ -43,15 +43,17 @@ import (
)
var (
convertSourceType string
convertSourcePath string
convertSourceConn string
convertFromList []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{
@@ -175,6 +177,8 @@ func init() {
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 {
@@ -241,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)
}
@@ -284,79 +288,79 @@ 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(&readers.ReaderOptions{ConnectionString: connString})
reader = mssql.NewReader(newReaderOptions("", connString))
case "sqlite", "sqlite3":
// SQLite can use either file path or connection string
@@ -367,7 +371,7 @@ func readDatabaseForConvert(dbType, filePath, connString string) (*models.Databa
if dbPath == "" {
return nil, fmt.Errorf("file path or connection string is required for SQLite format")
}
reader = sqlite.NewReader(&readers.ReaderOptions{FilePath: dbPath})
reader = sqlite.NewReader(newReaderOptions(dbPath, ""))
default:
return nil, fmt.Errorf("unsupported source format: %s", dbType)
@@ -381,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":
+26 -26
View File
@@ -240,62 +240,62 @@ 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
@@ -305,7 +305,7 @@ func readDatabaseForEdit(dbType, filePath, connString, label string) (*models.Da
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})
reader = sqlite.NewReader(newReaderOptions(dbPath, ""))
default:
return nil, fmt.Errorf("%s: unsupported format: %s", label, dbType)
}
@@ -323,31 +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(&writers.WriterOptions{OutputPath: filePath})
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)
}
+13 -13
View File
@@ -221,73 +221,73 @@ 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
@@ -298,7 +298,7 @@ func readDatabaseForInspect(dbType, filePath, connString string) (*models.Databa
if dbPath == "" {
return nil, fmt.Errorf("file path or connection string is required for SQLite format")
}
reader = sqlite.NewReader(&readers.ReaderOptions{FilePath: dbPath})
reader = sqlite.NewReader(newReaderOptions(dbPath, ""))
default:
return nil, fmt.Errorf("unsupported database type: %s", dbType)
+31 -26
View File
@@ -258,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)
@@ -284,62 +289,62 @@ 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
@@ -349,7 +354,7 @@ func readDatabaseForMerge(dbType, filePath, connString, label string) (*models.D
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})
reader = sqlite.NewReader(newReaderOptions(dbPath, ""))
default:
return nil, fmt.Errorf("%s: unsupported format '%s'", label, dbType)
}
@@ -370,61 +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(&writers.WriterOptions{OutputPath: filePath, FlattenSchema: flattenSchema})
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,
+36
View File
@@ -3,6 +3,7 @@ package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
@@ -160,3 +161,38 @@ func TestRunMerge_FromListMissingSourceType(t *testing.T) {
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)
}
}
+25
View File
@@ -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,
}
}
+2
View File
@@ -12,6 +12,7 @@ var (
// Version information, set via ldflags during build
version = "dev"
buildDate = "unknown"
prisma7 bool
)
func init() {
@@ -68,4 +69,5 @@ func init() {
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")
}
+4
View File
@@ -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)
+34
View File
@@ -71,6 +71,40 @@ func writeTestJSON(t *testing.T, path string, tableNames []string) {
}
}
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
+11 -11
View File
@@ -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"`
+20 -20
View File
@@ -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"`
+12 -12
View File
@@ -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"`
+8 -8
View File
@@ -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"`
+14 -15
View File
@@ -1,19 +1,19 @@
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/microsoft/go-mssqldb v1.9.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.31.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.44.3
modernc.org/sqlite v1.50.1
)
require (
@@ -27,9 +27,8 @@ require (
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-isatty v0.0.20 // 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
@@ -41,11 +40,11 @@ require (
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.45.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/term v0.37.0 // indirect
modernc.org/libc v1.67.6 // 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
)
+56 -95
View File
@@ -1,15 +1,15 @@
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 h1:Wgf5rZba3YZqeTNJPtvqZoBu1sBN/L4sry+u2U3Y75w=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:xxCBG/f/4Vbmh2XQJBsOmNdxWUY5j/s27jujKPbQf14=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
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=
@@ -19,15 +19,14 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
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/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
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/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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=
@@ -40,8 +39,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
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=
@@ -52,14 +51,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
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.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
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/microsoft/go-mssqldb v1.9.6 h1:1MNQg5UiSsokiPz3++K2KPx4moKrwIqly1wv+RyCKTw=
github.com/microsoft/go-mssqldb v1.9.6/go.mod h1:yYMPDufyoF2vVuVCUGtZARr06DKFIhMrluTcgWlXpr4=
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=
@@ -73,8 +70,6 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
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=
@@ -95,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=
@@ -105,83 +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.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
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.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
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.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
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.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
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.6.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.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
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.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
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.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
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=
@@ -189,30 +150,30 @@ 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.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
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.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
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.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
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.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
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.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
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=
+35
View File
@@ -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"
}
+43
View File
@@ -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
+11
View File
@@ -0,0 +1,11 @@
Package: relspec
Version: VERSION
Architecture: ARCH
Maintainer: Hein (Warky Devs) <hein@warky.dev>
Section: database
Priority: optional
Homepage: https://git.warky.dev/wdevs/relspecgo
Description: Database schema conversion and analysis tool
RelSpec provides bidirectional conversion between various database schema
formats including PostgreSQL, MySQL, SQLite, Prisma, TypeORM, GORM, Drizzle,
DBML, GraphQL, and more.
+156
View File
@@ -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
View File
@@ -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
}
+62
View File
@@ -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) {
+94 -50
View File
@@ -2,32 +2,73 @@ package mssql
import "strings"
// CanonicalToMSSQLTypes maps canonical types to MSSQL types
// 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{
"bool": "BIT",
"int8": "TINYINT",
"int16": "SMALLINT",
"int": "INT",
"int32": "INT",
"int64": "BIGINT",
"uint": "BIGINT",
"uint8": "SMALLINT",
"uint16": "INT",
"uint32": "BIGINT",
"uint64": "BIGINT",
"float32": "REAL",
"float64": "FLOAT",
"decimal": "NUMERIC",
"string": "NVARCHAR(255)",
"text": "NVARCHAR(MAX)",
// 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",
"uuid": "UNIQUEIDENTIFIER",
"json": "NVARCHAR(MAX)",
"jsonb": "NVARCHAR(MAX)",
"bytea": "VARBINARY(MAX)",
"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
@@ -68,47 +109,50 @@ var MSSQLToCanonicalTypes = map[string]string{
"geometry": "string",
}
// ConvertCanonicalToMSSQL converts a canonical type to MSSQL type
// 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 {
// Check direct mapping
if mssqlType, exists := CanonicalToMSSQLTypes[strings.ToLower(canonicalType)]; exists {
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
}
// Try to find by prefix
lowerType := strings.ToLower(canonicalType)
for canonical, mssql := range CanonicalToMSSQLTypes {
if strings.HasPrefix(lowerType, canonical) {
return mssql
}
}
// Default to NVARCHAR
return "NVARCHAR(255)"
}
// ConvertMSSQLToCanonical converts an MSSQL type to canonical type
// 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 {
// Extract base type (remove parentheses and parameters)
baseType := mssqlType
if idx := strings.Index(baseType, "("); idx != -1 {
baseType = baseType[:idx]
base := strings.ToLower(strings.TrimSpace(mssqlType))
if idx := strings.Index(base, "("); idx >= 0 {
base = strings.TrimSpace(base[:idx])
}
baseType = strings.TrimSpace(baseType)
// Check direct mapping
if canonicalType, exists := MSSQLToCanonicalTypes[strings.ToLower(baseType)]; exists {
if canonicalType, exists := MSSQLToCanonicalTypes[base]; exists {
return canonicalType
}
// Try to find by prefix
lowerType := strings.ToLower(baseType)
for mssql, canonical := range MSSQLToCanonicalTypes {
if strings.HasPrefix(lowerType, mssql) {
return canonical
}
}
// Default to string
return "string"
}
+85
View File
@@ -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"
}
+53
View File
@@ -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")
}
}
+58
View File
@@ -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) {
+348
View File
@@ -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)), " ")
}
+147
View File
@@ -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])
}
}
})
}
}
+13 -6
View File
@@ -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")
}
}
+35 -5
View File
@@ -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
View File
@@ -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:"))
+61
View File
@@ -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 {
+40 -1
View File
@@ -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 {
+52
View File
@@ -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)
}
})
}
}
+17 -11
View File
@@ -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
}
}
}
}
+56
View File
@@ -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
View File
@@ -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
}
+32 -2
View File
@@ -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"),
+4
View File
@@ -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
+51 -5
View File
@@ -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,12 +232,12 @@ 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
}
@@ -241,12 +252,12 @@ func (r *Reader) queryColumns(schemaName string) (map[string]map[string]*models.
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, hasNextval)
column.Type = r.mapDataType(dataType, udtName, formattedDataType, hasNextval)
column.NotNull = (isNullable == "NO")
column.Sequence = uint(ordinalPosition)
@@ -259,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 {
@@ -602,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()
}
+39 -57
View File
@@ -244,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
}
@@ -259,12 +259,16 @@ func (r *Reader) close() {
}
}
// mapDataType maps PostgreSQL data types to canonical types
func (r *Reader) mapDataType(pgType, udtName string, hasNextval bool) string {
// If the column has a nextval default, it's likely a serial type
// Map to the appropriate serial type instead of the base integer type
// 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 strings.ToLower(pgType) {
switch normalizedPGType {
case "integer", "int", "int4":
return "serial"
case "bigint", "int8":
@@ -274,67 +278,45 @@ func (r *Reader) mapDataType(pgType, udtName string, hasNextval bool) string {
}
}
// Map common PostgreSQL types
typeMap := map[string]string{
"integer": "integer",
"bigint": "bigint",
"smallint": "smallint",
"int": "integer",
"int2": "smallint",
"int4": "integer",
"int8": "bigint",
"serial": "serial",
"bigserial": "bigserial",
"smallserial": "smallserial",
"numeric": "numeric",
"decimal": "decimal",
"real": "real",
"double precision": "double precision",
"float4": "real",
"float8": "double precision",
"money": "money",
"character varying": "varchar",
"varchar": "varchar",
"character": "char",
"char": "char",
"text": "text",
"boolean": "boolean",
"bool": "boolean",
"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",
// 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:] + "[]"
}
// Try mapped type first
if mapped, exists := typeMap[pgType]; exists {
return mapped
// 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
}
// Use pgsql utilities if available
if pgsql.ValidSQLType(pgType) {
return pgsql.GetSQLType(pgType)
// 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
// 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
}
+71 -67
View File
@@ -173,35 +173,39 @@ 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", "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
{"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, false)
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)
}
})
}
@@ -218,9 +222,9 @@ func TestMapDataType(t *testing.T) {
for _, tt := range serialTests {
t.Run(tt.pgType+"_with_nextval", func(t *testing.T) {
result := reader.mapDataType(tt.pgType, "", true)
result := reader.mapDataType(tt.pgType, "", "", true)
if result != tt.expected {
t.Errorf("mapDataType(%s, '', true) = %s, expected %s", tt.pgType, result, tt.expected)
t.Errorf("mapDataType(%s, '', '', true) = %s, expected %s", tt.pgType, result, tt.expected)
}
})
}
@@ -230,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,
},
}
+26 -1
View File
@@ -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+)"?`)
+77
View File
@@ -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)
}
}
+3
View File
@@ -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{}
}
+3 -52
View File
@@ -10,6 +10,7 @@ import (
"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
@@ -183,59 +184,9 @@ func (r *Reader) close() {
}
}
// mapDataType maps SQLite data types to canonical types
// mapDataType maps SQLite data types to canonical types.
func (r *Reader) mapDataType(sqliteType string) string {
// SQLite has a flexible type system, but we map common types
typeMap := map[string]string{
"INTEGER": "int",
"INT": "int",
"TINYINT": "int8",
"SMALLINT": "int16",
"MEDIUMINT": "int",
"BIGINT": "int64",
"UNSIGNED BIG INT": "uint64",
"INT2": "int16",
"INT8": "int64",
"REAL": "float64",
"DOUBLE": "float64",
"DOUBLE PRECISION": "float64",
"FLOAT": "float32",
"NUMERIC": "decimal",
"DECIMAL": "decimal",
"BOOLEAN": "bool",
"BOOL": "bool",
"DATE": "date",
"DATETIME": "timestamp",
"TIMESTAMP": "timestamp",
"TEXT": "string",
"VARCHAR": "string",
"CHAR": "string",
"CHARACTER": "string",
"VARYING CHARACTER": "string",
"NCHAR": "string",
"NVARCHAR": "string",
"CLOB": "text",
"BLOB": "bytea",
}
// Try exact match first
if mapped, exists := typeMap[sqliteType]; exists {
return mapped
}
// Try case-insensitive match for common types
sqliteTypeUpper := sqliteType
if len(sqliteType) > 0 {
// Extract base type (e.g., "VARCHAR(255)" -> "VARCHAR")
for baseType := range typeMap {
if len(sqliteTypeUpper) >= len(baseType) && sqliteTypeUpper[:len(baseType)] == baseType {
return typeMap[baseType]
}
}
}
// Default to string for unknown types
return "string"
return sqlitepkg.ConvertSQLiteToCanonical(sqliteType)
}
// deriveRelationship creates a relationship from a foreign key constraint
+37
View File
@@ -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
}
+60
View File
@@ -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)
}
})
}
}
+1 -1
View File
@@ -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
}
+152
View File
@@ -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"
}
+136
View File
@@ -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
+485
View File
@@ -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")
}
})
}
+981
View File
@@ -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}}
}
+134
View File
@@ -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)
}
})
}
+958
View File
@@ -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)
}
}
+678
View File
@@ -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)
}
}
+164
View File
@@ -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)
}
}
+180
View File
@@ -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)
}
+101 -37
View File
@@ -6,6 +6,8 @@ Generates Go source files with Bun model definitions from database schema inform
The Bun Writer converts RelSpec's internal database model representation into Go source code with Bun struct definitions, complete with proper tags, relationships, and table configuration.
With `--types sqltypes`, nullable fields use the [`pkg/sqltypes`](../../sqltypes/README.md) package.
## Features
- Generates Bun-compatible Go structs
@@ -46,54 +48,67 @@ func main() {
### CLI Examples
```bash
# Generate Bun models from PostgreSQL database
relspec --input pgsql \
--conn "postgres://localhost/mydb" \
--output bun \
--out-file models.go \
--package models
# Generate Bun models from a DBML schema (default: baselib pointer types)
relspec convert --from dbml --from-path schema.dbml \
--to bun --to-path models.go --package models
# Convert GORM models to Bun
relspec --input gorm --in-file gorm_models.go --output bun --out-file bun_models.go
# Use standard library database/sql nullable types instead
relspec convert --from dbml --from-path schema.dbml \
--to bun --to-path models.go --package models \
--types stdlib
# Multi-file output
relspec --input json --in-file schema.json --output bun --out-file models/
# Select sqltypes package types (git.warky.dev/wdevs/relspecgo/pkg/sqltypes)
relspec convert --from pgsql --from-conn "postgres://localhost/mydb" \
--to bun --to-path models.go --package models \
--types sqltypes
# Multi-file output (one file per table)
relspec convert --from json --from-path schema.json \
--to bun --to-path models/ --package models
```
## Generated Code Example
## Generated Code Examples
### sqltypes package types (`--types sqltypes`)
```go
package models
import (
"time"
"database/sql"
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun"
)
type User struct {
bun.BaseModel `bun:"table:users,alias:u"`
ID int64 `bun:"id,pk,autoincrement" json:"id"`
Username string `bun:"username,notnull,unique" json:"username"`
Email string `bun:"email,notnull" json:"email"`
Bio sql.NullString `bun:"bio" json:"bio,omitempty"`
CreatedAt time.Time `bun:"created_at,notnull,default:now()" json:"created_at"`
// Relationships
Posts []*Post `bun:"rel:has-many,join:id=user_id" json:"posts,omitempty"`
ID int64 `bun:"id,type:uuid,pk," json:"id"`
Username string `bun:"username,type:text,notnull," json:"username"`
Email sql_types.SqlString `bun:"email,type:text,nullzero," json:"email"`
Tags sql_types.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
}
```
type Post struct {
bun.BaseModel `bun:"table:posts,alias:p"`
### Standard library — `--types stdlib`
ID int64 `bun:"id,pk" json:"id"`
UserID int64 `bun:"user_id,notnull" json:"user_id"`
Title string `bun:"title,notnull" json:"title"`
Content sql.NullString `bun:"content" json:"content,omitempty"`
```go
package models
// Belongs to
User *User `bun:"rel:belongs-to,join:user_id=id" json:"user,omitempty"`
import (
"database/sql"
"time"
"github.com/uptrace/bun"
)
type User struct {
bun.BaseModel `bun:"table:users,alias:u"`
ID string `bun:"id,type:uuid,pk," json:"id"`
Username string `bun:"username,type:text,notnull," json:"username"`
Email sql.NullString `bun:"email,type:text,nullzero," json:"email"`
Tags []string `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
CreatedAt time.Time `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
}
```
@@ -111,19 +126,68 @@ type Post struct {
## Type Mapping
| SQL Type | Go Type | Nullable Type |
|----------|---------|---------------|
| bigint | int64 | sql.NullInt64 |
| integer | int | sql.NullInt32 |
| varchar, text | string | sql.NullString |
| boolean | bool | sql.NullBool |
| timestamp | time.Time | sql.NullTime |
| numeric | float64 | sql.NullFloat64 |
The nullable type package is selected with `--types` (or `WriterOptions.NullableTypes`).
| SQL Type | NOT NULL (both) | Nullable — sqltypes | Nullable — stdlib |
|---|---|---|---|
| `bigint` | `int64` | `SqlInt64` | `sql.NullInt64` |
| `integer` | `int32` | `SqlInt32` | `sql.NullInt32` |
| `smallint` | `int16` | `SqlInt16` | `sql.NullInt16` |
| `text`, `varchar` | `string` | `SqlString` | `sql.NullString` |
| `boolean` | `bool` | `SqlBool` | `sql.NullBool` |
| `timestamp`, `timestamptz` | `time.Time`* | `SqlTimeStamp` | `sql.NullTime` |
| `numeric`, `decimal` | `float64` | `SqlFloat64` | `sql.NullFloat64` |
| `uuid` | `string` | `SqlUUID` | `sql.NullString` |
| `jsonb` | `string` | `SqlJSONB` | `sql.NullString` |
| `text[]` | `SqlStringArray` | `SqlStringArray` | `[]string` |
| `integer[]` | `SqlInt32Array` | `SqlInt32Array` | `[]int32` |
| `uuid[]` | `SqlUUIDArray` | `SqlUUIDArray` | `[]string` |
| `vector` | `SqlVector` | `SqlVector` | `[]float32` |
\* In sqltypes mode, NOT NULL timestamps use `SqlTimeStamp` (not `time.Time`) unless the base type is a simple integer or boolean. In stdlib mode, NOT NULL timestamps use `time.Time`.
## Writer Options
### NullableTypes
Controls which Go package is used for nullable column types. Set via the `--types` CLI flag or `WriterOptions.NullableTypes`:
```go
// Use sqltypes package types
options := &writers.WriterOptions{
OutputPath: "models.go",
PackageName: "models",
NullableTypes: writers.NullableTypeSqlTypes,
}
// Use standard library database/sql types
options := &writers.WriterOptions{
OutputPath: "models.go",
PackageName: "models",
NullableTypes: writers.NullableTypeStdlib,
}
```
### Metadata Options
```go
options := &writers.WriterOptions{
OutputPath: "models.go",
PackageName: "models",
Metadata: map[string]any{
"multi_file": true, // Enable multi-file mode
"populate_refs": true, // Populate RefDatabase/RefSchema
"generate_get_id_str": true, // Generate GetIDStr() methods
},
}
```
## Notes
- Model names are derived from table names (singularized, PascalCase)
- Table aliases are auto-generated from table names
- Nullable columns use plain Go pointer types (`*string`, `*time.Time`, …) by default; pass `--types sqltypes` to use `sql_types.SqlString`, `sql_types.SqlTimeStamp`, etc., or `--types stdlib` to use `sql.NullString`, `sql.NullTime`, etc.
- Array columns use plain Go slices (`[]string`, `[]int32`, …) by default; `--types sqltypes` uses `sql_types.SqlStringArray`, `sql_types.SqlInt32Array`, etc.
- Multi-file mode: one file per table named `sql_{schema}_{table}.go`
- Generated code is auto-formatted
- JSON tags are automatically added
+31 -13
View File
@@ -18,17 +18,20 @@ type TemplateData struct {
// ModelData represents a single model/struct in the template
type ModelData struct {
Name string
TableName string // schema.table format
SchemaName string
TableNameOnly string // just table name without schema
Comment string
Fields []*FieldData
Config *MethodConfig
PrimaryKeyField string // Name of the primary key field
PrimaryKeyIsSQL bool // Whether PK uses SQL type (needs .Int64() call)
IDColumnName string // Name of the ID column in database
Prefix string // 3-letter prefix
Name string
TableName string // schema.table format
SchemaName string
TableNameOnly string // just table name without schema
Comment string
Fields []*FieldData
Config *MethodConfig
PrimaryKeyField string // Name of the primary key field
PrimaryKeyType string // Go type of the primary key field
PrimaryKeyIsSQL bool // Whether PK uses SQL type (needs .Int64() call)
PrimaryKeyIsStr bool // Whether helper methods should use string IDs
PrimaryKeyIDType string // Helper method GetID/SetID/UpdateID type
IDColumnName string // Name of the ID column in database
Prefix string // 3-letter prefix
}
// FieldData represents a single field in a struct
@@ -138,9 +141,15 @@ func NewModelData(table *models.Table, schema string, typeMapper *TypeMapper, fl
safeName := writers.SanitizeStructTagValue(col.Name)
model.PrimaryKeyField = SnakeCaseToPascalCase(safeName)
model.IDColumnName = safeName
// Check if PK type is a SQL type (contains resolvespec_common or sql_types)
// Check if PK type is a SQL type (contains sql_types)
goType := typeMapper.SQLTypeToGoType(col.Type, col.NotNull)
model.PrimaryKeyIsSQL = strings.Contains(goType, "resolvespec_common") || strings.Contains(goType, "sql_types")
model.PrimaryKeyType = goType
model.PrimaryKeyIsSQL = strings.Contains(goType, "sql_types")
model.PrimaryKeyIsStr = isStringLikePrimaryKeyType(goType)
model.PrimaryKeyIDType = "int64"
if model.PrimaryKeyIsStr {
model.PrimaryKeyIDType = "string"
}
break
}
}
@@ -192,6 +201,15 @@ func formatComment(description, comment string) string {
return comment
}
func isStringLikePrimaryKeyType(goType string) bool {
switch goType {
case "string", "sql.NullString", "sql_types.SqlString", "sql_types.SqlUUID":
return true
default:
return false
}
}
// resolveFieldNameCollision checks if a field name conflicts with generated method names
// and adds an underscore suffix if there's a collision
func resolveFieldNameCollision(fieldName string) string {
+27 -5
View File
@@ -44,33 +44,55 @@ func (m {{.Name}}) SchemaName() string {
{{end}}
{{if and .Config.GenerateGetID .PrimaryKeyField}}
// GetID returns the primary key value
func (m {{.Name}}) GetID() int64 {
func (m {{.Name}}) GetID() {{.PrimaryKeyIDType}} {
{{if .PrimaryKeyIsSQL -}}
{{if .PrimaryKeyIsStr -}}
return m.{{.PrimaryKeyField}}.String()
{{- else -}}
return m.{{.PrimaryKeyField}}.Int64()
{{- end}}
{{- else -}}
{{if .PrimaryKeyIsStr -}}
return m.{{.PrimaryKeyField}}
{{- else -}}
return int64(m.{{.PrimaryKeyField}})
{{- end}}
{{- end}}
}
{{end}}
{{if and .Config.GenerateGetIDStr .PrimaryKeyField}}
// GetIDStr returns the primary key as a string
func (m {{.Name}}) GetIDStr() string {
{{if .PrimaryKeyIsSQL -}}
return m.{{.PrimaryKeyField}}.String()
{{- else if .PrimaryKeyIsStr -}}
return m.{{.PrimaryKeyField}}
{{- else -}}
return fmt.Sprintf("%d", m.{{.PrimaryKeyField}})
{{- end}}
}
{{end}}
{{if and .Config.GenerateSetID .PrimaryKeyField}}
// SetID sets the primary key value
func (m {{.Name}}) SetID(newid int64) {
func (m {{.Name}}) SetID(newid {{.PrimaryKeyIDType}}) {
m.UpdateID(newid)
}
{{end}}
{{if and .Config.GenerateUpdateID .PrimaryKeyField}}
// UpdateID updates the primary key value
func (m *{{.Name}}) UpdateID(newid int64) {
func (m *{{.Name}}) UpdateID(newid {{.PrimaryKeyIDType}}) {
{{if .PrimaryKeyIsSQL -}}
m.{{.PrimaryKeyField}}.FromString(fmt.Sprintf("%d", newid))
{{if .PrimaryKeyIsStr -}}
m.{{.PrimaryKeyField}}.FromString(newid)
{{- else -}}
m.{{.PrimaryKeyField}} = int32(newid)
m.{{.PrimaryKeyField}}.FromString(fmt.Sprintf("%d", newid))
{{- end}}
{{- else -}}
{{if .PrimaryKeyIsStr -}}
m.{{.PrimaryKeyField}} = newid
{{- else -}}
m.{{.PrimaryKeyField}} = {{.PrimaryKeyType}}(newid)
{{- end}}
{{- end}}
}
{{end}}
+207 -25
View File
@@ -5,48 +5,61 @@ import (
"strings"
"git.warky.dev/wdevs/relspecgo/pkg/models"
"git.warky.dev/wdevs/relspecgo/pkg/pgsql"
"git.warky.dev/wdevs/relspecgo/pkg/writers"
)
// TypeMapper handles type conversions between SQL and Go types for Bun
type TypeMapper struct {
// Package alias for sql_types import
sqlTypesAlias string
typeStyle string // writers.NullableTypeSqlTypes | writers.NullableTypeStdlib | writers.NullableTypeBaselib
}
// NewTypeMapper creates a new TypeMapper with default settings
func NewTypeMapper() *TypeMapper {
// NewTypeMapper creates a new TypeMapper.
// typeStyle should be writers.NullableTypeSqlTypes, writers.NullableTypeStdlib, or
// writers.NullableTypeBaselib; an empty string defaults to baselib.
func NewTypeMapper(typeStyle string) *TypeMapper {
if typeStyle == "" {
typeStyle = writers.NullableTypeBaselib
}
return &TypeMapper{
sqlTypesAlias: "resolvespec_common",
sqlTypesAlias: "sql_types",
typeStyle: typeStyle,
}
}
// SQLTypeToGoType converts a SQL type to its Go equivalent
// Uses ResolveSpec common package types (all are nullable by default in Bun)
// SQLTypeToGoType converts a SQL type to its Go equivalent.
func (tm *TypeMapper) SQLTypeToGoType(sqlType string, notNull bool) string {
// Normalize SQL type (lowercase, remove length/precision)
// Array types are handled separately for both styles.
if pgsql.IsArrayType(sqlType) {
return tm.arrayGoType(tm.extractBaseType(sqlType))
}
baseType := tm.extractBaseType(sqlType)
// For Bun, we typically use resolvespec_common types for most fields
// unless they're explicitly NOT NULL and we want to avoid null handling
switch tm.typeStyle {
case writers.NullableTypeStdlib:
if notNull {
return tm.rawGoType(baseType)
}
return tm.stdlibNullableGoType(baseType)
case writers.NullableTypeBaselib:
if notNull {
return tm.rawGoType(baseType)
}
return tm.baselibNullableGoType(baseType)
}
// sqltypes: use base Go types only for simple NOT NULL fields.
if notNull && tm.isSimpleType(baseType) {
return tm.baseGoType(baseType)
}
// Use resolvespec_common types for nullable fields
return tm.bunGoType(baseType)
}
// extractBaseType extracts the base type from a SQL type string
func (tm *TypeMapper) extractBaseType(sqlType string) string {
sqlType = strings.ToLower(strings.TrimSpace(sqlType))
// Remove everything after '('
if idx := strings.Index(sqlType, "("); idx > 0 {
sqlType = sqlType[:idx]
}
return sqlType
return pgsql.CanonicalizeBaseType(pgsql.ExtractBaseTypeLower(sqlType))
}
// isSimpleType checks if a type should use base Go type when NOT NULL
@@ -93,11 +106,11 @@ func (tm *TypeMapper) baseGoType(sqlType string) string {
return goType
}
// Default to resolvespec type
// Default to sqltypes type
return tm.bunGoType(sqlType)
}
// bunGoType returns the Bun/ResolveSpec common type
// bunGoType returns the Bun/sqltypes common type
func (tm *TypeMapper) bunGoType(sqlType string) string {
typeMap := map[string]string{
// Integer types
@@ -160,6 +173,9 @@ func (tm *TypeMapper) bunGoType(sqlType string) string {
// Other
"money": tm.sqlTypesAlias + ".SqlFloat64",
// pgvector
"vector": tm.sqlTypesAlias + ".SqlVector",
}
if goType, ok := typeMap[sqlType]; ok {
@@ -170,6 +186,155 @@ func (tm *TypeMapper) bunGoType(sqlType string) string {
return tm.sqlTypesAlias + ".SqlString"
}
// arrayGoType returns the Go type for a PostgreSQL array column.
// The baseElemType is the canonical base type (e.g. "text", "integer").
func (tm *TypeMapper) arrayGoType(baseElemType string) string {
if tm.typeStyle == writers.NullableTypeStdlib || tm.typeStyle == writers.NullableTypeBaselib {
return tm.stdlibArrayGoType(baseElemType)
}
typeMap := map[string]string{
"text": tm.sqlTypesAlias + ".SqlStringArray", "varchar": tm.sqlTypesAlias + ".SqlStringArray",
"char": tm.sqlTypesAlias + ".SqlStringArray", "character": tm.sqlTypesAlias + ".SqlStringArray",
"citext": tm.sqlTypesAlias + ".SqlStringArray", "bpchar": tm.sqlTypesAlias + ".SqlStringArray",
"inet": tm.sqlTypesAlias + ".SqlStringArray", "cidr": tm.sqlTypesAlias + ".SqlStringArray",
"macaddr": tm.sqlTypesAlias + ".SqlStringArray",
"json": tm.sqlTypesAlias + ".SqlStringArray", "jsonb": tm.sqlTypesAlias + ".SqlStringArray",
"integer": tm.sqlTypesAlias + ".SqlInt32Array", "int": tm.sqlTypesAlias + ".SqlInt32Array",
"int4": tm.sqlTypesAlias + ".SqlInt32Array", "serial": tm.sqlTypesAlias + ".SqlInt32Array",
"smallint": tm.sqlTypesAlias + ".SqlInt16Array", "int2": tm.sqlTypesAlias + ".SqlInt16Array",
"smallserial": tm.sqlTypesAlias + ".SqlInt16Array",
"bigint": tm.sqlTypesAlias + ".SqlInt64Array", "int8": tm.sqlTypesAlias + ".SqlInt64Array",
"bigserial": tm.sqlTypesAlias + ".SqlInt64Array",
"real": tm.sqlTypesAlias + ".SqlFloat32Array", "float4": tm.sqlTypesAlias + ".SqlFloat32Array",
"double precision": tm.sqlTypesAlias + ".SqlFloat64Array", "float8": tm.sqlTypesAlias + ".SqlFloat64Array",
"numeric": tm.sqlTypesAlias + ".SqlFloat64Array", "decimal": tm.sqlTypesAlias + ".SqlFloat64Array",
"money": tm.sqlTypesAlias + ".SqlFloat64Array",
"boolean": tm.sqlTypesAlias + ".SqlBoolArray", "bool": tm.sqlTypesAlias + ".SqlBoolArray",
"uuid": tm.sqlTypesAlias + ".SqlUUIDArray",
}
if goType, ok := typeMap[baseElemType]; ok {
return goType
}
return tm.sqlTypesAlias + ".SqlStringArray"
}
// rawGoType returns the plain Go type for a NOT NULL column in stdlib mode.
func (tm *TypeMapper) rawGoType(sqlType string) string {
typeMap := map[string]string{
"integer": "int32", "int": "int32", "int4": "int32", "serial": "int32",
"smallint": "int16", "int2": "int16", "smallserial": "int16",
"bigint": "int64", "int8": "int64", "bigserial": "int64",
"boolean": "bool", "bool": "bool",
"real": "float32", "float4": "float32",
"double precision": "float64", "float8": "float64",
"numeric": "float64", "decimal": "float64", "money": "float64",
"text": "string", "varchar": "string", "char": "string",
"character": "string", "citext": "string", "bpchar": "string",
"inet": "string", "cidr": "string", "macaddr": "string",
"uuid": "string", "json": "string", "jsonb": "string",
"timestamp": "time.Time",
"timestamp without time zone": "time.Time",
"timestamp with time zone": "time.Time",
"timestamptz": "time.Time",
"date": "time.Time",
"time": "time.Time",
"time without time zone": "time.Time",
"time with time zone": "time.Time",
"timetz": "time.Time",
"bytea": "[]byte",
"vector": "[]float32",
}
if goType, ok := typeMap[sqlType]; ok {
return goType
}
return "string"
}
// stdlibNullableGoType returns the database/sql nullable type for a column.
func (tm *TypeMapper) stdlibNullableGoType(sqlType string) string {
typeMap := map[string]string{
"integer": "sql.NullInt32", "int": "sql.NullInt32", "int4": "sql.NullInt32", "serial": "sql.NullInt32",
"smallint": "sql.NullInt16", "int2": "sql.NullInt16", "smallserial": "sql.NullInt16",
"bigint": "sql.NullInt64", "int8": "sql.NullInt64", "bigserial": "sql.NullInt64",
"boolean": "sql.NullBool", "bool": "sql.NullBool",
"real": "sql.NullFloat64", "float4": "sql.NullFloat64",
"double precision": "sql.NullFloat64", "float8": "sql.NullFloat64",
"numeric": "sql.NullFloat64", "decimal": "sql.NullFloat64", "money": "sql.NullFloat64",
"text": "sql.NullString", "varchar": "sql.NullString", "char": "sql.NullString",
"character": "sql.NullString", "citext": "sql.NullString", "bpchar": "sql.NullString",
"inet": "sql.NullString", "cidr": "sql.NullString", "macaddr": "sql.NullString",
"uuid": "sql.NullString", "json": "sql.NullString", "jsonb": "sql.NullString",
"timestamp": "sql.NullTime",
"timestamp without time zone": "sql.NullTime",
"timestamp with time zone": "sql.NullTime",
"timestamptz": "sql.NullTime",
"date": "sql.NullTime",
"time": "sql.NullTime",
"time without time zone": "sql.NullTime",
"time with time zone": "sql.NullTime",
"timetz": "sql.NullTime",
"bytea": "[]byte",
"vector": "[]float32",
}
if goType, ok := typeMap[sqlType]; ok {
return goType
}
return "sql.NullString"
}
// baselibNullableGoType returns plain Go pointer types for nullable columns.
func (tm *TypeMapper) baselibNullableGoType(sqlType string) string {
typeMap := map[string]string{
"integer": "*int32", "int": "*int32", "int4": "*int32", "serial": "*int32",
"smallint": "*int16", "int2": "*int16", "smallserial": "*int16",
"bigint": "*int64", "int8": "*int64", "bigserial": "*int64",
"boolean": "*bool", "bool": "*bool",
"real": "*float32", "float4": "*float32",
"double precision": "*float64", "float8": "*float64",
"numeric": "*float64", "decimal": "*float64", "money": "*float64",
"text": "*string", "varchar": "*string", "char": "*string",
"character": "*string", "citext": "*string", "bpchar": "*string",
"inet": "*string", "cidr": "*string", "macaddr": "*string",
"uuid": "*string", "json": "*string", "jsonb": "*string",
"timestamp": "*time.Time",
"timestamp without time zone": "*time.Time",
"timestamp with time zone": "*time.Time",
"timestamptz": "*time.Time",
"date": "*time.Time",
"time": "*time.Time",
"time without time zone": "*time.Time",
"time with time zone": "*time.Time",
"timetz": "*time.Time",
"bytea": "[]byte",
"vector": "[]float32",
}
if goType, ok := typeMap[sqlType]; ok {
return goType
}
return "*string"
}
// stdlibArrayGoType returns a plain Go slice type for array columns in stdlib mode.
func (tm *TypeMapper) stdlibArrayGoType(baseElemType string) string {
typeMap := map[string]string{
"text": "[]string", "varchar": "[]string", "char": "[]string",
"character": "[]string", "citext": "[]string", "bpchar": "[]string",
"inet": "[]string", "cidr": "[]string", "macaddr": "[]string",
"uuid": "[]string", "json": "[]string", "jsonb": "[]string",
"integer": "[]int32", "int": "[]int32", "int4": "[]int32", "serial": "[]int32",
"smallint": "[]int16", "int2": "[]int16", "smallserial": "[]int16",
"bigint": "[]int64", "int8": "[]int64", "bigserial": "[]int64",
"real": "[]float32", "float4": "[]float32",
"double precision": "[]float64", "float8": "[]float64",
"numeric": "[]float64", "decimal": "[]float64", "money": "[]float64",
"boolean": "[]bool", "bool": "[]bool",
}
if goType, ok := typeMap[baseElemType]; ok {
return goType
}
return "[]string"
}
// BuildBunTag generates a complete Bun tag string for a column
// Bun format: bun:"column_name,type:type_name,pk,default:value"
func (tm *TypeMapper) BuildBunTag(column *models.Column, table *models.Table) string {
@@ -184,9 +349,11 @@ func (tm *TypeMapper) BuildBunTag(column *models.Column, table *models.Table) st
if column.Type != "" {
// Sanitize type to remove backticks
typeStr := writers.SanitizeStructTagValue(column.Type)
if column.Length > 0 {
isArray := pgsql.IsArrayType(typeStr)
hasExplicitTypeModifier := pgsql.HasExplicitTypeModifier(typeStr)
if !hasExplicitTypeModifier && !isArray && column.Length > 0 {
typeStr = fmt.Sprintf("%s(%d)", typeStr, column.Length)
} else if column.Precision > 0 {
} else if !hasExplicitTypeModifier && !isArray && column.Precision > 0 {
if column.Scale > 0 {
typeStr = fmt.Sprintf("%s(%d,%d)", typeStr, column.Precision, column.Scale)
} else {
@@ -194,6 +361,9 @@ func (tm *TypeMapper) BuildBunTag(column *models.Column, table *models.Table) st
}
}
parts = append(parts, fmt.Sprintf("type:%s", typeStr))
if isArray && tm.typeStyle == writers.NullableTypeStdlib {
parts = append(parts, "array")
}
}
// Primary key
@@ -291,9 +461,21 @@ func (tm *TypeMapper) NeedsFmtImport(generateGetIDStr bool) bool {
return generateGetIDStr
}
// GetSQLTypesImport returns the import path for sql_types (ResolveSpec common)
// GetSQLTypesImport returns the import path for the sqltypes package.
func (tm *TypeMapper) GetSQLTypesImport() string {
return "github.com/bitechdev/ResolveSpec/pkg/spectypes"
return "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
}
// GetNullableTypeImportLine returns the full Go import line for the nullable type
// package (ready to pass to AddImport). Returns empty string when no import is needed.
func (tm *TypeMapper) GetNullableTypeImportLine() string {
switch tm.typeStyle {
case writers.NullableTypeStdlib:
return "\"database/sql\""
case writers.NullableTypeBaselib:
return ""
}
return fmt.Sprintf("%s \"%s\"", tm.sqlTypesAlias, tm.GetSQLTypesImport())
}
// GetBunImport returns the import path for Bun
+29 -9
View File
@@ -24,7 +24,7 @@ type Writer struct {
func NewWriter(options *writers.WriterOptions) *Writer {
w := &Writer{
options: options,
typeMapper: NewTypeMapper(),
typeMapper: NewTypeMapper(options.NullableTypes),
config: LoadMethodConfigFromMetadata(options.Metadata),
}
@@ -80,8 +80,8 @@ func (w *Writer) writeSingleFile(db *models.Database) error {
// Add bun import (always needed)
templateData.AddImport(fmt.Sprintf("\"%s\"", w.typeMapper.GetBunImport()))
// Add resolvespec_common import (always needed for nullable types)
templateData.AddImport(fmt.Sprintf("resolvespec_common \"%s\"", w.typeMapper.GetSQLTypesImport()))
// Add nullable types import (sqltypes or stdlib depending on options)
templateData.AddImport(w.typeMapper.GetNullableTypeImportLine())
// Collect all models
for _, schema := range db.Schemas {
@@ -102,8 +102,8 @@ func (w *Writer) writeSingleFile(db *models.Database) error {
}
}
// Add fmt import if GetIDStr is enabled
if w.config.GenerateGetIDStr {
// Add fmt import when generated helper methods need string formatting.
if w.needsFmtImport(templateData.Models) {
templateData.AddImport("\"fmt\"")
}
@@ -177,8 +177,8 @@ func (w *Writer) writeMultiFile(db *models.Database) error {
// Add bun import
templateData.AddImport(fmt.Sprintf("\"%s\"", w.typeMapper.GetBunImport()))
// Add resolvespec_common import
templateData.AddImport(fmt.Sprintf("resolvespec_common \"%s\"", w.typeMapper.GetSQLTypesImport()))
// Add nullable types import (sqltypes or stdlib depending on options)
templateData.AddImport(w.typeMapper.GetNullableTypeImportLine())
// Create model data
modelData := NewModelData(table, schema.Name, w.typeMapper, w.options.FlattenSchema)
@@ -195,8 +195,8 @@ func (w *Writer) writeMultiFile(db *models.Database) error {
}
}
// Add fmt import if GetIDStr is enabled
if w.config.GenerateGetIDStr {
// Add fmt import when generated helper methods need string formatting.
if w.needsFmtImport(templateData.Models) {
templateData.AddImport("\"fmt\"")
}
@@ -301,6 +301,26 @@ func (w *Writer) addRelationshipFields(modelData *ModelData, table *models.Table
}
}
func (w *Writer) needsFmtImport(models []*ModelData) bool {
if w.config.GenerateGetIDStr {
for _, model := range models {
if model.PrimaryKeyField != "" && !model.PrimaryKeyIsSQL && !model.PrimaryKeyIsStr {
return true
}
}
}
if w.config.GenerateUpdateID {
for _, model := range models {
if model.PrimaryKeyField != "" && model.PrimaryKeyIsSQL && !model.PrimaryKeyIsStr {
return true
}
}
}
return false
}
// findTable finds a table by schema and name in the database
func (w *Writer) findTable(schemaName, tableName string, db *models.Database) *models.Table {
for _, schema := range db.Schemas {
+190 -14
View File
@@ -73,9 +73,9 @@ func TestWriter_WriteTable(t *testing.T) {
"ID",
"int64",
"Email",
"resolvespec_common.SqlString",
"*string",
"CreatedAt",
"resolvespec_common.SqlTime",
"time.Time",
"bun:\"id",
"bun:\"email",
"func (m ModelPublicUsers) TableName() string",
@@ -550,13 +550,13 @@ func TestWriter_FieldNameCollision(t *testing.T) {
}
// Verify NO field named just "TableName" (without underscore)
if strings.Contains(generated, "TableName resolvespec_common") || strings.Contains(generated, "TableName string") {
if strings.Contains(generated, "TableName sql_types") || strings.Contains(generated, "TableName string") {
t.Errorf("Field 'TableName' without underscore should not exist (would conflict with method)\nGenerated:\n%s", generated)
}
}
func TestTypeMapper_SQLTypeToGoType_Bun(t *testing.T) {
mapper := NewTypeMapper()
mapper := NewTypeMapper("")
tests := []struct {
sqlType string
@@ -564,16 +564,20 @@ func TestTypeMapper_SQLTypeToGoType_Bun(t *testing.T) {
want string
}{
{"bigint", true, "int64"},
{"bigint", false, "resolvespec_common.SqlInt64"},
{"varchar", true, "resolvespec_common.SqlString"}, // Bun uses sql types even for NOT NULL strings
{"varchar", false, "resolvespec_common.SqlString"},
{"timestamp", true, "resolvespec_common.SqlTimeStamp"},
{"timestamp", false, "resolvespec_common.SqlTimeStamp"},
{"date", false, "resolvespec_common.SqlDate"},
{"bigint", false, "*int64"},
{"varchar", true, "string"},
{"varchar", false, "*string"},
{"timestamp", true, "time.Time"},
{"timestamp", false, "*time.Time"},
{"date", false, "*time.Time"},
{"boolean", true, "bool"},
{"boolean", false, "resolvespec_common.SqlBool"},
{"uuid", false, "resolvespec_common.SqlUUID"},
{"jsonb", false, "resolvespec_common.SqlJSONB"},
{"boolean", false, "*bool"},
{"uuid", false, "*string"},
{"jsonb", false, "*string"},
{"text[]", true, "[]string"},
{"text[]", false, "[]string"},
{"integer[]", true, "[]int32"},
{"bigint[]", false, "[]int64"},
}
for _, tt := range tests {
@@ -586,8 +590,118 @@ func TestTypeMapper_SQLTypeToGoType_Bun(t *testing.T) {
}
}
func TestWriter_UpdateIDTypeSafety_Bun(t *testing.T) {
tests := []struct {
name string
pkType string
expectedPK string
expectedLine string
forbidInt32 bool
}{
{"int32_pk", "int", "int32", "m.ID = int32(newid)", false},
{"sql_int16_pk", "smallint", "int16", "m.ID = int16(newid)", true},
{"int64_pk", "bigint", "int64", "m.ID = int64(newid)", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
table := models.InitTable("test_table", "public")
table.Columns["id"] = &models.Column{
Name: "id",
Type: tt.pkType,
NotNull: true,
IsPrimaryKey: true,
}
tmpDir := t.TempDir()
opts := &writers.WriterOptions{
PackageName: "models",
OutputPath: filepath.Join(tmpDir, "test.go"),
}
writer := NewWriter(opts)
err := writer.WriteTable(table)
if err != nil {
t.Fatalf("WriteTable failed: %v", err)
}
content, err := os.ReadFile(opts.OutputPath)
if err != nil {
t.Fatalf("Failed to read generated file: %v", err)
}
generated := string(content)
if !strings.Contains(generated, tt.expectedLine) {
t.Errorf("Expected UpdateID to include %s\nGenerated:\n%s", tt.expectedLine, generated)
}
if !strings.Contains(generated, "ID "+tt.expectedPK) {
t.Errorf("Expected generated primary key field type %s\nGenerated:\n%s", tt.expectedPK, generated)
}
if tt.forbidInt32 && strings.Contains(generated, "int32(newid)") {
t.Errorf("UpdateID should not cast to int32 for %s type\nGenerated:\n%s", tt.pkType, generated)
}
if !strings.Contains(generated, "UpdateID(newid int64)") {
t.Errorf("UpdateID should accept int64 parameter\nGenerated:\n%s", generated)
}
})
}
}
func TestWriter_StringPrimaryKeyHelpers_Bun(t *testing.T) {
table := models.InitTable("accounts", "public")
table.Columns["id"] = &models.Column{
Name: "id",
Type: "uuid",
NotNull: true,
IsPrimaryKey: true,
}
tmpDir := t.TempDir()
opts := &writers.WriterOptions{
PackageName: "models",
OutputPath: filepath.Join(tmpDir, "test.go"),
}
writer := NewWriter(opts)
err := writer.WriteTable(table)
if err != nil {
t.Fatalf("WriteTable failed: %v", err)
}
content, err := os.ReadFile(opts.OutputPath)
if err != nil {
t.Fatalf("Failed to read generated file: %v", err)
}
generated := string(content)
expectations := []string{
"ID string",
"func (m ModelPublicAccounts) GetID() string",
"return m.ID",
"func (m ModelPublicAccounts) GetIDStr() string",
"func (m ModelPublicAccounts) SetID(newid string)",
"func (m *ModelPublicAccounts) UpdateID(newid string)",
"m.ID = newid",
}
for _, expected := range expectations {
if !strings.Contains(generated, expected) {
t.Errorf("Generated code missing expected content: %q\nGenerated:\n%s", expected, generated)
}
}
if strings.Contains(generated, "GetID() int64") || strings.Contains(generated, "UpdateID(newid int64)") {
t.Errorf("String primary keys should not use int64 helper signatures\nGenerated:\n%s", generated)
}
}
func TestTypeMapper_BuildBunTag(t *testing.T) {
mapper := NewTypeMapper()
mapper := NewTypeMapper("")
tests := []struct {
name string
@@ -685,6 +799,24 @@ func TestTypeMapper_BuildBunTag(t *testing.T) {
},
want: []string{"id,", "type:bigserial,", "pk,", "autoincrement,"},
},
{
name: "text array type",
column: &models.Column{
Name: "tags",
Type: "text[]",
NotNull: false,
},
want: []string{"tags,", "type:text[],"},
},
{
name: "integer array type",
column: &models.Column{
Name: "scores",
Type: "integer[]",
NotNull: true,
},
want: []string{"scores,", "type:integer[],"},
},
}
for _, tt := range tests {
@@ -695,6 +827,50 @@ func TestTypeMapper_BuildBunTag(t *testing.T) {
t.Errorf("BuildBunTag() = %q, missing %q", result, part)
}
}
// sqltypes mode must NOT add "array" — SqlXxxArray uses sql.Scanner
if strings.Contains(result, ",array,") || strings.HasSuffix(result, ",array,") {
t.Errorf("BuildBunTag() = %q, must not contain 'array' in sqltypes mode", result)
}
})
}
}
func TestTypeMapper_BuildBunTag_StdlibArrayHasArrayTag(t *testing.T) {
mapper := NewTypeMapper(writers.NullableTypeStdlib)
cases := []struct {
name string
column *models.Column
}{
{name: "text array", column: &models.Column{Name: "tags", Type: "text[]"}},
{name: "integer array", column: &models.Column{Name: "scores", Type: "integer[]", NotNull: true}},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
result := mapper.BuildBunTag(tt.column, nil)
if !strings.Contains(result, "array") {
t.Errorf("BuildBunTag() = %q, expected 'array' in stdlib mode", result)
}
})
}
}
func TestTypeMapper_BuildBunTag_PreservesExplicitTypeModifiers(t *testing.T) {
mapper := NewTypeMapper("")
col := &models.Column{
Name: "embedding",
Type: "vector(1536)",
Length: 1536,
Precision: 0,
Scale: 0,
}
tag := mapper.BuildBunTag(col, nil)
if !strings.Contains(tag, "type:vector(1536),") {
t.Fatalf("expected explicit modifier to be preserved, got %q", tag)
}
if strings.Contains(tag, ")(") {
t.Fatalf("type modifier appears duplicated in %q", tag)
}
}
+28 -5
View File
@@ -4,6 +4,7 @@ import (
"encoding/xml"
"fmt"
"os"
"sort"
"strings"
"github.com/google/uuid"
@@ -155,8 +156,15 @@ func (w *Writer) mapTableFields(table *models.Table) models.DCTXTable {
},
}
columnNames := make([]string, 0, len(table.Columns))
for name := range table.Columns {
columnNames = append(columnNames, name)
}
sort.Strings(columnNames)
i := 0
for _, column := range table.Columns {
for _, colName := range columnNames {
column := table.Columns[colName]
dctxTable.Fields[i] = w.mapField(column)
i++
}
@@ -165,12 +173,27 @@ func (w *Writer) mapTableFields(table *models.Table) models.DCTXTable {
}
func (w *Writer) mapTableKeys(table *models.Table) []models.DCTXKey {
keys := make([]models.DCTXKey, len(table.Indexes))
i := 0
indexes := make([]*models.Index, 0, len(table.Indexes))
for _, index := range table.Indexes {
keys[i] = w.mapKey(index, table)
i++
indexes = append(indexes, index)
}
// Stable ordering for deterministic output and test reproducibility:
// primary keys first, then lexicographic by index name.
sort.Slice(indexes, func(i, j int) bool {
iPrimary := strings.HasSuffix(indexes[i].Name, "_pkey")
jPrimary := strings.HasSuffix(indexes[j].Name, "_pkey")
if iPrimary != jPrimary {
return iPrimary
}
return indexes[i].Name < indexes[j].Name
})
keys := make([]models.DCTXKey, len(indexes))
for i, index := range indexes {
keys[i] = w.mapKey(index, table)
}
return keys
}
+2 -8
View File
@@ -5,6 +5,7 @@ import (
"strings"
"git.warky.dev/wdevs/relspecgo/pkg/models"
"git.warky.dev/wdevs/relspecgo/pkg/pgsql"
)
// TypeMapper handles SQL to Drizzle type conversions
@@ -18,7 +19,7 @@ func NewTypeMapper() *TypeMapper {
// SQLTypeToDrizzle converts SQL types to Drizzle column type functions
// Returns the Drizzle column constructor (e.g., "integer", "varchar", "text")
func (tm *TypeMapper) SQLTypeToDrizzle(sqlType string) string {
sqlTypeLower := strings.ToLower(sqlType)
sqlTypeLower := pgsql.CanonicalizeBaseType(pgsql.ExtractBaseTypeLower(sqlType))
// PostgreSQL type mapping to Drizzle
typeMap := map[string]string{
@@ -87,13 +88,6 @@ func (tm *TypeMapper) SQLTypeToDrizzle(sqlType string) string {
return drizzleType
}
// Check for partial matches (e.g., "varchar(255)" -> "varchar")
for sqlPattern, drizzleType := range typeMap {
if strings.HasPrefix(sqlTypeLower, sqlPattern) {
return drizzleType
}
}
// Default to text for unknown types
return "text"
}
+87 -50
View File
@@ -6,6 +6,8 @@ Generates Go source files with GORM model definitions from database schema infor
The GORM Writer converts RelSpec's internal database model representation into Go source code with GORM struct definitions, complete with proper tags, relationships, and methods.
With `--types sqltypes`, nullable fields use the [`pkg/sqltypes`](../../sqltypes/README.md) package.
## Features
- Generates GORM-compatible Go structs
@@ -48,22 +50,23 @@ func main() {
### CLI Examples
```bash
# Generate GORM models from PostgreSQL database (single file)
relspec --input pgsql \
--conn "postgres://localhost/mydb" \
--output gorm \
--out-file models.go \
--package models
# Generate GORM models from a DBML schema (default: baselib pointer types)
relspec convert --from dbml --from-path schema.dbml \
--to gorm --to-path models.go --package models
# Generate GORM models with multi-file output (one file per table)
relspec --input json \
--in-file schema.json \
--output gorm \
--out-file models/ \
--package models
# Use standard library database/sql nullable types instead
relspec convert --from dbml --from-path schema.dbml \
--to gorm --to-path models.go --package models \
--types stdlib
# Convert DBML to GORM models
relspec --input dbml --in-file schema.dbml --output gorm --out-file models.go
# Select sqltypes package types (git.warky.dev/wdevs/relspecgo/pkg/sqltypes)
relspec convert --from pgsql --from-conn "postgres://localhost/mydb" \
--to gorm --to-path models.go --package models \
--types sqltypes
# Multi-file output (one file per table)
relspec convert --from json --from-path schema.json \
--to gorm --to-path models/ --package models
```
## Output Modes
@@ -86,58 +89,86 @@ relspec --input pgsql --conn "..." --output gorm --out-file models/
Files are named: `sql_{schema}_{table}.go`
## Generated Code Example
## Generated Code Examples
### sqltypes package types (`--types sqltypes`)
```go
package models
import (
"time"
sql_types "git.warky.dev/wdevs/sql_types"
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
)
type ModelUser struct {
ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement" json:"id"`
Username string `gorm:"column:username;type:varchar(50);not null;uniqueIndex" json:"username"`
Email string `gorm:"column:email;type:varchar(100);not null" json:"email"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:now()" json:"created_at"`
// Relationships
Pos []*ModelPost `gorm:"foreignKey:UserID;references:ID;constraint:OnDelete:CASCADE" json:"pos,omitempty"`
ID string `gorm:"column:id;type:uuid;primaryKey" json:"id"`
Username string `gorm:"column:username;type:text;not null" json:"username"`
Email sql_types.SqlString `gorm:"column:email;type:text" json:"email,omitempty"`
Tags sql_types.SqlStringArray `gorm:"column:tags;type:text[];not null;default:'{}'" json:"tags"`
CreatedAt sql_types.SqlTimeStamp `gorm:"column:created_at;type:timestamptz;not null;default:now()" json:"created_at"`
}
func (ModelUser) TableName() string {
return "public.users"
}
```
type ModelPost struct {
ID int64 `gorm:"column:id;type:bigint;primaryKey" json:"id"`
UserID int64 `gorm:"column:user_id;type:bigint;not null" json:"user_id"`
Title string `gorm:"column:title;type:varchar(200);not null" json:"title"`
Content sql_types.SqlString `gorm:"column:content;type:text" json:"content,omitempty"`
### Standard library — `--types stdlib`
// Belongs to
Use *ModelUser `gorm:"foreignKey:UserID;references:ID" json:"use,omitempty"`
```go
package models
import (
"database/sql"
"time"
)
type ModelUser struct {
ID string `gorm:"column:id;type:uuid;primaryKey" json:"id"`
Username string `gorm:"column:username;type:text;not null" json:"username"`
Email sql.NullString `gorm:"column:email;type:text" json:"email,omitempty"`
Tags []string `gorm:"column:tags;type:text[];not null;default:'{}'" json:"tags"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;default:now()" json:"created_at"`
}
func (ModelPost) TableName() string {
return "public.posts"
func (ModelUser) TableName() string {
return "public.users"
}
```
## Writer Options
### NullableTypes
Controls which Go package is used for nullable column types. Set via the `--types` CLI flag or `WriterOptions.NullableTypes`:
```go
// Use sqltypes package types
options := &writers.WriterOptions{
OutputPath: "models.go",
PackageName: "models",
NullableTypes: writers.NullableTypeSqlTypes,
}
// Use standard library database/sql types
options := &writers.WriterOptions{
OutputPath: "models.go",
PackageName: "models",
NullableTypes: writers.NullableTypeStdlib,
}
```
### Metadata Options
Configure the writer behavior using metadata in `WriterOptions`:
Configure additional writer behavior using metadata in `WriterOptions`:
```go
options := &writers.WriterOptions{
OutputPath: "models.go",
PackageName: "models",
Metadata: map[string]interface{}{
"multi_file": true, // Enable multi-file mode
"populate_refs": true, // Populate RefDatabase/RefSchema
Metadata: map[string]any{
"multi_file": true, // Enable multi-file mode
"populate_refs": true, // Populate RefDatabase/RefSchema
"generate_get_id_str": true, // Generate GetIDStr() methods
},
}
@@ -145,18 +176,23 @@ options := &writers.WriterOptions{
## Type Mapping
| SQL Type | Go Type | Notes |
|----------|---------|-------|
| bigint, int8 | int64 | - |
| integer, int, int4 | int | - |
| smallint, int2 | int16 | - |
| varchar, text | string | Not nullable |
| varchar, text (nullable) | sql_types.SqlString | Nullable |
| boolean, bool | bool | - |
| timestamp, timestamptz | time.Time | - |
| numeric, decimal | float64 | - |
| uuid | string | - |
| json, jsonb | string | - |
The nullable type package is selected with `--types` (or `WriterOptions.NullableTypes`).
| SQL Type | NOT NULL — both | Nullable — sqltypes | Nullable — stdlib |
|---|---|---|---|
| `bigint` | `int64` | `SqlInt64` | `sql.NullInt64` |
| `integer` | `int32` | `SqlInt32` | `sql.NullInt32` |
| `smallint` | `int16` | `SqlInt16` | `sql.NullInt16` |
| `text`, `varchar` | `string` | `SqlString` | `sql.NullString` |
| `boolean` | `bool` | `SqlBool` | `sql.NullBool` |
| `timestamp`, `timestamptz` | `time.Time` | `SqlTimeStamp` | `sql.NullTime` |
| `numeric`, `decimal` | `float64` | `SqlFloat64` | `sql.NullFloat64` |
| `uuid` | `string` | `SqlUUID` | `sql.NullString` |
| `jsonb` | `string` | `SqlString` | `sql.NullString` |
| `text[]` | `SqlStringArray` | `SqlStringArray` | `[]string` |
| `integer[]` | `SqlInt32Array` | `SqlInt32Array` | `[]int32` |
| `uuid[]` | `SqlUUIDArray` | `SqlUUIDArray` | `[]string` |
| `vector` | `SqlVector` | `SqlVector` | `[]float32` |
## Relationship Generation
@@ -170,7 +206,8 @@ The writer automatically generates relationship fields:
## Notes
- Model names are prefixed with "Model" (e.g., `ModelUser`)
- Nullable columns use `sql_types.SqlString`, `sql_types.SqlInt64`, etc.
- Nullable columns use `sql_types.SqlString`, `sql_types.SqlInt64`, etc. by default; pass `--types stdlib` to use `sql.NullString`, `sql.NullInt64`, etc. instead
- Array columns use `sql_types.SqlStringArray`, `sql_types.SqlInt32Array`, etc. by default; `--types stdlib` produces plain Go slices (`[]string`, `[]int32`, …)
- Generated code is auto-formatted with `go fmt`
- JSON tags are automatically added
- Supports schema-qualified table names in `TableName()` method
+32 -12
View File
@@ -2,6 +2,7 @@ package gorm
import (
"sort"
"strings"
"git.warky.dev/wdevs/relspecgo/pkg/models"
"git.warky.dev/wdevs/relspecgo/pkg/writers"
@@ -17,17 +18,20 @@ type TemplateData struct {
// ModelData represents a single model/struct in the template
type ModelData struct {
Name string
TableName string // schema.table format
SchemaName string
TableNameOnly string // just table name without schema
Comment string
Fields []*FieldData
Config *MethodConfig
PrimaryKeyField string // Name of the primary key field
PrimaryKeyType string // Go type of the primary key field
IDColumnName string // Name of the ID column in database
Prefix string // 3-letter prefix
Name string
TableName string // schema.table format
SchemaName string
TableNameOnly string // just table name without schema
Comment string
Fields []*FieldData
Config *MethodConfig
PrimaryKeyField string // Name of the primary key field
PrimaryKeyType string // Go type of the primary key field
PrimaryKeyIsSQL bool // Whether PK uses a SQL wrapper type
PrimaryKeyIsStr bool // Whether helper methods should use string IDs
PrimaryKeyIDType string // Helper method GetID/SetID/UpdateID type
IDColumnName string // Name of the ID column in database
Prefix string // 3-letter prefix
}
// FieldData represents a single field in a struct
@@ -136,7 +140,14 @@ func NewModelData(table *models.Table, schema string, typeMapper *TypeMapper, fl
// Sanitize column name to remove backticks
safeName := writers.SanitizeStructTagValue(col.Name)
model.PrimaryKeyField = SnakeCaseToPascalCase(safeName)
model.PrimaryKeyType = typeMapper.SQLTypeToGoType(col.Type, col.NotNull)
goType := typeMapper.SQLTypeToGoType(col.Type, col.NotNull)
model.PrimaryKeyType = goType
model.PrimaryKeyIsSQL = strings.Contains(goType, "sql_types.") || strings.Contains(goType, "sql.")
model.PrimaryKeyIsStr = isStringLikePrimaryKeyType(goType)
model.PrimaryKeyIDType = "int64"
if model.PrimaryKeyIsStr {
model.PrimaryKeyIDType = "string"
}
model.IDColumnName = safeName
break
}
@@ -189,6 +200,15 @@ func formatComment(description, comment string) string {
return comment
}
func isStringLikePrimaryKeyType(goType string) bool {
switch goType {
case "string", "*string", "sql.NullString", "sql_types.SqlString", "sql_types.SqlUUID":
return true
default:
return false
}
}
// resolveFieldNameCollision checks if a field name conflicts with generated method names
// and adds an underscore suffix if there's a collision
func resolveFieldNameCollision(fieldName string) string {
+33 -3
View File
@@ -43,26 +43,56 @@ func (m {{.Name}}) SchemaName() string {
{{end}}
{{if and .Config.GenerateGetID .PrimaryKeyField}}
// GetID returns the primary key value
func (m {{.Name}}) GetID() int64 {
func (m {{.Name}}) GetID() {{.PrimaryKeyIDType}} {
{{if .PrimaryKeyIsSQL -}}
{{if .PrimaryKeyIsStr -}}
return m.{{.PrimaryKeyField}}.String()
{{- else -}}
return m.{{.PrimaryKeyField}}.Int64()
{{- end}}
{{- else -}}
{{if .PrimaryKeyIsStr -}}
return m.{{.PrimaryKeyField}}
{{- else -}}
return int64(m.{{.PrimaryKeyField}})
{{- end}}
{{- end}}
}
{{end}}
{{if and .Config.GenerateGetIDStr .PrimaryKeyField}}
// GetIDStr returns the primary key as a string
func (m {{.Name}}) GetIDStr() string {
{{if .PrimaryKeyIsSQL -}}
return m.{{.PrimaryKeyField}}.String()
{{- else if .PrimaryKeyIsStr -}}
return m.{{.PrimaryKeyField}}
{{- else -}}
return fmt.Sprintf("%d", m.{{.PrimaryKeyField}})
{{- end}}
}
{{end}}
{{if and .Config.GenerateSetID .PrimaryKeyField}}
// SetID sets the primary key value
func (m {{.Name}}) SetID(newid int64) {
func (m {{.Name}}) SetID(newid {{.PrimaryKeyIDType}}) {
m.UpdateID(newid)
}
{{end}}
{{if and .Config.GenerateUpdateID .PrimaryKeyField}}
// UpdateID updates the primary key value
func (m *{{.Name}}) UpdateID(newid int64) {
func (m *{{.Name}}) UpdateID(newid {{.PrimaryKeyIDType}}) {
{{if .PrimaryKeyIsSQL -}}
{{if .PrimaryKeyIsStr -}}
m.{{.PrimaryKeyField}}.FromString(newid)
{{- else -}}
m.{{.PrimaryKeyField}}.FromString(fmt.Sprintf("%d", newid))
{{- end}}
{{- else -}}
{{if .PrimaryKeyIsStr -}}
m.{{.PrimaryKeyField}} = newid
{{- else -}}
m.{{.PrimaryKeyField}} = {{.PrimaryKeyType}}(newid)
{{- end}}
{{- end}}
}
{{end}}
{{if and .Config.GenerateGetIDName .IDColumnName}}
+203 -21
View File
@@ -5,48 +5,62 @@ import (
"strings"
"git.warky.dev/wdevs/relspecgo/pkg/models"
"git.warky.dev/wdevs/relspecgo/pkg/pgsql"
"git.warky.dev/wdevs/relspecgo/pkg/writers"
)
// TypeMapper handles type conversions between SQL and Go types
type TypeMapper struct {
// Package alias for sql_types import
sqlTypesAlias string
typeStyle string // writers.NullableTypeSqlTypes | writers.NullableTypeStdlib | writers.NullableTypeBaselib
}
// NewTypeMapper creates a new TypeMapper with default settings
func NewTypeMapper() *TypeMapper {
// NewTypeMapper creates a new TypeMapper.
// typeStyle should be writers.NullableTypeSqlTypes, writers.NullableTypeStdlib, or
// writers.NullableTypeBaselib; an empty string defaults to baselib.
func NewTypeMapper(typeStyle string) *TypeMapper {
if typeStyle == "" {
typeStyle = writers.NullableTypeBaselib
}
return &TypeMapper{
sqlTypesAlias: "sql_types",
typeStyle: typeStyle,
}
}
// SQLTypeToGoType converts a SQL type to its Go equivalent
// Handles nullable types using ResolveSpec sql_types package
// SQLTypeToGoType converts a SQL type to its Go equivalent.
func (tm *TypeMapper) SQLTypeToGoType(sqlType string, notNull bool) string {
// Normalize SQL type (lowercase, remove length/precision)
// Array types are handled separately for both styles.
if pgsql.IsArrayType(sqlType) {
return tm.arrayGoType(tm.extractBaseType(sqlType))
}
baseType := tm.extractBaseType(sqlType)
// If not null, use base Go types
switch tm.typeStyle {
case writers.NullableTypeStdlib:
if notNull {
return tm.rawGoType(baseType)
}
return tm.stdlibNullableGoType(baseType)
case writers.NullableTypeBaselib:
if notNull {
return tm.rawGoType(baseType)
}
return tm.baselibNullableGoType(baseType)
}
// sqltypes
if notNull {
return tm.baseGoType(baseType)
}
// For nullable fields, use sql_types
return tm.nullableGoType(baseType)
}
// extractBaseType extracts the base type from a SQL type string
// Examples: varchar(100) → varchar, numeric(10,2) → numeric
func (tm *TypeMapper) extractBaseType(sqlType string) string {
sqlType = strings.ToLower(strings.TrimSpace(sqlType))
// Remove everything after '('
if idx := strings.Index(sqlType, "("); idx > 0 {
sqlType = sqlType[:idx]
}
return sqlType
return pgsql.CanonicalizeBaseType(pgsql.ExtractBaseTypeLower(sqlType))
}
// baseGoType returns the base Go type for a SQL type (not null)
@@ -112,6 +126,9 @@ func (tm *TypeMapper) baseGoType(sqlType string) string {
// Other
"money": "float64",
// pgvector — always uses SqlVector even when NOT NULL
"vector": tm.sqlTypesAlias + ".SqlVector",
}
if goType, ok := typeMap[sqlType]; ok {
@@ -185,6 +202,9 @@ func (tm *TypeMapper) nullableGoType(sqlType string) string {
// Other
"money": tm.sqlTypesAlias + ".SqlFloat64",
// pgvector
"vector": tm.sqlTypesAlias + ".SqlVector",
}
if goType, ok := typeMap[sqlType]; ok {
@@ -195,6 +215,155 @@ func (tm *TypeMapper) nullableGoType(sqlType string) string {
return tm.sqlTypesAlias + ".SqlString"
}
// arrayGoType returns the Go type for a PostgreSQL array column.
// The baseElemType is the canonical base type (e.g. "text", "integer").
func (tm *TypeMapper) arrayGoType(baseElemType string) string {
if tm.typeStyle == writers.NullableTypeStdlib || tm.typeStyle == writers.NullableTypeBaselib {
return tm.stdlibArrayGoType(baseElemType)
}
typeMap := map[string]string{
"text": tm.sqlTypesAlias + ".SqlStringArray", "varchar": tm.sqlTypesAlias + ".SqlStringArray",
"char": tm.sqlTypesAlias + ".SqlStringArray", "character": tm.sqlTypesAlias + ".SqlStringArray",
"citext": tm.sqlTypesAlias + ".SqlStringArray", "bpchar": tm.sqlTypesAlias + ".SqlStringArray",
"inet": tm.sqlTypesAlias + ".SqlStringArray", "cidr": tm.sqlTypesAlias + ".SqlStringArray",
"macaddr": tm.sqlTypesAlias + ".SqlStringArray",
"json": tm.sqlTypesAlias + ".SqlStringArray", "jsonb": tm.sqlTypesAlias + ".SqlStringArray",
"integer": tm.sqlTypesAlias + ".SqlInt32Array", "int": tm.sqlTypesAlias + ".SqlInt32Array",
"int4": tm.sqlTypesAlias + ".SqlInt32Array", "serial": tm.sqlTypesAlias + ".SqlInt32Array",
"smallint": tm.sqlTypesAlias + ".SqlInt16Array", "int2": tm.sqlTypesAlias + ".SqlInt16Array",
"smallserial": tm.sqlTypesAlias + ".SqlInt16Array",
"bigint": tm.sqlTypesAlias + ".SqlInt64Array", "int8": tm.sqlTypesAlias + ".SqlInt64Array",
"bigserial": tm.sqlTypesAlias + ".SqlInt64Array",
"real": tm.sqlTypesAlias + ".SqlFloat32Array", "float4": tm.sqlTypesAlias + ".SqlFloat32Array",
"double precision": tm.sqlTypesAlias + ".SqlFloat64Array", "float8": tm.sqlTypesAlias + ".SqlFloat64Array",
"numeric": tm.sqlTypesAlias + ".SqlFloat64Array", "decimal": tm.sqlTypesAlias + ".SqlFloat64Array",
"money": tm.sqlTypesAlias + ".SqlFloat64Array",
"boolean": tm.sqlTypesAlias + ".SqlBoolArray", "bool": tm.sqlTypesAlias + ".SqlBoolArray",
"uuid": tm.sqlTypesAlias + ".SqlUUIDArray",
}
if goType, ok := typeMap[baseElemType]; ok {
return goType
}
return tm.sqlTypesAlias + ".SqlStringArray"
}
// rawGoType returns the plain Go type for a NOT NULL column in stdlib mode.
func (tm *TypeMapper) rawGoType(sqlType string) string {
typeMap := map[string]string{
"integer": "int32", "int": "int32", "int4": "int32", "serial": "int32",
"smallint": "int16", "int2": "int16", "smallserial": "int16",
"bigint": "int64", "int8": "int64", "bigserial": "int64",
"boolean": "bool", "bool": "bool",
"real": "float32", "float4": "float32",
"double precision": "float64", "float8": "float64",
"numeric": "float64", "decimal": "float64", "money": "float64",
"text": "string", "varchar": "string", "char": "string",
"character": "string", "citext": "string", "bpchar": "string",
"inet": "string", "cidr": "string", "macaddr": "string",
"uuid": "string", "json": "string", "jsonb": "string",
"timestamp": "time.Time",
"timestamp without time zone": "time.Time",
"timestamp with time zone": "time.Time",
"timestamptz": "time.Time",
"date": "time.Time",
"time": "time.Time",
"time without time zone": "time.Time",
"time with time zone": "time.Time",
"timetz": "time.Time",
"bytea": "[]byte",
"vector": "[]float32",
}
if goType, ok := typeMap[sqlType]; ok {
return goType
}
return "string"
}
// stdlibNullableGoType returns the database/sql nullable type for a column.
func (tm *TypeMapper) stdlibNullableGoType(sqlType string) string {
typeMap := map[string]string{
"integer": "sql.NullInt32", "int": "sql.NullInt32", "int4": "sql.NullInt32", "serial": "sql.NullInt32",
"smallint": "sql.NullInt16", "int2": "sql.NullInt16", "smallserial": "sql.NullInt16",
"bigint": "sql.NullInt64", "int8": "sql.NullInt64", "bigserial": "sql.NullInt64",
"boolean": "sql.NullBool", "bool": "sql.NullBool",
"real": "sql.NullFloat64", "float4": "sql.NullFloat64",
"double precision": "sql.NullFloat64", "float8": "sql.NullFloat64",
"numeric": "sql.NullFloat64", "decimal": "sql.NullFloat64", "money": "sql.NullFloat64",
"text": "sql.NullString", "varchar": "sql.NullString", "char": "sql.NullString",
"character": "sql.NullString", "citext": "sql.NullString", "bpchar": "sql.NullString",
"inet": "sql.NullString", "cidr": "sql.NullString", "macaddr": "sql.NullString",
"uuid": "sql.NullString", "json": "sql.NullString", "jsonb": "sql.NullString",
"timestamp": "sql.NullTime",
"timestamp without time zone": "sql.NullTime",
"timestamp with time zone": "sql.NullTime",
"timestamptz": "sql.NullTime",
"date": "sql.NullTime",
"time": "sql.NullTime",
"time without time zone": "sql.NullTime",
"time with time zone": "sql.NullTime",
"timetz": "sql.NullTime",
"bytea": "[]byte",
"vector": "[]float32",
}
if goType, ok := typeMap[sqlType]; ok {
return goType
}
return "sql.NullString"
}
// baselibNullableGoType returns plain Go pointer types for nullable columns.
func (tm *TypeMapper) baselibNullableGoType(sqlType string) string {
typeMap := map[string]string{
"integer": "*int32", "int": "*int32", "int4": "*int32", "serial": "*int32",
"smallint": "*int16", "int2": "*int16", "smallserial": "*int16",
"bigint": "*int64", "int8": "*int64", "bigserial": "*int64",
"boolean": "*bool", "bool": "*bool",
"real": "*float32", "float4": "*float32",
"double precision": "*float64", "float8": "*float64",
"numeric": "*float64", "decimal": "*float64", "money": "*float64",
"text": "*string", "varchar": "*string", "char": "*string",
"character": "*string", "citext": "*string", "bpchar": "*string",
"inet": "*string", "cidr": "*string", "macaddr": "*string",
"uuid": "*string", "json": "*string", "jsonb": "*string",
"timestamp": "*time.Time",
"timestamp without time zone": "*time.Time",
"timestamp with time zone": "*time.Time",
"timestamptz": "*time.Time",
"date": "*time.Time",
"time": "*time.Time",
"time without time zone": "*time.Time",
"time with time zone": "*time.Time",
"timetz": "*time.Time",
"bytea": "[]byte",
"vector": "[]float32",
}
if goType, ok := typeMap[sqlType]; ok {
return goType
}
return "*string"
}
// stdlibArrayGoType returns a plain Go slice type for array columns in stdlib mode.
func (tm *TypeMapper) stdlibArrayGoType(baseElemType string) string {
typeMap := map[string]string{
"text": "[]string", "varchar": "[]string", "char": "[]string",
"character": "[]string", "citext": "[]string", "bpchar": "[]string",
"inet": "[]string", "cidr": "[]string", "macaddr": "[]string",
"uuid": "[]string", "json": "[]string", "jsonb": "[]string",
"integer": "[]int32", "int": "[]int32", "int4": "[]int32", "serial": "[]int32",
"smallint": "[]int16", "int2": "[]int16", "smallserial": "[]int16",
"bigint": "[]int64", "int8": "[]int64", "bigserial": "[]int64",
"real": "[]float32", "float4": "[]float32",
"double precision": "[]float64", "float8": "[]float64",
"numeric": "[]float64", "decimal": "[]float64", "money": "[]float64",
"boolean": "[]bool", "bool": "[]bool",
}
if goType, ok := typeMap[baseElemType]; ok {
return goType
}
return "[]string"
}
// BuildGormTag generates a complete GORM tag string for a column
func (tm *TypeMapper) BuildGormTag(column *models.Column, table *models.Table) string {
var parts []string
@@ -209,9 +378,10 @@ func (tm *TypeMapper) BuildGormTag(column *models.Column, table *models.Table) s
// Include length, precision, scale if present
// Sanitize type to remove backticks
typeStr := writers.SanitizeStructTagValue(column.Type)
if column.Length > 0 {
hasExplicitTypeModifier := pgsql.HasExplicitTypeModifier(typeStr)
if !hasExplicitTypeModifier && column.Length > 0 {
typeStr = fmt.Sprintf("%s(%d)", typeStr, column.Length)
} else if column.Precision > 0 {
} else if !hasExplicitTypeModifier && column.Precision > 0 {
if column.Scale > 0 {
typeStr = fmt.Sprintf("%s(%d,%d)", typeStr, column.Precision, column.Scale)
} else {
@@ -335,7 +505,19 @@ func (tm *TypeMapper) NeedsFmtImport(generateGetIDStr bool) bool {
return generateGetIDStr
}
// GetSQLTypesImport returns the import path for sql_types
// GetSQLTypesImport returns the import path for the sqltypes package.
func (tm *TypeMapper) GetSQLTypesImport() string {
return "github.com/bitechdev/ResolveSpec/pkg/spectypes"
return "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
}
// GetNullableTypeImportLine returns the full Go import line for the nullable type
// package (ready to pass to AddImport). Returns empty string when no import is needed.
func (tm *TypeMapper) GetNullableTypeImportLine() string {
switch tm.typeStyle {
case writers.NullableTypeStdlib:
return "\"database/sql\""
case writers.NullableTypeBaselib:
return ""
}
return fmt.Sprintf("%s \"%s\"", tm.sqlTypesAlias, tm.GetSQLTypesImport())
}
+29 -9
View File
@@ -24,7 +24,7 @@ type Writer struct {
func NewWriter(options *writers.WriterOptions) *Writer {
w := &Writer{
options: options,
typeMapper: NewTypeMapper(),
typeMapper: NewTypeMapper(options.NullableTypes),
config: LoadMethodConfigFromMetadata(options.Metadata),
}
@@ -77,8 +77,8 @@ func (w *Writer) writeSingleFile(db *models.Database) error {
packageName := w.getPackageName()
templateData := NewTemplateData(packageName, w.config)
// Add sql_types import (always needed for nullable types)
templateData.AddImport(fmt.Sprintf("sql_types \"%s\"", w.typeMapper.GetSQLTypesImport()))
// Add nullable types import (sqltypes or stdlib depending on options)
templateData.AddImport(w.typeMapper.GetNullableTypeImportLine())
// Collect all models
for _, schema := range db.Schemas {
@@ -99,8 +99,8 @@ func (w *Writer) writeSingleFile(db *models.Database) error {
}
}
// Add fmt import if GetIDStr is enabled
if w.config.GenerateGetIDStr {
// Add fmt import when generated helper methods need string formatting.
if w.needsFmtImport(templateData.Models) {
templateData.AddImport("\"fmt\"")
}
@@ -171,8 +171,8 @@ func (w *Writer) writeMultiFile(db *models.Database) error {
// Create template data for this single table
templateData := NewTemplateData(packageName, w.config)
// Add sql_types import
templateData.AddImport(fmt.Sprintf("sql_types \"%s\"", w.typeMapper.GetSQLTypesImport()))
// Add nullable types import (sqltypes or stdlib depending on options)
templateData.AddImport(w.typeMapper.GetNullableTypeImportLine())
// Create model data
modelData := NewModelData(table, schema.Name, w.typeMapper, w.options.FlattenSchema)
@@ -189,8 +189,8 @@ func (w *Writer) writeMultiFile(db *models.Database) error {
}
}
// Add fmt import if GetIDStr is enabled
if w.config.GenerateGetIDStr {
// Add fmt import when generated helper methods need string formatting.
if w.needsFmtImport(templateData.Models) {
templateData.AddImport("\"fmt\"")
}
@@ -295,6 +295,26 @@ func (w *Writer) addRelationshipFields(modelData *ModelData, table *models.Table
}
}
func (w *Writer) needsFmtImport(models []*ModelData) bool {
if w.config.GenerateGetIDStr {
for _, model := range models {
if model.PrimaryKeyField != "" && !model.PrimaryKeyIsSQL && !model.PrimaryKeyIsStr {
return true
}
}
}
if w.config.GenerateUpdateID {
for _, model := range models {
if model.PrimaryKeyField != "" && model.PrimaryKeyIsSQL && !model.PrimaryKeyIsStr {
return true
}
}
}
return false
}
// findTable finds a table by schema and name in the database
func (w *Writer) findTable(schemaName, tableName string, db *models.Database) *models.Table {
for _, schema := range db.Schemas {
+102 -14
View File
@@ -14,12 +14,12 @@ func TestWriter_WriteTable(t *testing.T) {
// Create a simple table
table := models.InitTable("users", "public")
table.Columns["id"] = &models.Column{
Name: "id",
Type: "bigint",
NotNull: true,
IsPrimaryKey: true,
Name: "id",
Type: "bigint",
NotNull: true,
IsPrimaryKey: true,
AutoIncrement: true,
Sequence: 1,
Sequence: 1,
}
table.Columns["email"] = &models.Column{
Name: "email",
@@ -70,7 +70,7 @@ func TestWriter_WriteTable(t *testing.T) {
"ID",
"int64",
"Email",
"sql_types.SqlString",
"*string",
"CreatedAt",
"time.Time",
"gorm:\"column:id",
@@ -444,10 +444,10 @@ func TestWriter_MultipleHasManyRelationships(t *testing.T) {
// Verify all has-many relationships have unique names
hasManyExpectations := []string{
"RelRIDAPIProviderOrgLogins", // Has many via Login
"RelRIDAPIProviderOrgLogins", // Has many via Login
"RelRIDAPIProviderOrgFilepointers", // Has many via Filepointer
"RelRIDAPIProviderOrgAPIEvents", // Has many via APIEvent
"RelRIDOwner", // Belongs to via rid_owner
"RelRIDAPIProviderOrgAPIEvents", // Has many via APIEvent
"RelRIDOwner", // Belongs to via rid_owner
}
for _, exp := range hasManyExpectations {
@@ -598,6 +598,55 @@ func TestWriter_UpdateIDTypeSafety(t *testing.T) {
}
}
func TestWriter_StringPrimaryKeyHelpers_Gorm(t *testing.T) {
table := models.InitTable("accounts", "public")
table.Columns["id"] = &models.Column{
Name: "id",
Type: "uuid",
NotNull: true,
IsPrimaryKey: true,
}
tmpDir := t.TempDir()
opts := &writers.WriterOptions{
PackageName: "models",
OutputPath: filepath.Join(tmpDir, "test.go"),
}
writer := NewWriter(opts)
err := writer.WriteTable(table)
if err != nil {
t.Fatalf("WriteTable failed: %v", err)
}
content, err := os.ReadFile(opts.OutputPath)
if err != nil {
t.Fatalf("Failed to read generated file: %v", err)
}
generated := string(content)
expectations := []string{
"ID string",
"func (m ModelPublicAccounts) GetID() string",
"return m.ID",
"func (m ModelPublicAccounts) GetIDStr() string",
"func (m ModelPublicAccounts) SetID(newid string)",
"func (m *ModelPublicAccounts) UpdateID(newid string)",
"m.ID = newid",
}
for _, expected := range expectations {
if !strings.Contains(generated, expected) {
t.Errorf("Generated code missing expected content: %q\nGenerated:\n%s", expected, generated)
}
}
if strings.Contains(generated, "GetID() int64") || strings.Contains(generated, "UpdateID(newid int64)") {
t.Errorf("String primary keys should not use int64 helper signatures\nGenerated:\n%s", generated)
}
}
func TestNameConverter_SnakeCaseToPascalCase(t *testing.T) {
tests := []struct {
input string
@@ -643,7 +692,7 @@ func TestNameConverter_Pluralize(t *testing.T) {
}
func TestTypeMapper_SQLTypeToGoType(t *testing.T) {
mapper := NewTypeMapper()
mapper := NewTypeMapper("")
tests := []struct {
sqlType string
@@ -651,13 +700,17 @@ func TestTypeMapper_SQLTypeToGoType(t *testing.T) {
want string
}{
{"bigint", true, "int64"},
{"bigint", false, "sql_types.SqlInt64"},
{"bigint", false, "*int64"},
{"varchar", true, "string"},
{"varchar", false, "sql_types.SqlString"},
{"varchar", false, "*string"},
{"timestamp", true, "time.Time"},
{"timestamp", false, "sql_types.SqlTimeStamp"},
{"timestamp", false, "*time.Time"},
{"boolean", true, "bool"},
{"boolean", false, "sql_types.SqlBool"},
{"boolean", false, "*bool"},
{"text[]", true, "[]string"},
{"text[]", false, "[]string"},
{"integer[]", true, "[]int32"},
{"bigint[]", false, "[]int64"},
}
for _, tt := range tests {
@@ -669,3 +722,38 @@ func TestTypeMapper_SQLTypeToGoType(t *testing.T) {
})
}
}
func TestTypeMapper_BuildGormTag_ArrayType(t *testing.T) {
mapper := NewTypeMapper("")
col := &models.Column{
Name: "tags",
Type: "text[]",
NotNull: false,
}
tag := mapper.BuildGormTag(col, nil)
if !strings.Contains(tag, "type:text[]") {
t.Fatalf("expected array type to be preserved, got %q", tag)
}
}
func TestTypeMapper_BuildGormTag_PreservesExplicitTypeModifiers(t *testing.T) {
mapper := NewTypeMapper("")
col := &models.Column{
Name: "embedding",
Type: "vector(1536)",
Length: 1536,
Precision: 0,
Scale: 0,
}
tag := mapper.BuildGormTag(col, nil)
if !strings.Contains(tag, "type:vector(1536)") {
t.Fatalf("expected explicit modifier to be preserved, got %q", tag)
}
if strings.Contains(tag, ")(") {
t.Fatalf("type modifier appears duplicated in %q", tag)
}
}
+6 -8
View File
@@ -4,6 +4,7 @@ import (
"strings"
"git.warky.dev/wdevs/relspecgo/pkg/models"
"git.warky.dev/wdevs/relspecgo/pkg/pgsql"
)
func (w *Writer) sqlTypeToGraphQL(sqlType string, column *models.Column, table *models.Table, schema *models.Schema) string {
@@ -33,12 +34,11 @@ func (w *Writer) sqlTypeToGraphQL(sqlType string, column *models.Column, table *
}
// Standard type mappings
baseType := strings.Split(sqlType, "(")[0] // Remove length/precision
baseType = strings.TrimSpace(baseType)
baseType := pgsql.CanonicalizeBaseType(pgsql.ExtractBaseTypeLower(sqlType))
// Handle array types
if strings.HasSuffix(baseType, "[]") {
elemType := strings.TrimSuffix(baseType, "[]")
if pgsql.IsArrayType(sqlType) {
elemType := pgsql.CanonicalizeBaseType(pgsql.ExtractBaseTypeLower(pgsql.ElementType(sqlType)))
gqlType := w.mapBaseTypeToGraphQL(elemType)
return "[" + gqlType + "]"
}
@@ -108,8 +108,7 @@ func (w *Writer) sqlTypeToCustomScalar(sqlType string) string {
"date": "Date",
}
baseType := strings.Split(sqlType, "(")[0]
baseType = strings.TrimSpace(baseType)
baseType := pgsql.CanonicalizeBaseType(pgsql.ExtractBaseTypeLower(sqlType))
if scalar, ok := scalarMap[baseType]; ok {
return scalar
@@ -132,8 +131,7 @@ func (w *Writer) isIntegerType(sqlType string) bool {
"smallserial": true,
}
baseType := strings.Split(sqlType, "(")[0]
baseType = strings.TrimSpace(baseType)
baseType := pgsql.CanonicalizeBaseType(pgsql.ExtractBaseTypeLower(sqlType))
return intTypes[baseType]
}
+152 -24
View File
@@ -31,6 +31,10 @@ type MigrationWriter struct {
// NewMigrationWriter creates a new templated migration writer
func NewMigrationWriter(options *writers.WriterOptions) (*MigrationWriter, error) {
if options == nil {
options = &writers.WriterOptions{}
}
executor, err := NewTemplateExecutor(options.FlattenSchema)
if err != nil {
return nil, fmt.Errorf("failed to create template executor: %w", err)
@@ -44,6 +48,16 @@ func NewMigrationWriter(options *writers.WriterOptions) (*MigrationWriter, error
// WriteMigration generates migration scripts using templates
func (w *MigrationWriter) WriteMigration(model *models.Database, current *models.Database) error {
if model == nil {
return fmt.Errorf("model database is required")
}
if w.options == nil {
w.options = &writers.WriterOptions{}
}
if current == nil {
current = models.InitDatabase(model.Name)
}
var writer io.Writer
var file *os.File
var err error
@@ -86,9 +100,16 @@ func (w *MigrationWriter) WriteMigration(model *models.Database, current *models
// Process each schema in the model
for _, modelSchema := range model.Schemas {
if modelSchema == nil {
continue
}
// Find corresponding schema in current database
var currentSchema *models.Schema
for _, cs := range current.Schemas {
if cs == nil {
continue
}
if strings.EqualFold(cs.Name, modelSchema.Name) {
currentSchema = cs
break
@@ -123,7 +144,11 @@ func (w *MigrationWriter) WriteMigration(model *models.Database, current *models
// Write header
fmt.Fprintf(w.writer, "-- PostgreSQL Migration Script\n")
fmt.Fprintf(w.writer, "-- Generated by RelSpec\n")
fmt.Fprintf(w.writer, "-- Source: %s -> %s\n\n", current.Name, model.Name)
fmt.Fprintf(w.writer, "-- Source: %s -> %s\n", current.Name, model.Name)
if w.options.ContinueOnError {
fmt.Fprintf(w.writer, "\\set ON_ERROR_STOP off\n")
}
fmt.Fprintf(w.writer, "\n")
// Write scripts
for _, script := range scripts {
@@ -139,13 +164,26 @@ func (w *MigrationWriter) WriteMigration(model *models.Database, current *models
func (w *MigrationWriter) generateSchemaScripts(model *models.Schema, current *models.Schema) ([]MigrationScript, error) {
scripts := make([]MigrationScript, 0)
// Phase 1: Drop constraints and indexes that changed (Priority 11-50)
if schemaRequiresPGTrgm(model) {
scripts = append(scripts, MigrationScript{
ObjectName: "extension.pg_trgm",
ObjectType: "create extension",
Schema: model.Name,
Priority: 80,
Sequence: len(scripts),
Body: "CREATE EXTENSION IF NOT EXISTS pg_trgm;",
})
}
// Phase 1: Drop constraints and indexes that changed (Priority 5-50)
var droppedFKs map[string]bool
if current != nil {
dropScripts, err := w.generateDropScripts(model, current)
dropScripts, dropped, err := w.generateDropScripts(model, current)
if err != nil {
return nil, fmt.Errorf("failed to generate drop scripts: %w", err)
}
scripts = append(scripts, dropScripts...)
droppedFKs = dropped
}
// Phase 3: Create/Alter tables and columns (Priority 100-145)
@@ -163,7 +201,7 @@ func (w *MigrationWriter) generateSchemaScripts(model *models.Schema, current *m
scripts = append(scripts, indexScripts...)
// Phase 5: Create foreign keys (Priority 195)
fkScripts, err := w.generateForeignKeyScripts(model, current)
fkScripts, err := w.generateForeignKeyScripts(model, current, droppedFKs)
if err != nil {
return nil, fmt.Errorf("failed to generate foreign key scripts: %w", err)
}
@@ -179,9 +217,12 @@ func (w *MigrationWriter) generateSchemaScripts(model *models.Schema, current *m
return scripts, nil
}
// generateDropScripts generates DROP scripts using templates
func (w *MigrationWriter) generateDropScripts(model *models.Schema, current *models.Schema) ([]MigrationScript, error) {
// generateDropScripts generates DROP scripts using templates.
// Returns the scripts and a set of FK constraint keys (schema.table.name) that were
// explicitly dropped because their referenced PK was being dropped, so they can be force-recreated.
func (w *MigrationWriter) generateDropScripts(model *models.Schema, current *models.Schema) ([]MigrationScript, map[string]bool, error) {
scripts := make([]MigrationScript, 0)
droppedFKs := make(map[string]bool)
// Build map of model tables for quick lookup
modelTables := make(map[string]*models.Table)
@@ -208,6 +249,44 @@ func (w *MigrationWriter) generateDropScripts(model *models.Schema, current *mod
shouldDrop = true
}
if shouldDrop && currentConstraint.Type == models.PrimaryKeyConstraint {
// Drop FK constraints that depend on this PK before dropping the PK itself.
for _, otherTable := range current.Tables {
for fkName, fkConstraint := range otherTable.Constraints {
if fkConstraint.Type != models.ForeignKeyConstraint {
continue
}
refTable := fkConstraint.ReferencedTable
refSchema := fkConstraint.ReferencedSchema
if refSchema == "" {
refSchema = current.Name
}
if strings.EqualFold(refTable, currentTable.Name) && strings.EqualFold(refSchema, current.Name) {
fkKey := fmt.Sprintf("%s.%s.%s", current.Name, otherTable.Name, fkName)
if !droppedFKs[fkKey] {
droppedFKs[fkKey] = true
sql, err := w.executor.ExecuteDropConstraint(DropConstraintData{
SchemaName: current.Name,
TableName: otherTable.Name,
ConstraintName: fkName,
})
if err != nil {
return nil, nil, err
}
scripts = append(scripts, MigrationScript{
ObjectName: fkKey,
ObjectType: "drop constraint",
Schema: current.Name,
Priority: 5,
Sequence: len(scripts),
Body: sql,
})
}
}
}
}
}
if shouldDrop {
sql, err := w.executor.ExecuteDropConstraint(DropConstraintData{
SchemaName: current.Name,
@@ -215,7 +294,7 @@ func (w *MigrationWriter) generateDropScripts(model *models.Schema, current *mod
ConstraintName: constraintName,
})
if err != nil {
return nil, err
return nil, nil, err
}
script := MigrationScript{
@@ -247,7 +326,7 @@ func (w *MigrationWriter) generateDropScripts(model *models.Schema, current *mod
IndexName: indexName,
})
if err != nil {
return nil, err
return nil, nil, err
}
script := MigrationScript{
@@ -263,7 +342,7 @@ func (w *MigrationWriter) generateDropScripts(model *models.Schema, current *mod
}
}
return scripts, nil
return scripts, droppedFKs, nil
}
// generateTableScripts generates CREATE/ALTER TABLE scripts using templates
@@ -329,14 +408,18 @@ func (w *MigrationWriter) generateAlterTableScripts(schema *models.Schema, model
// Column doesn't exist, add it
defaultVal := ""
if modelCol.Default != nil {
defaultVal = fmt.Sprintf("%v", modelCol.Default)
if value, ok := modelCol.Default.(string); ok {
defaultVal = writers.QuoteDefaultValue(value, modelCol.Type)
} else {
defaultVal = fmt.Sprintf("%v", modelCol.Default)
}
}
sql, err := w.executor.ExecuteAddColumn(AddColumnData{
SchemaName: schema.Name,
TableName: modelTable.Name,
ColumnName: modelCol.Name,
ColumnType: pgsql.ConvertSQLType(modelCol.Type),
ColumnType: effectiveColumnSQLType(modelCol),
Default: defaultVal,
NotNull: modelCol.NotNull,
})
@@ -355,12 +438,13 @@ func (w *MigrationWriter) generateAlterTableScripts(schema *models.Schema, model
scripts = append(scripts, script)
} else if !columnsEqual(modelCol, currentCol) {
// Column exists but properties changed
if modelCol.Type != currentCol.Type {
if !columnTypesEqual(modelCol, currentCol) {
sql, err := w.executor.ExecuteAlterColumnType(AlterColumnTypeData{
SchemaName: schema.Name,
TableName: modelTable.Name,
ColumnName: modelCol.Name,
NewType: pgsql.ConvertSQLType(modelCol.Type),
NewType: effectiveAlterColumnSQLType(modelCol),
UsingExpr: buildAlterColumnUsingExpression(modelCol.Name, effectiveAlterColumnSQLType(modelCol)),
})
if err != nil {
return nil, err
@@ -382,7 +466,11 @@ func (w *MigrationWriter) generateAlterTableScripts(schema *models.Schema, model
setDefault := modelCol.Default != nil
defaultVal := ""
if setDefault {
defaultVal = fmt.Sprintf("%v", modelCol.Default)
if value, ok := modelCol.Default.(string); ok {
defaultVal = writers.QuoteDefaultValue(value, modelCol.Type)
} else {
defaultVal = fmt.Sprintf("%v", modelCol.Default)
}
}
sql, err := w.executor.ExecuteAlterColumnDefault(AlterColumnDefaultData{
@@ -537,12 +625,17 @@ func (w *MigrationWriter) generateIndexScripts(model *models.Schema, current *mo
indexType = modelIndex.Type
}
columnExprs := buildIndexColumnExpressions(modelTable, modelIndex, indexType)
if len(columnExprs) == 0 {
continue
}
sql, err := w.executor.ExecuteCreateIndex(CreateIndexData{
SchemaName: model.Name,
TableName: modelTable.Name,
IndexName: indexName,
IndexType: indexType,
Columns: strings.Join(modelIndex.Columns, ", "),
Columns: strings.Join(columnExprs, ", "),
Unique: modelIndex.Unique,
})
if err != nil {
@@ -565,8 +658,30 @@ func (w *MigrationWriter) generateIndexScripts(model *models.Schema, current *mo
return scripts, nil
}
// generateForeignKeyScripts generates ADD CONSTRAINT FOREIGN KEY scripts using templates
func (w *MigrationWriter) generateForeignKeyScripts(model *models.Schema, current *models.Schema) ([]MigrationScript, error) {
func buildIndexColumnExpressions(table *models.Table, index *models.Index, indexType string) []string {
columnExprs := make([]string, 0, len(index.Columns))
for _, colName := range index.Columns {
colExpr := colName
if table != nil {
if col, ok := resolveIndexColumn(table, colName); ok && col != nil {
colExpr = col.SQLName()
if strings.EqualFold(indexType, "gin") {
opClass := ginOperatorClassForColumn(col, index.Comment)
if opClass != "" {
colExpr = fmt.Sprintf("%s %s", col.SQLName(), opClass)
}
}
}
}
columnExprs = append(columnExprs, colExpr)
}
return columnExprs
}
// generateForeignKeyScripts generates ADD CONSTRAINT FOREIGN KEY scripts using templates.
// forceRecreate is a set of FK constraint keys (schema.table.name) that must be recreated
// even if unchanged, because their referenced PK was dropped and recreated.
func (w *MigrationWriter) generateForeignKeyScripts(model *models.Schema, current *models.Schema, forceRecreate map[string]bool) ([]MigrationScript, error) {
scripts := make([]MigrationScript, 0)
// Build map of current tables
@@ -587,13 +702,16 @@ func (w *MigrationWriter) generateForeignKeyScripts(model *models.Schema, curren
continue
}
shouldCreate := true
fkKey := fmt.Sprintf("%s.%s.%s", model.Name, modelTable.Name, constraintName)
shouldCreate := forceRecreate[fkKey]
if currentTable != nil {
if currentConstraint, exists := currentTable.Constraints[constraintName]; exists {
if constraintsEqual(constraint, currentConstraint) {
shouldCreate = false
}
if !shouldCreate {
if currentTable == nil {
shouldCreate = true
} else if currentConstraint, exists := currentTable.Constraints[constraintName]; !exists {
shouldCreate = true
} else if !constraintsEqual(constraint, currentConstraint) {
shouldCreate = true
}
}
@@ -820,11 +938,21 @@ func columnsEqual(col1, col2 *models.Column) bool {
if col1 == nil || col2 == nil {
return false
}
return strings.EqualFold(col1.Type, col2.Type) &&
return columnTypesEqual(col1, col2) &&
col1.NotNull == col2.NotNull &&
fmt.Sprintf("%v", col1.Default) == fmt.Sprintf("%v", col2.Default)
}
func columnTypesEqual(col1, col2 *models.Column) bool {
if col1 == nil || col2 == nil {
return false
}
return strings.EqualFold(
pgsql.NormalizeEquivalentSQLType(effectiveColumnSQLType(col1)),
pgsql.NormalizeEquivalentSQLType(effectiveColumnSQLType(col2)),
)
}
// constraintsEqual checks if two constraints are equal
func constraintsEqual(c1, c2 *models.Constraint) bool {
if c1 == nil || c2 == nil {
+447
View File
@@ -57,6 +57,410 @@ func TestWriteMigration_NewTable(t *testing.T) {
}
}
func TestWriteMigration_ArrayDefault(t *testing.T) {
current := models.InitDatabase("testdb")
currentSchema := models.InitSchema("public")
current.Schemas = append(current.Schemas, currentSchema)
model := models.InitDatabase("testdb")
modelSchema := models.InitSchema("public")
table := models.InitTable("plans", "public")
tagsCol := models.InitColumn("tags", "plans", "public")
tagsCol.Type = "text[]"
tagsCol.NotNull = true
tagsCol.Default = "''{}''"
table.Columns["tags"] = tagsCol
modelSchema.Tables = append(modelSchema.Tables, table)
model.Schemas = append(model.Schemas, modelSchema)
var buf bytes.Buffer
writer, err := NewMigrationWriter(&writers.WriterOptions{})
if err != nil {
t.Fatalf("Failed to create writer: %v", err)
}
writer.writer = &buf
err = writer.WriteMigration(model, current)
if err != nil {
t.Fatalf("WriteMigration failed: %v", err)
}
output := buf.String()
if !strings.Contains(output, "tags text[] DEFAULT '{}' NOT NULL") {
t.Fatalf("expected normalized array default in migration, got:\n%s", output)
}
if strings.Contains(output, "'''{}'''") {
t.Fatalf("migration still contains triple-quoted array default:\n%s", output)
}
}
func TestWriteMigration_AltersColumnTypeWhenActualTypeDiffers(t *testing.T) {
current := models.InitDatabase("testdb")
currentSchema := models.InitSchema("public")
currentTable := models.InitTable("learnings", "public")
currentDetails := models.InitColumn("details", "learnings", "public")
currentDetails.Type = "jsonb"
currentTable.Columns["details"] = currentDetails
currentSchema.Tables = append(currentSchema.Tables, currentTable)
current.Schemas = append(current.Schemas, currentSchema)
model := models.InitDatabase("testdb")
modelSchema := models.InitSchema("public")
modelTable := models.InitTable("learnings", "public")
modelDetails := models.InitColumn("details", "learnings", "public")
modelDetails.Type = "text"
modelTable.Columns["details"] = modelDetails
modelSchema.Tables = append(modelSchema.Tables, modelTable)
model.Schemas = append(model.Schemas, modelSchema)
var buf bytes.Buffer
writer, err := NewMigrationWriter(&writers.WriterOptions{})
if err != nil {
t.Fatalf("Failed to create writer: %v", err)
}
writer.writer = &buf
if err := writer.WriteMigration(model, current); err != nil {
t.Fatalf("WriteMigration failed: %v", err)
}
output := buf.String()
if !strings.Contains(output, "ALTER TABLE public.learnings") || !strings.Contains(output, "ALTER COLUMN details TYPE text") {
t.Fatalf("expected migration to alter mismatched column type, got:\n%s", output)
}
if !strings.Contains(output, `ALTER COLUMN details TYPE text USING details::text;`) {
t.Fatalf("expected migration type alter to include USING cast, got:\n%s", output)
}
}
func TestWriteMigration_UsesStorageTypeForSerialAlterStatements(t *testing.T) {
current := models.InitDatabase("testdb")
currentSchema := models.InitSchema("public")
currentTable := models.InitTable("learnings", "public")
currentID := models.InitColumn("id", "learnings", "public")
currentID.Type = "uuid"
currentTable.Columns["id"] = currentID
currentSchema.Tables = append(currentSchema.Tables, currentTable)
current.Schemas = append(current.Schemas, currentSchema)
model := models.InitDatabase("testdb")
modelSchema := models.InitSchema("public")
modelTable := models.InitTable("learnings", "public")
modelID := models.InitColumn("id", "learnings", "public")
modelID.Type = "bigserial"
modelTable.Columns["id"] = modelID
modelSchema.Tables = append(modelSchema.Tables, modelTable)
model.Schemas = append(model.Schemas, modelSchema)
var buf bytes.Buffer
writer, err := NewMigrationWriter(&writers.WriterOptions{})
if err != nil {
t.Fatalf("Failed to create writer: %v", err)
}
writer.writer = &buf
if err := writer.WriteMigration(model, current); err != nil {
t.Fatalf("WriteMigration failed: %v", err)
}
output := buf.String()
if !strings.Contains(output, "ALTER COLUMN id TYPE bigint") {
t.Fatalf("expected serial alter to use bigint storage type, got:\n%s", output)
}
if strings.Contains(output, "ALTER COLUMN id TYPE bigserial;") {
t.Fatalf("did not expect invalid bigserial alter statement, got:\n%s", output)
}
if !strings.Contains(output, `ALTER COLUMN id TYPE bigint USING id::bigint;`) {
t.Fatalf("expected serial alter to include USING cast, got:\n%s", output)
}
}
func TestWriteMigration_ArrayAlterIncludesUsingCast(t *testing.T) {
current := models.InitDatabase("testdb")
currentSchema := models.InitSchema("public")
currentTable := models.InitTable("learnings", "public")
currentTags := models.InitColumn("tags", "learnings", "public")
currentTags.Type = "text"
currentTable.Columns["tags"] = currentTags
currentSchema.Tables = append(currentSchema.Tables, currentTable)
current.Schemas = append(current.Schemas, currentSchema)
model := models.InitDatabase("testdb")
modelSchema := models.InitSchema("public")
modelTable := models.InitTable("learnings", "public")
modelTags := models.InitColumn("tags", "learnings", "public")
modelTags.Type = "text[]"
modelTable.Columns["tags"] = modelTags
modelSchema.Tables = append(modelSchema.Tables, modelTable)
model.Schemas = append(model.Schemas, modelSchema)
var buf bytes.Buffer
writer, err := NewMigrationWriter(&writers.WriterOptions{})
if err != nil {
t.Fatalf("Failed to create writer: %v", err)
}
writer.writer = &buf
if err := writer.WriteMigration(model, current); err != nil {
t.Fatalf("WriteMigration failed: %v", err)
}
output := buf.String()
if !strings.Contains(output, `ALTER COLUMN tags TYPE text[] USING tags::text[];`) {
t.Fatalf("expected array alter to include USING cast, got:\n%s", output)
}
}
func TestWriteMigration_DoesNotAlterEquivalentNormalizedColumnType(t *testing.T) {
current := models.InitDatabase("testdb")
currentSchema := models.InitSchema("public")
currentTable := models.InitTable("users", "public")
currentEmail := models.InitColumn("email", "users", "public")
currentEmail.Type = "character varying"
currentEmail.Length = 255
currentTable.Columns["email"] = currentEmail
currentSchema.Tables = append(currentSchema.Tables, currentTable)
current.Schemas = append(current.Schemas, currentSchema)
model := models.InitDatabase("testdb")
modelSchema := models.InitSchema("public")
modelTable := models.InitTable("users", "public")
modelEmail := models.InitColumn("email", "users", "public")
modelEmail.Type = "varchar(255)"
modelTable.Columns["email"] = modelEmail
modelSchema.Tables = append(modelSchema.Tables, modelTable)
model.Schemas = append(model.Schemas, modelSchema)
var buf bytes.Buffer
writer, err := NewMigrationWriter(&writers.WriterOptions{})
if err != nil {
t.Fatalf("Failed to create writer: %v", err)
}
writer.writer = &buf
if err := writer.WriteMigration(model, current); err != nil {
t.Fatalf("WriteMigration failed: %v", err)
}
output := buf.String()
if strings.Contains(output, "ALTER COLUMN email TYPE") {
t.Fatalf("did not expect alter type for equivalent normalized types, got:\n%s", output)
}
}
func TestWriteMigration_GinIndexOnTextUsesTrigramOperatorClass(t *testing.T) {
current := models.InitDatabase("testdb")
currentSchema := models.InitSchema("public")
current.Schemas = append(current.Schemas, currentSchema)
model := models.InitDatabase("testdb")
modelSchema := models.InitSchema("public")
table := models.InitTable("articles", "public")
titleCol := models.InitColumn("title", "articles", "public")
titleCol.Type = "text"
table.Columns["title"] = titleCol
index := &models.Index{
Name: "idx_articles_title_gin",
Type: "gin",
Columns: []string{"title"},
}
table.Indexes[index.Name] = index
modelSchema.Tables = append(modelSchema.Tables, table)
model.Schemas = append(model.Schemas, modelSchema)
var buf bytes.Buffer
writer, err := NewMigrationWriter(&writers.WriterOptions{})
if err != nil {
t.Fatalf("Failed to create writer: %v", err)
}
writer.writer = &buf
if err := writer.WriteMigration(model, current); err != nil {
t.Fatalf("WriteMigration failed: %v", err)
}
output := buf.String()
if !strings.Contains(output, "CREATE EXTENSION IF NOT EXISTS pg_trgm;") {
t.Fatalf("expected trigram extension for text GIN migration index, got:\n%s", output)
}
if !strings.Contains(output, "USING gin (title gin_trgm_ops)") {
t.Fatalf("expected GIN text index to include gin_trgm_ops, got:\n%s", output)
}
}
func TestWriteMigration_GinIndexOnQuotedTextColumnUsesTrigramOperatorClass(t *testing.T) {
current := models.InitDatabase("testdb")
currentSchema := models.InitSchema("public")
current.Schemas = append(current.Schemas, currentSchema)
model := models.InitDatabase("testdb")
modelSchema := models.InitSchema("public")
table := models.InitTable("agent_personas", "public")
nameCol := models.InitColumn("name", "agent_personas", "public")
nameCol.Type = "text"
table.Columns["name"] = nameCol
index := &models.Index{
Name: "idx_agent_personas_name_gin",
Type: "gin",
Columns: []string{`"name"`},
}
table.Indexes[index.Name] = index
modelSchema.Tables = append(modelSchema.Tables, table)
model.Schemas = append(model.Schemas, modelSchema)
var buf bytes.Buffer
writer, err := NewMigrationWriter(&writers.WriterOptions{})
if err != nil {
t.Fatalf("Failed to create writer: %v", err)
}
writer.writer = &buf
if err := writer.WriteMigration(model, current); err != nil {
t.Fatalf("WriteMigration failed: %v", err)
}
output := buf.String()
if !strings.Contains(output, "USING gin (name gin_trgm_ops)") {
t.Fatalf("expected quoted text column GIN index to include gin_trgm_ops, got:\n%s", output)
}
}
func TestWriteMigration_GinIndexOnTextArrayDoesNotUseTrigramOperatorClass(t *testing.T) {
current := models.InitDatabase("testdb")
currentSchema := models.InitSchema("public")
current.Schemas = append(current.Schemas, currentSchema)
model := models.InitDatabase("testdb")
modelSchema := models.InitSchema("public")
table := models.InitTable("plans", "public")
tagsCol := models.InitColumn("tags", "plans", "public")
tagsCol.Type = "text[]"
table.Columns["tags"] = tagsCol
index := &models.Index{
Name: "idx_plans_tags",
Type: "gin",
Columns: []string{"tags"},
}
table.Indexes[index.Name] = index
modelSchema.Tables = append(modelSchema.Tables, table)
model.Schemas = append(model.Schemas, modelSchema)
var buf bytes.Buffer
writer, err := NewMigrationWriter(&writers.WriterOptions{})
if err != nil {
t.Fatalf("Failed to create writer: %v", err)
}
writer.writer = &buf
if err := writer.WriteMigration(model, current); err != nil {
t.Fatalf("WriteMigration failed: %v", err)
}
output := buf.String()
if !strings.Contains(output, "USING gin (tags array_ops)") {
t.Fatalf("expected GIN array index with array_ops, got:\n%s", output)
}
if strings.Contains(output, "gin_trgm_ops") {
t.Fatalf("did not expect gin_trgm_ops for text[] migration index, got:\n%s", output)
}
}
func TestWriteMigration_GinIndexOnJSONBUsesJSONBOperatorClass(t *testing.T) {
current := models.InitDatabase("testdb")
currentSchema := models.InitSchema("public")
current.Schemas = append(current.Schemas, currentSchema)
model := models.InitDatabase("testdb")
modelSchema := models.InitSchema("public")
table := models.InitTable("learnings", "public")
detailsCol := models.InitColumn("details", "learnings", "public")
detailsCol.Type = "jsonb"
table.Columns["details"] = detailsCol
index := &models.Index{
Name: "idx_learnings_details",
Type: "gin",
Columns: []string{"details"},
}
table.Indexes[index.Name] = index
modelSchema.Tables = append(modelSchema.Tables, table)
model.Schemas = append(model.Schemas, modelSchema)
var buf bytes.Buffer
writer, err := NewMigrationWriter(&writers.WriterOptions{})
if err != nil {
t.Fatalf("Failed to create writer: %v", err)
}
writer.writer = &buf
if err := writer.WriteMigration(model, current); err != nil {
t.Fatalf("WriteMigration failed: %v", err)
}
output := buf.String()
if !strings.Contains(output, "USING gin (details jsonb_ops)") {
t.Fatalf("expected GIN jsonb index to include jsonb_ops, got:\n%s", output)
}
if strings.Contains(output, "gin_trgm_ops") {
t.Fatalf("did not expect gin_trgm_ops for jsonb migration index, got:\n%s", output)
}
}
func TestWriteMigration_GinIndexOnJSONBIgnoresIncompatibleTrigramOperatorClass(t *testing.T) {
current := models.InitDatabase("testdb")
currentSchema := models.InitSchema("public")
current.Schemas = append(current.Schemas, currentSchema)
model := models.InitDatabase("testdb")
modelSchema := models.InitSchema("public")
table := models.InitTable("learnings", "public")
detailsCol := models.InitColumn("details", "learnings", "public")
detailsCol.Type = "jsonb"
table.Columns["details"] = detailsCol
index := &models.Index{
Name: "idx_learnings_details",
Type: "gin",
Columns: []string{"details"},
Comment: "gin_trgm_ops",
}
table.Indexes[index.Name] = index
modelSchema.Tables = append(modelSchema.Tables, table)
model.Schemas = append(model.Schemas, modelSchema)
var buf bytes.Buffer
writer, err := NewMigrationWriter(&writers.WriterOptions{})
if err != nil {
t.Fatalf("Failed to create writer: %v", err)
}
writer.writer = &buf
if err := writer.WriteMigration(model, current); err != nil {
t.Fatalf("WriteMigration failed: %v", err)
}
output := buf.String()
if !strings.Contains(output, "USING gin (details jsonb_ops)") {
t.Fatalf("expected incompatible trigram hint on jsonb to fall back to jsonb_ops, got:\n%s", output)
}
}
func TestWriteMigration_WithAudit(t *testing.T) {
// Current database (empty)
current := models.InitDatabase("testdb")
@@ -282,3 +686,46 @@ func TestWriteMigration_NumericConstraintNames(t *testing.T) {
t.Error("Migration missing FOREIGN KEY")
}
}
func TestNewMigrationWriter_NilOptions(t *testing.T) {
writer, err := NewMigrationWriter(nil)
if err != nil {
t.Fatalf("NewMigrationWriter(nil) returned error: %v", err)
}
if writer == nil {
t.Fatal("expected writer instance")
}
if writer.options == nil {
t.Fatal("expected default writer options to be initialized")
}
}
func TestWriteMigration_NilCurrentTreatsDatabaseAsEmpty(t *testing.T) {
model := models.InitDatabase("testdb")
modelSchema := models.InitSchema("public")
table := models.InitTable("users", "public")
idCol := models.InitColumn("id", "users", "public")
idCol.Type = "integer"
idCol.NotNull = true
table.Columns["id"] = idCol
modelSchema.Tables = append(modelSchema.Tables, table)
model.Schemas = append(model.Schemas, modelSchema)
var buf bytes.Buffer
writer, err := NewMigrationWriter(nil)
if err != nil {
t.Fatalf("Failed to create writer: %v", err)
}
writer.writer = &buf
if err := writer.WriteMigration(model, nil); err != nil {
t.Fatalf("WriteMigration with nil current failed: %v", err)
}
output := buf.String()
if !strings.Contains(output, "CREATE TABLE") {
t.Fatalf("expected CREATE TABLE in migration output, got:\n%s", output)
}
}
+5 -15
View File
@@ -5,6 +5,8 @@ import (
"regexp"
"strings"
"unicode"
"git.warky.dev/wdevs/relspecgo/pkg/pgsql"
)
// TemplateFunctions returns a map of custom template functions
@@ -162,24 +164,12 @@ func quoteIdent(s string) string {
// Type conversion functions
// goTypeToSQL converts Go type to PostgreSQL type
// goTypeToSQL converts Go type to PostgreSQL type using the shared pgsql type map.
func goTypeToSQL(goType string) string {
typeMap := map[string]string{
"string": "text",
"int": "integer",
"int32": "integer",
"int64": "bigint",
"float32": "real",
"float64": "double precision",
"bool": "boolean",
"time.Time": "timestamp",
"[]byte": "bytea",
}
if sqlType, ok := typeMap[goType]; ok {
if sqlType, ok := pgsql.GoToPGSQLTypes[goType]; ok {
return sqlType
}
return "text" // Default
return "text"
}
// sqlTypeToGo converts PostgreSQL type to Go type
+26 -1
View File
@@ -8,6 +8,7 @@ import (
"text/template"
"git.warky.dev/wdevs/relspecgo/pkg/models"
"git.warky.dev/wdevs/relspecgo/pkg/writers"
)
//go:embed templates/*.tmpl
@@ -94,6 +95,16 @@ type AlterColumnTypeData struct {
TableName string
ColumnName string
NewType string
UsingExpr string
}
type AlterColumnTypeWithCheckData struct {
SchemaName string
TableName string
ColumnName string
NewType string
EquivalentTypes string
UsingExpr string
}
// AlterColumnDefaultData contains data for alter column default template
@@ -266,6 +277,7 @@ type CreatePrimaryKeyWithAutoGenCheckData struct {
ConstraintName string
AutoGenNames string // Comma-separated list of names like "'name1', 'name2'"
Columns string
ColumnNames string // Comma-separated list of quoted column names like "'id', 'tenant_id'"
}
// Execute methods for each template
@@ -300,6 +312,15 @@ func (te *TemplateExecutor) ExecuteAlterColumnType(data AlterColumnTypeData) (st
return buf.String(), nil
}
func (te *TemplateExecutor) ExecuteAlterColumnTypeWithCheck(data AlterColumnTypeWithCheckData) (string, error) {
var buf bytes.Buffer
err := te.templates.ExecuteTemplate(&buf, "alter_column_type_with_check.tmpl", data)
if err != nil {
return "", fmt.Errorf("failed to execute alter_column_type_with_check template: %w", err)
}
return buf.String(), nil
}
// ExecuteAlterColumnDefault executes the alter column default template
func (te *TemplateExecutor) ExecuteAlterColumnDefault(data AlterColumnDefaultData) (string, error) {
var buf bytes.Buffer
@@ -495,7 +516,11 @@ func BuildCreateTableData(schemaName string, table *models.Table) CreateTableDat
NotNull: col.NotNull,
}
if col.Default != nil {
colData.Default = fmt.Sprintf("%v", col.Default)
if value, ok := col.Default.(string); ok {
colData.Default = writers.QuoteDefaultValue(value, col.Type)
} else {
colData.Default = fmt.Sprintf("%v", col.Default)
}
}
columns = append(columns, colData)
}
@@ -1,2 +1,2 @@
ALTER TABLE {{qual_table .SchemaName .TableName}}
ALTER COLUMN {{quote_ident .ColumnName}} TYPE {{.NewType}};
ALTER COLUMN {{quote_ident .ColumnName}} TYPE {{.NewType}}{{if .UsingExpr}} USING {{.UsingExpr}}{{end}};
@@ -0,0 +1,22 @@
DO $$
DECLARE
current_type text;
BEGIN
SELECT pg_catalog.format_type(a.atttypid, a.atttypmod)
INTO current_type
FROM pg_attribute a
JOIN pg_class t ON t.oid = a.attrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = '{{.SchemaName}}'
AND t.relname = '{{.TableName}}'
AND a.attname = '{{.ColumnName}}'
AND a.attnum > 0
AND NOT a.attisdropped;
IF current_type IS NOT NULL
AND current_type <> ALL(ARRAY[{{.EquivalentTypes}}]) THEN
ALTER TABLE {{qual_table .SchemaName .TableName}}
ALTER COLUMN {{quote_ident .ColumnName}} TYPE {{.NewType}}{{if .UsingExpr}} USING {{.UsingExpr}}{{end}};
END IF;
END;
$$;
@@ -1,26 +1,42 @@
DO $$
DECLARE
auto_pk_name text;
current_pk_name text;
current_pk_matches boolean := false;
BEGIN
-- Drop auto-generated primary key if it exists
SELECT constraint_name INTO auto_pk_name
FROM information_schema.table_constraints
WHERE table_schema = '{{.SchemaName}}'
AND table_name = '{{.TableName}}'
AND constraint_type = 'PRIMARY KEY'
AND constraint_name IN ({{.AutoGenNames}});
SELECT tc.constraint_name,
COALESCE(
ARRAY(
SELECT a.attname::text
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
JOIN unnest(c.conkey) WITH ORDINALITY AS cols(attnum, ord)
ON TRUE
JOIN pg_attribute a
ON a.attrelid = t.oid
AND a.attnum = cols.attnum
WHERE c.contype = 'p'
AND n.nspname = '{{.SchemaName}}'
AND t.relname = '{{.TableName}}'
ORDER BY cols.ord
),
ARRAY[]::text[]
) = ARRAY[{{.ColumnNames}}]
INTO current_pk_name, current_pk_matches
FROM information_schema.table_constraints tc
WHERE tc.table_schema = '{{.SchemaName}}'
AND tc.table_name = '{{.TableName}}'
AND tc.constraint_type = 'PRIMARY KEY';
IF auto_pk_name IS NOT NULL THEN
EXECUTE 'ALTER TABLE {{qual_table .SchemaName .TableName}} DROP CONSTRAINT ' || quote_ident(auto_pk_name);
IF current_pk_name IS NOT NULL
AND NOT current_pk_matches
AND current_pk_name IN ({{.AutoGenNames}}) THEN
EXECUTE 'ALTER TABLE {{qual_table .SchemaName .TableName}} DROP CONSTRAINT ' || quote_ident(current_pk_name) || ' CASCADE';
END IF;
-- Add named primary key if it doesn't exist
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_schema = '{{.SchemaName}}'
AND table_name = '{{.TableName}}'
AND constraint_name = '{{.ConstraintName}}'
) THEN
-- Add the desired primary key only when no matching primary key already exists.
IF current_pk_name IS NULL
OR (NOT current_pk_matches AND current_pk_name IN ({{.AutoGenNames}})) THEN
ALTER TABLE {{qual_table .SchemaName .TableName}} ADD CONSTRAINT {{quote_ident .ConstraintName}} PRIMARY KEY ({{.Columns}});
END IF;
END;
+433 -86
View File
@@ -10,8 +10,6 @@ import (
"strings"
"time"
"github.com/jackc/pgx/v5"
"git.warky.dev/wdevs/relspecgo/pkg/models"
"git.warky.dev/wdevs/relspecgo/pkg/pgsql"
"git.warky.dev/wdevs/relspecgo/pkg/writers"
@@ -101,7 +99,11 @@ func (w *Writer) WriteDatabase(db *models.Database) error {
// Write header comment
fmt.Fprintf(w.writer, "-- PostgreSQL Database Schema\n")
fmt.Fprintf(w.writer, "-- Database: %s\n", db.Name)
fmt.Fprintf(w.writer, "-- Generated by RelSpec\n\n")
fmt.Fprintf(w.writer, "-- Generated by RelSpec\n")
if w.options.ContinueOnError {
fmt.Fprintf(w.writer, "\\set ON_ERROR_STOP off\n")
}
fmt.Fprintf(w.writer, "\n")
// Process each schema in the database
for _, schema := range db.Schemas {
@@ -145,6 +147,10 @@ func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, erro
statements = append(statements, fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS %s", schema.SQLName()))
}
if schemaRequiresPGTrgm(schema) {
statements = append(statements, `CREATE EXTENSION IF NOT EXISTS pg_trgm`)
}
// Phase 2: Create sequences
for _, table := range schema.Tables {
pk := table.GetPrimaryKey()
@@ -183,6 +189,12 @@ func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, erro
}
statements = append(statements, addColStmts...)
alterTypeStmts, err := w.GenerateAlterColumnTypeStatements(schema)
if err != nil {
return nil, fmt.Errorf("failed to generate alter column type statements: %w", err)
}
statements = append(statements, alterTypeStmts...)
// Phase 4: Primary keys
for _, table := range schema.Tables {
// First check for explicit PrimaryKeyConstraint
@@ -230,6 +242,7 @@ func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, erro
ConstraintName: pkName,
AutoGenNames: formatStringList(autoGenPKNames),
Columns: strings.Join(pkColumns, ", "),
ColumnNames: formatStringList(pkColumns),
}
stmt, err := w.executor.ExecuteCreatePrimaryKeyWithAutoGenCheck(data)
@@ -262,14 +275,11 @@ func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, erro
columnExprs := make([]string, 0, len(index.Columns))
for _, colName := range index.Columns {
colExpr := colName
if col, ok := table.Columns[colName]; ok {
// For GIN indexes on text columns, add operator class
if strings.EqualFold(indexType, "gin") && isTextType(col.Type) {
opClass := extractOperatorClass(index.Comment)
if opClass == "" {
opClass = "gin_trgm_ops"
if col, ok := resolveIndexColumn(table, colName); ok {
if strings.EqualFold(indexType, "gin") {
if opClass := ginOperatorClassForColumn(col, index.Comment); opClass != "" {
colExpr = fmt.Sprintf("%s %s", colName, opClass)
}
colExpr = fmt.Sprintf("%s %s", colName, opClass)
}
}
columnExprs = append(columnExprs, colExpr)
@@ -438,6 +448,33 @@ func (w *Writer) GenerateAddColumnStatements(schema *models.Schema) ([]string, e
return statements, nil
}
func (w *Writer) GenerateAlterColumnTypeStatements(schema *models.Schema) ([]string, error) {
statements := []string{}
statements = append(statements, fmt.Sprintf("-- Alter column types for schema: %s", schema.Name))
for _, table := range schema.Tables {
columns := getSortedColumns(table.Columns)
for _, col := range columns {
targetType := effectiveAlterColumnSQLType(col)
stmt, err := w.executor.ExecuteAlterColumnTypeWithCheck(AlterColumnTypeWithCheckData{
SchemaName: schema.Name,
TableName: table.Name,
ColumnName: col.Name,
NewType: targetType,
EquivalentTypes: equivalentTypeListSQL(targetType),
UsingExpr: buildAlterColumnUsingExpression(col.Name, targetType),
})
if err != nil {
return nil, fmt.Errorf("failed to generate alter column type for %s.%s.%s: %w", schema.Name, table.Name, col.Name, err)
}
statements = append(statements, stmt)
}
}
return statements, nil
}
// GenerateAddColumnsForDatabase generates ALTER TABLE ADD COLUMN statements for the entire database
func (w *Writer) GenerateAddColumnsForDatabase(db *models.Database) ([]string, error) {
statements := []string{}
@@ -490,30 +527,7 @@ func (w *Writer) generateCreateTableStatement(schema *models.Schema, table *mode
func (w *Writer) generateColumnDefinition(col *models.Column) string {
parts := []string{col.SQLName()}
// Type with length/precision - convert to valid PostgreSQL type
baseType := pgsql.ConvertSQLType(col.Type)
typeStr := baseType
// Only add size specifiers for types that support them
if col.Length > 0 && col.Precision == 0 {
if supportsLength(baseType) {
typeStr = fmt.Sprintf("%s(%d)", baseType, col.Length)
} else if isTextTypeWithoutLength(baseType) {
// Convert text with length to varchar
typeStr = fmt.Sprintf("varchar(%d)", col.Length)
}
// For types that don't support length (integer, bigint, etc.), ignore the length
} else if col.Precision > 0 {
if supportsPrecision(baseType) {
if col.Scale > 0 {
typeStr = fmt.Sprintf("%s(%d,%d)", baseType, col.Precision, col.Scale)
} else {
typeStr = fmt.Sprintf("%s(%d)", baseType, col.Precision)
}
}
// For types that don't support precision, ignore it
}
parts = append(parts, typeStr)
parts = append(parts, effectiveColumnSQLType(col))
// NOT NULL
if col.NotNull {
@@ -524,15 +538,7 @@ func (w *Writer) generateColumnDefinition(col *models.Column) string {
if col.Default != nil {
switch v := col.Default.(type) {
case string:
// Strip backticks - DBML uses them for SQL expressions but PostgreSQL doesn't
cleanDefault := stripBackticks(v)
if strings.HasPrefix(cleanDefault, "nextval") || strings.HasPrefix(cleanDefault, "CURRENT_") || strings.Contains(cleanDefault, "()") {
parts = append(parts, fmt.Sprintf("DEFAULT %s", cleanDefault))
} else if cleanDefault == "true" || cleanDefault == "false" {
parts = append(parts, fmt.Sprintf("DEFAULT %s", cleanDefault))
} else {
parts = append(parts, fmt.Sprintf("DEFAULT '%s'", escapeQuote(cleanDefault)))
}
parts = append(parts, fmt.Sprintf("DEFAULT %s", writers.QuoteDefaultValue(stripBackticks(v), col.Type)))
case bool:
parts = append(parts, fmt.Sprintf("DEFAULT %v", v))
default:
@@ -543,6 +549,64 @@ func (w *Writer) generateColumnDefinition(col *models.Column) string {
return strings.Join(parts, " ")
}
func effectiveColumnSQLType(col *models.Column) string {
if col == nil {
return ""
}
baseType := pgsql.ConvertSQLType(col.Type)
typeStr := baseType
hasExplicitTypeModifier := pgsql.HasExplicitTypeModifier(baseType)
if !hasExplicitTypeModifier && col.Length > 0 && col.Precision == 0 {
if pgsql.SupportsLength(baseType) {
typeStr = fmt.Sprintf("%s(%d)", baseType, col.Length)
} else if isTextTypeWithoutLength(baseType) {
typeStr = fmt.Sprintf("varchar(%d)", col.Length)
}
} else if !hasExplicitTypeModifier && col.Precision > 0 {
if pgsql.SupportsPrecision(baseType) {
if col.Scale > 0 {
typeStr = fmt.Sprintf("%s(%d,%d)", baseType, col.Precision, col.Scale)
} else {
typeStr = fmt.Sprintf("%s(%d)", baseType, col.Precision)
}
}
}
return typeStr
}
func effectiveAlterColumnSQLType(col *models.Column) string {
typeStr := effectiveColumnSQLType(col)
switch strings.ToLower(strings.TrimSpace(typeStr)) {
case "smallserial":
return "smallint"
case "serial":
return "integer"
case "bigserial":
return "bigint"
default:
return typeStr
}
}
func buildAlterColumnUsingExpression(columnName, targetType string) string {
if strings.TrimSpace(columnName) == "" || strings.TrimSpace(targetType) == "" {
return ""
}
return fmt.Sprintf("%s::%s", quoteIdent(columnName), targetType)
}
func equivalentTypeListSQL(sqlType string) string {
variants := pgsql.EquivalentSQLTypeVariants(sqlType)
quoted := make([]string, 0, len(variants))
for _, variant := range variants {
quoted = append(quoted, fmt.Sprintf("'%s'", escapeQuote(variant)))
}
return strings.Join(quoted, ", ")
}
// WriteSchema writes a single schema and all its tables
func (w *Writer) WriteSchema(schema *models.Schema) error {
if w.writer == nil {
@@ -554,6 +618,10 @@ func (w *Writer) WriteSchema(schema *models.Schema) error {
return err
}
if err := w.writeRequiredExtensions(schema); err != nil {
return err
}
// Phase 2: Create sequences (priority 80)
if err := w.writeSequences(schema); err != nil {
return err
@@ -569,6 +637,10 @@ func (w *Writer) WriteSchema(schema *models.Schema) error {
return err
}
if err := w.writeAlterColumnTypes(schema); err != nil {
return err
}
// Phase 4: Create primary keys (priority 160)
if err := w.writePrimaryKeys(schema); err != nil {
return err
@@ -669,6 +741,16 @@ func (w *Writer) writeCreateSchema(schema *models.Schema) error {
return nil
}
func (w *Writer) writeRequiredExtensions(schema *models.Schema) error {
if !schemaRequiresPGTrgm(schema) {
return nil
}
fmt.Fprintln(w.writer, "CREATE EXTENSION IF NOT EXISTS pg_trgm;")
fmt.Fprintln(w.writer)
return nil
}
// writeSequences generates CREATE SEQUENCE statements for identity columns
func (w *Writer) writeSequences(schema *models.Schema) error {
fmt.Fprintf(w.writer, "-- Sequences for schema: %s\n", schema.Name)
@@ -762,6 +844,21 @@ func (w *Writer) writeAddColumns(schema *models.Schema) error {
return nil
}
func (w *Writer) writeAlterColumnTypes(schema *models.Schema) error {
fmt.Fprintf(w.writer, "-- Alter column types for schema: %s\n", schema.Name)
statements, err := w.GenerateAlterColumnTypeStatements(schema)
if err != nil {
return err
}
for _, stmt := range statements[1:] {
fmt.Fprint(w.writer, stmt)
fmt.Fprint(w.writer, "\n")
}
return nil
}
// writePrimaryKeys generates ALTER TABLE statements for primary keys
func (w *Writer) writePrimaryKeys(schema *models.Schema) error {
fmt.Fprintf(w.writer, "-- Primary keys for schema: %s\n", schema.Name)
@@ -815,6 +912,7 @@ func (w *Writer) writePrimaryKeys(schema *models.Schema) error {
ConstraintName: pkName,
AutoGenNames: formatStringList(autoGenPKNames),
Columns: strings.Join(columnNames, ", "),
ColumnNames: formatStringList(columnNames),
}
sql, err := w.executor.ExecuteCreatePrimaryKeyWithAutoGenCheck(data)
@@ -862,15 +960,13 @@ func (w *Writer) writeIndexes(schema *models.Schema) error {
// Build column list with operator class support for GIN indexes
columnExprs := make([]string, 0, len(index.Columns))
for _, colName := range index.Columns {
if col, ok := table.Columns[colName]; ok {
if col, ok := resolveIndexColumn(table, colName); ok {
colExpr := col.SQLName()
// For GIN indexes on text columns, add operator class
if strings.EqualFold(index.Type, "gin") && isTextType(col.Type) {
opClass := extractOperatorClass(index.Comment)
if opClass == "" {
opClass = "gin_trgm_ops"
if strings.EqualFold(index.Type, "gin") {
opClass := ginOperatorClassForColumn(col, index.Comment)
if opClass != "" {
colExpr = fmt.Sprintf("%s %s", col.SQLName(), opClass)
}
colExpr = fmt.Sprintf("%s %s", col.SQLName(), opClass)
}
columnExprs = append(columnExprs, colExpr)
}
@@ -1257,46 +1353,128 @@ func isIntegerType(colType string) bool {
}
// isTextType checks if a column type is a text type (for GIN index operator class)
func isTextType(colType string) bool {
textTypes := []string{"text", "varchar", "character varying", "char", "character", "string"}
lowerType := strings.ToLower(colType)
for _, t := range textTypes {
if strings.HasPrefix(lowerType, t) {
return true
}
}
return false
}
// supportsLength checks if a PostgreSQL type supports length specification
func supportsLength(colType string) bool {
lengthTypes := []string{"varchar", "character varying", "char", "character", "bit", "bit varying", "varbit"}
lowerType := strings.ToLower(colType)
for _, t := range lengthTypes {
if lowerType == t || strings.HasPrefix(lowerType, t+"(") {
return true
}
}
return false
}
// supportsPrecision checks if a PostgreSQL type supports precision/scale specification
func supportsPrecision(colType string) bool {
precisionTypes := []string{"numeric", "decimal", "time", "timestamp", "timestamptz", "timestamp with time zone", "timestamp without time zone", "time with time zone", "time without time zone", "interval"}
lowerType := strings.ToLower(colType)
for _, t := range precisionTypes {
if lowerType == t || strings.HasPrefix(lowerType, t+"(") {
return true
}
}
return false
}
// func isTextType(colType string) bool {
// textTypes := []string{"text", "varchar", "character varying", "char", "character", "string"}
// lowerType := strings.ToLower(colType)
// if strings.HasSuffix(lowerType, "[]") {
// return false
// }
// for _, t := range textTypes {
// if strings.HasPrefix(lowerType, t) {
// return true
// }
// }
// return false
// }
// isTextTypeWithoutLength checks if type is text (which should convert to varchar when length is specified)
func isTextTypeWithoutLength(colType string) bool {
return strings.EqualFold(colType, "text")
}
func ginOperatorClassForColumn(col *models.Column, comment string) string {
if col == nil {
return ""
}
sqlType := effectiveColumnSQLType(col)
baseType := pgsql.CanonicalizeBaseType(pgsql.ExtractBaseTypeLower(sqlType))
isArray := pgsql.IsArrayType(sqlType)
requested := extractOperatorClass(comment)
if requested != "" && ginOperatorClassCompatible(baseType, isArray, requested) {
return requested
}
if isArray {
return "array_ops"
}
switch {
case isTextGinBaseType(baseType):
return "gin_trgm_ops"
case baseType == "jsonb":
return "jsonb_ops"
default:
return requested
}
}
func ginOperatorClassCompatible(baseType string, isArray bool, opClass string) bool {
switch opClass {
case "gin_trgm_ops", "gin_bigm_ops":
return !isArray && isTextGinBaseType(baseType)
case "jsonb_ops", "jsonb_path_ops":
return !isArray && baseType == "jsonb"
case "array_ops":
return isArray
default:
return true
}
}
func isTextGinBaseType(baseType string) bool {
switch baseType {
case "text", "varchar", "character varying", "char", "character", "string", "citext", "bpchar":
return true
default:
return false
}
}
func schemaRequiresPGTrgm(schema *models.Schema) bool {
if schema == nil {
return false
}
for _, table := range schema.Tables {
if table == nil {
continue
}
for _, index := range table.Indexes {
if index == nil || !strings.EqualFold(index.Type, "gin") {
continue
}
for _, colName := range index.Columns {
col, ok := resolveIndexColumn(table, colName)
if !ok || col == nil {
continue
}
if ginOperatorClassForColumn(col, index.Comment) == "gin_trgm_ops" {
return true
}
}
}
}
return false
}
func resolveIndexColumn(table *models.Table, colName string) (*models.Column, bool) {
if table == nil {
return nil, false
}
if col, ok := table.Columns[colName]; ok && col != nil {
return col, true
}
normalized := strings.ToLower(strings.Trim(colName, `"`))
for key, col := range table.Columns {
if col == nil {
continue
}
if strings.ToLower(strings.Trim(key, `"`)) == normalized {
return col, true
}
if strings.ToLower(strings.Trim(col.Name, `"`)) == normalized {
return col, true
}
if strings.ToLower(strings.Trim(col.SQLName(), `"`)) == normalized {
return col, true
}
}
return nil, false
}
// formatStringList formats a list of strings as a SQL-safe comma-separated quoted list
func formatStringList(items []string) string {
quoted := make([]string, len(items))
@@ -1376,7 +1554,7 @@ func (w *Writer) executeDatabaseSQL(db *models.Database, connString string) erro
// Connect to database
ctx := context.Background()
conn, err := pgx.Connect(ctx, connString)
conn, err := pgsql.Connect(ctx, connString, "writer-pgsql")
if err != nil {
return fmt.Errorf("failed to connect to database: %w", err)
}
@@ -1415,7 +1593,12 @@ func (w *Writer) executeDatabaseSQL(db *models.Database, connString string) erro
}
stmtType := detectStatementType(stmtTrimmed)
fmt.Fprintf(os.Stderr, "Executing statement %d/%d [%s]...\n", i+1, len(statements), stmtType)
stmtCtx := extractStatementContext(stmtTrimmed)
if stmtCtx != "" {
fmt.Fprintf(os.Stderr, "Executing statement %d/%d [%s] %s...\n", i+1, len(statements), stmtType, stmtCtx)
} else {
fmt.Fprintf(os.Stderr, "Executing statement %d/%d [%s]...\n", i+1, len(statements), stmtType)
}
_, execErr := conn.Exec(ctx, stmt)
if execErr != nil {
@@ -1550,6 +1733,170 @@ func getCurrentTimestamp() string {
return time.Now().Format("2006-01-02 15:04:05")
}
// extractStatementContext returns a human-readable schema/table/column context string for a SQL statement.
func extractStatementContext(stmt string) string {
upper := strings.ToUpper(stmt)
// DO $$ blocks: extract identifiers from information_schema WHERE clauses
if strings.HasPrefix(upper, "DO $$") || strings.HasPrefix(upper, "DO $") {
schema := extractSQLStringValue(stmt, "table_schema")
table := extractSQLStringValue(stmt, "table_name")
column := extractSQLStringValue(stmt, "column_name")
constraint := extractSQLStringValue(stmt, "constraint_name")
return buildStmtContext(schema, table, column, constraint)
}
// ALTER TABLE [schema.]table ...
if strings.HasPrefix(upper, "ALTER TABLE") {
schema, table := parseQualifiedIdent(strings.TrimSpace(stmt[11:]))
if strings.Contains(upper, "ADD COLUMN") {
col := firstIdentAfterKeyword(stmt, upper, "ADD COLUMN")
return buildStmtContext(schema, table, col, "")
}
if strings.Contains(upper, "ALTER COLUMN") {
col := firstIdentAfterKeyword(stmt, upper, "ALTER COLUMN")
return buildStmtContext(schema, table, col, "")
}
if strings.Contains(upper, "ADD CONSTRAINT") {
con := firstIdentAfterKeyword(stmt, upper, "ADD CONSTRAINT")
return buildStmtContext(schema, table, "", con)
}
if strings.Contains(upper, "DROP CONSTRAINT") {
con := firstIdentAfterKeyword(stmt, upper, "DROP CONSTRAINT")
return buildStmtContext(schema, table, "", con)
}
return buildStmtContext(schema, table, "", "")
}
// CREATE TABLE [IF NOT EXISTS] [schema.]table
if strings.HasPrefix(upper, "CREATE TABLE") {
rest := strings.TrimSpace(stmt[12:])
if strings.HasPrefix(strings.ToUpper(rest), "IF NOT EXISTS") {
rest = strings.TrimSpace(rest[13:])
}
schema, table := parseQualifiedIdent(rest)
return buildStmtContext(schema, table, "", "")
}
// CREATE SCHEMA name
if strings.HasPrefix(upper, "CREATE SCHEMA") {
name := firstBareIdent(strings.TrimSpace(stmt[13:]))
return name
}
// CREATE [UNIQUE] INDEX ... ON [schema.]table
if strings.HasPrefix(upper, "CREATE INDEX") || strings.HasPrefix(upper, "CREATE UNIQUE INDEX") {
onIdx := strings.Index(upper, " ON ")
if onIdx != -1 {
schema, table := parseQualifiedIdent(strings.TrimSpace(stmt[onIdx+4:]))
return buildStmtContext(schema, table, "", "")
}
}
// COMMENT ON TABLE [schema.]table
if strings.HasPrefix(upper, "COMMENT ON TABLE") {
schema, table := parseQualifiedIdent(strings.TrimSpace(stmt[16:]))
return buildStmtContext(schema, table, "", "")
}
// COMMENT ON COLUMN [schema.]table.col
if strings.HasPrefix(upper, "COMMENT ON COLUMN") {
return firstBareIdent(strings.TrimSpace(stmt[17:]))
}
return ""
}
// extractSQLStringValue extracts 'value' from patterns like: key = 'value' (case-insensitive key match).
func extractSQLStringValue(stmt, key string) string {
lower := strings.ToLower(stmt)
idx := strings.Index(lower, strings.ToLower(key))
if idx == -1 {
return ""
}
rest := strings.TrimSpace(stmt[idx+len(key):])
eqIdx := strings.IndexByte(rest, '=')
if eqIdx == -1 || eqIdx > 5 {
return ""
}
rest = strings.TrimSpace(rest[eqIdx+1:])
if len(rest) == 0 || rest[0] != '\'' {
return ""
}
rest = rest[1:]
end := strings.IndexByte(rest, '\'')
if end == -1 {
return ""
}
return rest[:end]
}
// parseQualifiedIdent extracts (schema, name) from the start of s, handling optional "schema"."table" quoting.
func parseQualifiedIdent(s string) (schema, name string) {
token := firstBareIdent(s)
parts := strings.SplitN(token, ".", 2)
if len(parts) == 2 {
return stripQuotes(parts[0]), stripQuotes(parts[1])
}
return "", stripQuotes(token)
}
// firstBareIdent returns the first whitespace/delimiter-terminated token, stripping outer quotes.
func firstBareIdent(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
end := strings.IndexAny(s, " \t\n(,;")
if end == -1 {
return s
}
return s[:end]
}
// firstIdentAfterKeyword returns the first identifier token after keyword (matched case-insensitively via upperStmt).
func firstIdentAfterKeyword(stmt, upperStmt, keyword string) string {
idx := strings.Index(upperStmt, keyword)
if idx == -1 {
return ""
}
return stripQuotes(firstBareIdent(strings.TrimSpace(stmt[idx+len(keyword):])))
}
// stripQuotes removes surrounding double-quotes from an identifier.
func stripQuotes(s string) string {
return strings.Trim(s, "\"")
}
// buildStmtContext assembles a display string from available identifiers.
func buildStmtContext(schema, table, column, constraint string) string {
var b strings.Builder
if schema != "" && table != "" {
b.WriteString(schema)
b.WriteByte('.')
b.WriteString(table)
} else if table != "" {
b.WriteString(table)
}
if column != "" {
if b.Len() > 0 {
b.WriteByte(' ')
}
b.WriteByte('(')
b.WriteString(column)
b.WriteByte(')')
}
if constraint != "" {
if b.Len() > 0 {
b.WriteByte(' ')
}
b.WriteByte('[')
b.WriteString(constraint)
b.WriteByte(']')
}
return b.String()
}
// detectStatementType detects the type of SQL statement for logging
func detectStatementType(stmt string) string {
upperStmt := strings.ToUpper(stmt)
+295 -13
View File
@@ -87,6 +87,117 @@ func TestWriteDatabase(t *testing.T) {
}
}
func TestWriteDatabase_GinIndexOnTextArrayDoesNotUseTrigramOperatorClass(t *testing.T) {
db := models.InitDatabase("testdb")
schema := models.InitSchema("public")
table := models.InitTable("plans", "public")
tagsCol := models.InitColumn("tags", "plans", "public")
tagsCol.Type = "text[]"
table.Columns["tags"] = tagsCol
index := &models.Index{
Name: "idx_plans_tags",
Type: "gin",
Columns: []string{"tags"},
}
table.Indexes[index.Name] = index
schema.Tables = append(schema.Tables, table)
db.Schemas = append(db.Schemas, schema)
var buf bytes.Buffer
writer := NewWriter(&writers.WriterOptions{})
writer.writer = &buf
if err := writer.WriteDatabase(db); err != nil {
t.Fatalf("WriteDatabase failed: %v", err)
}
output := buf.String()
if !strings.Contains(output, `USING gin (tags array_ops)`) {
t.Fatalf("expected GIN index on array column with array_ops, got:\n%s", output)
}
if strings.Contains(output, "gin_trgm_ops") {
t.Fatalf("did not expect gin_trgm_ops for text[] column, got:\n%s", output)
}
}
func TestWriteDatabase_GinIndexOnQuotedTextColumnUsesTrigramOperatorClass(t *testing.T) {
db := models.InitDatabase("testdb")
schema := models.InitSchema("public")
table := models.InitTable("agent_personas", "public")
nameCol := models.InitColumn("name", "agent_personas", "public")
nameCol.Type = "text"
table.Columns["name"] = nameCol
index := &models.Index{
Name: "idx_agent_personas_name_gin",
Type: "gin",
Columns: []string{`"name"`},
}
table.Indexes[index.Name] = index
schema.Tables = append(schema.Tables, table)
db.Schemas = append(db.Schemas, schema)
var buf bytes.Buffer
writer := NewWriter(&writers.WriterOptions{})
writer.writer = &buf
if err := writer.WriteDatabase(db); err != nil {
t.Fatalf("WriteDatabase failed: %v", err)
}
output := buf.String()
if !strings.Contains(output, `CREATE EXTENSION IF NOT EXISTS pg_trgm`) {
t.Fatalf("expected trigram extension for text GIN index, got:\n%s", output)
}
if !strings.Contains(output, `USING gin (name gin_trgm_ops)`) {
t.Fatalf("expected quoted text GIN index to include gin_trgm_ops, got:\n%s", output)
}
}
func TestWriteDatabase_GinIndexOnJSONBUsesJSONBOperatorClass(t *testing.T) {
db := models.InitDatabase("testdb")
schema := models.InitSchema("public")
table := models.InitTable("learnings", "public")
detailsCol := models.InitColumn("details", "learnings", "public")
detailsCol.Type = "jsonb"
table.Columns["details"] = detailsCol
index := &models.Index{
Name: "idx_learnings_details",
Type: "gin",
Columns: []string{"details"},
}
table.Indexes[index.Name] = index
schema.Tables = append(schema.Tables, table)
db.Schemas = append(db.Schemas, schema)
var buf bytes.Buffer
writer := NewWriter(&writers.WriterOptions{})
writer.writer = &buf
if err := writer.WriteDatabase(db); err != nil {
t.Fatalf("WriteDatabase failed: %v", err)
}
output := buf.String()
if !strings.Contains(output, `USING gin (details jsonb_ops)`) {
t.Fatalf("expected GIN jsonb index to include jsonb_ops, got:\n%s", output)
}
if strings.Contains(output, "gin_trgm_ops") {
t.Fatalf("did not expect gin_trgm_ops for jsonb column, got:\n%s", output)
}
}
func TestWriteForeignKeys(t *testing.T) {
// Create a test database with two related tables
db := models.InitDatabase("testdb")
@@ -426,11 +537,11 @@ func TestWriteAllConstraintTypes(t *testing.T) {
// Verify all constraint types are present
expectedConstraints := map[string]string{
"Primary Key": "PRIMARY KEY",
"Unique": "ADD CONSTRAINT uq_order_number UNIQUE (order_number)",
"Check (total)": "ADD CONSTRAINT ck_total_positive CHECK (total > 0)",
"Check (status)": "ADD CONSTRAINT ck_status_valid CHECK (status IN ('pending', 'completed', 'cancelled'))",
"Foreign Key": "FOREIGN KEY",
"Primary Key": "PRIMARY KEY",
"Unique": "ADD CONSTRAINT uq_order_number UNIQUE (order_number)",
"Check (total)": "ADD CONSTRAINT ck_total_positive CHECK (total > 0)",
"Check (status)": "ADD CONSTRAINT ck_status_valid CHECK (status IN ('pending', 'completed', 'cancelled'))",
"Foreign Key": "FOREIGN KEY",
}
for name, expected := range expectedConstraints {
@@ -636,9 +747,14 @@ func TestPrimaryKeyExistenceCheck(t *testing.T) {
t.Errorf("Output missing logic to drop auto-generated primary key\nFull output:\n%s", output)
}
// Verify it checks for our specific named constraint before adding it
if !strings.Contains(output, "constraint_name = 'pk_public_products'") {
t.Errorf("Output missing check for our named primary key constraint\nFull output:\n%s", output)
// Verify it compares the current primary key columns before dropping/recreating
if !strings.Contains(output, "current_pk_matches") || !strings.Contains(output, "ARRAY['id']") {
t.Errorf("Output missing safe primary key comparison logic\nFull output:\n%s", output)
}
// Verify it only adds the desired key when no PK exists or an auto-generated mismatch was dropped
if !strings.Contains(output, "current_pk_name IS NULL") || !strings.Contains(output, "current_pk_name IN ('products_pkey', 'public_products_pkey')") {
t.Errorf("Output missing guarded primary key creation logic\nFull output:\n%s", output)
}
}
@@ -715,11 +831,11 @@ func TestColumnSizeSpecifiers(t *testing.T) {
// Verify valid patterns ARE present
validPatterns := []string{
"integer", // without size
"bigint", // without size
"smallint", // without size
"varchar(100)", // text converted to varchar with length
"varchar(50)", // varchar with length
"integer", // without size
"bigint", // without size
"smallint", // without size
"varchar(100)", // text converted to varchar with length
"varchar(50)", // varchar with length
"decimal(19,4)", // decimal with precision and scale
}
for _, pattern := range validPatterns {
@@ -729,6 +845,93 @@ func TestColumnSizeSpecifiers(t *testing.T) {
}
}
func TestWriteDatabase_PrimaryKeyTemplateDoesNotDropMatchingAutoPrimaryKey(t *testing.T) {
db := models.InitDatabase("testdb")
schema := models.InitSchema("public")
table := models.InitTable("learnings", "public")
idCol := models.InitColumn("id", "learnings", "public")
idCol.Type = "bigint"
idCol.IsPrimaryKey = true
table.Columns["id"] = idCol
parentCol := models.InitColumn("duplicate_of_learning_id", "learnings", "public")
parentCol.Type = "bigint"
table.Columns["duplicate_of_learning_id"] = parentCol
schema.Tables = append(schema.Tables, table)
db.Schemas = append(db.Schemas, schema)
var buf bytes.Buffer
writer := NewWriter(&writers.WriterOptions{})
writer.writer = &buf
if err := writer.WriteDatabase(db); err != nil {
t.Fatalf("WriteDatabase failed: %v", err)
}
output := buf.String()
if !strings.Contains(output, "current_pk_matches") {
t.Fatalf("expected generated SQL to compare current PK columns, got:\n%s", output)
}
if !strings.Contains(output, "ARRAY['id']") {
t.Fatalf("expected generated SQL to compare against desired PK columns, got:\n%s", output)
}
if !strings.Contains(output, "NOT current_pk_matches") {
t.Fatalf("expected generated SQL to avoid dropping matching PKs, got:\n%s", output)
}
}
func TestGenerateColumnDefinition_PreservesExplicitTypeModifiers(t *testing.T) {
writer := NewWriter(&writers.WriterOptions{})
cases := []struct {
name string
colType string
length int
precision int
scale int
wantType string
}{
{
name: "character varying already includes length",
colType: "character varying(50)",
length: 50,
wantType: "character varying(50)",
},
{
name: "numeric already includes precision",
colType: "numeric(10,2)",
precision: 10,
scale: 2,
wantType: "numeric(10,2)",
},
{
name: "custom vector modifier preserved",
colType: "vector(1536)",
wantType: "vector(1536)",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
col := models.InitColumn("sample", "events", "public")
col.Type = tc.colType
col.Length = tc.length
col.Precision = tc.precision
col.Scale = tc.scale
def := writer.generateColumnDefinition(col)
if !strings.Contains(def, " "+tc.wantType+" ") && !strings.HasSuffix(def, " "+tc.wantType) {
t.Fatalf("generated definition %q does not contain expected type %q", def, tc.wantType)
}
if strings.Contains(def, ")(") {
t.Fatalf("generated definition %q appears to duplicate modifiers", def)
}
})
}
}
func TestGenerateAddColumnStatements(t *testing.T) {
// Create a test database with tables that have new columns
db := models.InitDatabase("testdb")
@@ -855,3 +1058,82 @@ func TestWriteAddColumnStatements(t *testing.T) {
t.Errorf("Output missing DO block\nFull output:\n%s", output)
}
}
func TestWriteSchema_EmitsGuardedAlterColumnTypeStatements(t *testing.T) {
db := models.InitDatabase("testdb")
schema := models.InitSchema("public")
table := models.InitTable("agent_skills", "public")
nameCol := models.InitColumn("name", "agent_skills", "public")
nameCol.Type = "character varying"
nameCol.Length = 255
table.Columns["name"] = nameCol
tagsCol := models.InitColumn("tags", "agent_skills", "public")
tagsCol.Type = "text[]"
table.Columns["tags"] = tagsCol
schema.Tables = append(schema.Tables, table)
db.Schemas = append(db.Schemas, schema)
var buf bytes.Buffer
writer := NewWriter(&writers.WriterOptions{})
writer.writer = &buf
if err := writer.WriteDatabase(db); err != nil {
t.Fatalf("WriteDatabase failed: %v", err)
}
output := buf.String()
if !strings.Contains(output, "-- Alter column types for schema: public") {
t.Fatalf("expected alter column type section, got:\n%s", output)
}
if !strings.Contains(output, "pg_catalog.format_type") {
t.Fatalf("expected guarded live-type check, got:\n%s", output)
}
if !strings.Contains(output, "ALTER COLUMN name TYPE character varying(255)") {
t.Fatalf("expected guarded alter for character varying(255), got:\n%s", output)
}
if !strings.Contains(output, "ARRAY['varchar(255)', 'character varying(255)']") {
t.Fatalf("expected equivalent type spellings for varchar guard, got:\n%s", output)
}
if !strings.Contains(output, "ALTER COLUMN tags TYPE text[]") {
t.Fatalf("expected guarded alter for array type, got:\n%s", output)
}
if !strings.Contains(output, `ALTER COLUMN tags TYPE text[] USING tags::text[];`) {
t.Fatalf("expected guarded alter for array type to include USING cast, got:\n%s", output)
}
}
func TestWriteSchema_UsesStorageTypeForSerialAlterStatements(t *testing.T) {
db := models.InitDatabase("testdb")
schema := models.InitSchema("public")
table := models.InitTable("learnings", "public")
idCol := models.InitColumn("id", "learnings", "public")
idCol.Type = "bigserial"
table.Columns["id"] = idCol
schema.Tables = append(schema.Tables, table)
db.Schemas = append(db.Schemas, schema)
var buf bytes.Buffer
writer := NewWriter(&writers.WriterOptions{})
writer.writer = &buf
if err := writer.WriteDatabase(db); err != nil {
t.Fatalf("WriteDatabase failed: %v", err)
}
output := buf.String()
if !strings.Contains(output, "ALTER COLUMN id TYPE bigint") {
t.Fatalf("expected serial alter to use bigint storage type, got:\n%s", output)
}
if strings.Contains(output, "ALTER COLUMN id TYPE bigserial;") {
t.Fatalf("did not expect invalid bigserial alter statement, got:\n%s", output)
}
if !strings.Contains(output, `ALTER COLUMN id TYPE bigint USING id::bigint;`) {
t.Fatalf("expected serial alter to include USING cast, got:\n%s", output)
}
}
+17 -2
View File
@@ -61,7 +61,7 @@ func (w *Writer) databaseToPrisma(db *models.Database) string {
sb.WriteString("\n")
// Write generator block
sb.WriteString(w.generateGenerator())
sb.WriteString(w.generateGenerator(db))
sb.WriteString("\n")
// Process all schemas (typically just one in Prisma)
@@ -114,13 +114,28 @@ func (w *Writer) generateDatasource(db *models.Database) string {
}
// generateGenerator generates the generator block
func (w *Writer) generateGenerator() string {
func (w *Writer) generateGenerator(db *models.Database) string {
if w.usePrisma7Generator(db) {
return `generator client {
provider = "prisma-client"
output = "./generated"
}
`
}
return `generator client {
provider = "prisma-client-js"
}
`
}
func (w *Writer) usePrisma7Generator(db *models.Database) bool {
if w.options != nil && w.options.Prisma7 {
return true
}
return db != nil && db.SourceFormat == "prisma7"
}
// enumToPrisma converts an Enum to Prisma enum block
func (w *Writer) enumToPrisma(enum *models.Enum) string {
var sb strings.Builder
+52
View File
@@ -0,0 +1,52 @@
package prisma
import (
"strings"
"testing"
"git.warky.dev/wdevs/relspecgo/pkg/models"
"git.warky.dev/wdevs/relspecgo/pkg/writers"
)
func TestGenerateGenerator_DefaultsToPrismaClientJS(t *testing.T) {
t.Parallel()
writer := NewWriter(&writers.WriterOptions{})
db := models.InitDatabase("testdb")
got := writer.generateGenerator(db)
if !strings.Contains(got, `provider = "prisma-client-js"`) {
t.Fatalf("expected prisma-client-js generator, got:\n%s", got)
}
if strings.Contains(got, `output = "./generated"`) {
t.Fatalf("did not expect prisma7 output path in default generator:\n%s", got)
}
}
func TestGenerateGenerator_Prisma7FlagUsesPrismaClient(t *testing.T) {
t.Parallel()
writer := NewWriter(&writers.WriterOptions{Prisma7: true})
db := models.InitDatabase("testdb")
got := writer.generateGenerator(db)
if !strings.Contains(got, `provider = "prisma-client"`) {
t.Fatalf("expected prisma-client generator, got:\n%s", got)
}
if !strings.Contains(got, `output = "./generated"`) {
t.Fatalf("expected prisma7 output path, got:\n%s", got)
}
}
func TestGenerateGenerator_Prisma7SourceFormatUsesPrismaClient(t *testing.T) {
t.Parallel()
writer := NewWriter(&writers.WriterOptions{})
db := models.InitDatabase("testdb")
db.SourceFormat = "prisma7"
got := writer.generateGenerator(db)
if !strings.Contains(got, `provider = "prisma-client"`) {
t.Fatalf("expected prisma-client generator from source format, got:\n%s", got)
}
}
+3 -2
View File
@@ -8,6 +8,7 @@ import (
"github.com/jackc/pgx/v5"
"git.warky.dev/wdevs/relspecgo/pkg/models"
"git.warky.dev/wdevs/relspecgo/pkg/pgsql"
"git.warky.dev/wdevs/relspecgo/pkg/writers"
)
@@ -42,7 +43,7 @@ func (w *Writer) WriteDatabase(db *models.Database) error {
// Connect to database
ctx := context.Background()
conn, err := pgx.Connect(ctx, connString)
conn, err := pgsql.Connect(ctx, connString, "writer-sqlexec")
if err != nil {
return fmt.Errorf("failed to connect to database: %w", err)
}
@@ -72,7 +73,7 @@ func (w *Writer) WriteSchema(schema *models.Schema) error {
// Connect to database
ctx := context.Background()
conn, err := pgx.Connect(ctx, connString)
conn, err := pgsql.Connect(ctx, connString, "writer-sqlexec")
if err != nil {
return fmt.Errorf("failed to connect to database: %w", err)
}
+17 -60
View File
@@ -2,9 +2,11 @@ package sqlite
import (
"strings"
sqlitepkg "git.warky.dev/wdevs/relspecgo/pkg/sqlite"
)
// SQLite type affinities
// SQLite type affinity constants
const (
TypeText = "TEXT"
TypeInteger = "INTEGER"
@@ -13,72 +15,27 @@ const (
TypeBlob = "BLOB"
)
// MapPostgreSQLType maps PostgreSQL data types to SQLite type affinities
// MapTypeToSQLite maps any SQL or Go canonical type to a SQLite type affinity.
// Handles input from any reader (PostgreSQL, MSSQL, SQLite, Go canonical).
func MapTypeToSQLite(colType string) string {
return sqlitepkg.ConvertCanonicalToSQLite(colType)
}
// MapPostgreSQLType is an alias for MapTypeToSQLite kept for compatibility.
//
// Deprecated: use MapTypeToSQLite.
func MapPostgreSQLType(pgType string) string {
// Normalize the type
normalized := strings.ToLower(strings.TrimSpace(pgType))
// Remove array notation if present
normalized = strings.TrimSuffix(normalized, "[]")
// Remove precision/scale if present
if idx := strings.Index(normalized, "("); idx != -1 {
normalized = normalized[:idx]
}
// Map to SQLite type affinity
switch normalized {
// TEXT affinity
case "varchar", "character varying", "text", "char", "character",
"citext", "uuid", "timestamp", "timestamptz", "timestamp with time zone",
"timestamp without time zone", "date", "time", "timetz", "time with time zone",
"time without time zone", "json", "jsonb", "xml", "inet", "cidr", "macaddr":
return TypeText
// INTEGER affinity
case "int", "int2", "int4", "int8", "integer", "smallint", "bigint",
"serial", "smallserial", "bigserial", "boolean", "bool":
return TypeInteger
// REAL affinity
case "real", "float", "float4", "float8", "double precision":
return TypeReal
// NUMERIC affinity
case "numeric", "decimal", "money":
return TypeNumeric
// BLOB affinity
case "bytea", "blob":
return TypeBlob
default:
// Default to TEXT for unknown types
return TypeText
}
return MapTypeToSQLite(pgType)
}
// IsIntegerType checks if a column type should be treated as integer
// IsIntegerType reports whether a column type maps to SQLite INTEGER affinity.
func IsIntegerType(colType string) bool {
normalized := strings.ToLower(strings.TrimSpace(colType))
normalized = strings.TrimSuffix(normalized, "[]")
if idx := strings.Index(normalized, "("); idx != -1 {
normalized = normalized[:idx]
}
switch normalized {
case "int", "int2", "int4", "int8", "integer", "smallint", "bigint",
"serial", "smallserial", "bigserial":
return true
default:
return false
}
return MapTypeToSQLite(colType) == TypeInteger
}
// MapBooleanValue converts PostgreSQL boolean literals to SQLite (0/1)
// MapBooleanValue converts common boolean literals to SQLite integers (1/0).
func MapBooleanValue(value string) string {
normalized := strings.ToLower(strings.TrimSpace(value))
switch normalized {
switch strings.ToLower(strings.TrimSpace(value)) {
case "true", "t", "yes", "y", "1":
return "1"
case "false", "f", "no", "n", "0":

Some files were not shown because too many files have changed in this diff Show More